From 5aad056bd662c98b554037361c78334de67e867c Mon Sep 17 00:00:00 2001 From: deadwavewave Date: Sun, 12 Jul 2026 18:10:38 +0800 Subject: [PATCH 1/2] fix agent session titles in completion notifications --- scripts/test-agent-session-stub.mjs | 6 + scripts/test-agent-session-stub/codex.mjs | 83 +++++++ .../useAgentStandbyNotificationTitleGate.ts | 77 +++++++ .../useAgentStandbyNotificationWatcher.ts | 12 +- .../hooks/useAgentStandbyNotifications.ts | 46 ++-- .../agentStandbyNotificationTitleGate.ts | 66 ++++++ .../hooks/useCanvasRuntimeBindings.ts | 7 +- .../hooks/usePtyTaskCompletion.ts | 91 +++++++- .../renderer/utils/agentSessionTitleSync.ts | 207 +++++++++++++++++ .../workspace-canvas.agent-launcher.spec.ts | 78 +++++++ .../agentStandbyNotificationTitleGate.spec.ts | 114 ++++++++++ tests/unit/contexts/agentLayoutSync.spec.tsx | 165 +++++++++++++- .../contexts/agentSessionTitleSync.spec.ts | 212 ++++++++++++++++++ 13 files changed, 1132 insertions(+), 32 deletions(-) create mode 100644 src/app/renderer/shell/hooks/useAgentStandbyNotificationTitleGate.ts create mode 100644 src/app/renderer/shell/utils/agentStandbyNotificationTitleGate.ts create mode 100644 src/contexts/workspace/presentation/renderer/utils/agentSessionTitleSync.ts create mode 100644 tests/unit/app/agentStandbyNotificationTitleGate.spec.ts create mode 100644 tests/unit/contexts/agentSessionTitleSync.spec.ts diff --git a/scripts/test-agent-session-stub.mjs b/scripts/test-agent-session-stub.mjs index 4f2cff3a3..a4a79cac2 100755 --- a/scripts/test-agent-session-stub.mjs +++ b/scripts/test-agent-session-stub.mjs @@ -7,6 +7,7 @@ import { runCodexClickRedrawAfterClickScenario, runCodexStandbyNoNewlineScenario, runCodexStandbyOnlyScenario, + runCodexTitleFromFirstInputScenario, runJsonlStdinSubmitDelayedTurnScenario, runJsonlStdinSubmitDrivenTurnScenario, } from './test-agent-session-stub/codex.mjs' @@ -81,6 +82,11 @@ async function main() { return } + if (provider === 'codex' && scenario === 'codex-title-from-first-input') { + await runCodexTitleFromFirstInputScenario(cwd) + return + } + if ( (provider === 'codex' || provider === 'claude-code') && scenario === 'jsonl-stdin-submit-delayed-turn' diff --git a/scripts/test-agent-session-stub/codex.mjs b/scripts/test-agent-session-stub/codex.mjs index 2ead9c2e7..e3fd9ec0a 100644 --- a/scripts/test-agent-session-stub/codex.mjs +++ b/scripts/test-agent-session-stub/codex.mjs @@ -118,4 +118,87 @@ export async function runCodexClickRedrawAfterClickScenario(cwd) { await runRawClickRedrawAfterClickScenario() } +function normalizeSubmittedPrompt(rawValue) { + let normalized = '' + + for (let index = 0; index < rawValue.length; index += 1) { + const characterCode = rawValue.charCodeAt(index) + if (characterCode === 27 && rawValue[index + 1] === '[') { + index += 2 + while (index < rawValue.length) { + const sequenceCode = rawValue.charCodeAt(index) + if (sequenceCode >= 64 && sequenceCode <= 126) { + break + } + index += 1 + } + continue + } + + if (characterCode < 32 || characterCode === 127) { + continue + } + + normalized += rawValue[index] + } + + return normalized.trim() +} + +export async function runCodexTitleFromFirstInputScenario(cwd) { + const sessionFilePath = await createCodexSessionFile(cwd) + let pendingInput = '' + let capturedFirstPrompt = false + + process.stdin.on('data', chunk => { + if (capturedFirstPrompt) { + return + } + + pendingInput += Buffer.from(chunk).toString('utf8') + const submitIndex = pendingInput.search(/[\r\n]/) + if (submitIndex === -1) { + return + } + + const prompt = normalizeSubmittedPrompt(pendingInput.slice(0, submitIndex)) + pendingInput = pendingInput.slice(submitIndex + 1) + if (prompt.length === 0) { + return + } + + capturedFirstPrompt = true + void (async () => { + await appendCodexRecord(sessionFilePath, { + type: 'response_item', + payload: { + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: prompt }], + }, + }) + await appendCodexRecord(sessionFilePath, { + type: 'event_msg', + payload: { + type: 'task_started', + turn_id: 'opencove-test-title-turn-1', + model_context_window: 128_000, + collaboration_mode_kind: 'default', + }, + }) + await sleep(50) + await appendCodexRecord(sessionFilePath, { + type: 'event_msg', + payload: { + type: 'task_complete', + turn_id: 'opencove-test-title-turn-1', + last_agent_message: 'Done.', + }, + }) + })() + }) + + await sleep(IDLE_SCENARIO_LIFETIME_MS) +} + export { runJsonlStdinSubmitDelayedTurnScenario, runJsonlStdinSubmitDrivenTurnScenario } diff --git a/src/app/renderer/shell/hooks/useAgentStandbyNotificationTitleGate.ts b/src/app/renderer/shell/hooks/useAgentStandbyNotificationTitleGate.ts new file mode 100644 index 000000000..8d421dede --- /dev/null +++ b/src/app/renderer/shell/hooks/useAgentStandbyNotificationTitleGate.ts @@ -0,0 +1,77 @@ +import { useCallback, useEffect, useRef } from 'react' +import { useAppStore } from '../store/useAppStore' +import { waitForAgentStandbyNotificationTitle } from '../utils/agentStandbyNotificationTitleGate' +import { + type AgentStandbyNotificationPayload, + resolveAgentNodeForSessionId, +} from './useAgentStandbyNotificationWatcher' + +export function useAgentStandbyNotificationTitleGate({ + enabled, + onReady, +}: { + enabled: boolean + onReady: (payload: AgentStandbyNotificationPayload) => void +}): { + deferUntilTitleReady: (payload: AgentStandbyNotificationPayload) => void + cancelPendingTitle: (sessionId: string) => void +} { + const pendingBySessionIdRef = useRef>(new Map()) + const readyHandlerRef = useRef(onReady) + + useEffect(() => { + readyHandlerRef.current = onReady + }, [onReady]) + + const deferUntilTitleReady = useCallback( + (payload: AgentStandbyNotificationPayload) => { + if (!enabled) { + return + } + + if (!payload.awaitingSessionTitle) { + readyHandlerRef.current(payload) + return + } + + if (pendingBySessionIdRef.current.has(payload.sessionId)) { + return + } + + const abortController = new AbortController() + pendingBySessionIdRef.current.set(payload.sessionId, abortController) + + void waitForAgentStandbyNotificationTitle({ + initial: payload, + resolveLatest: () => resolveAgentNodeForSessionId(payload.sessionId), + subscribe: listener => useAppStore.subscribe(listener), + signal: abortController.signal, + }).then(latest => { + if (pendingBySessionIdRef.current.get(payload.sessionId) !== abortController) { + return + } + + pendingBySessionIdRef.current.delete(payload.sessionId) + if (latest) { + readyHandlerRef.current(latest) + } + }) + }, + [enabled], + ) + + const cancelPendingTitle = useCallback((sessionId: string) => { + pendingBySessionIdRef.current.get(sessionId)?.abort() + pendingBySessionIdRef.current.delete(sessionId) + }, []) + + useEffect(() => { + const pending = pendingBySessionIdRef.current + return () => { + pending.forEach(abortController => abortController.abort()) + pending.clear() + } + }, []) + + return { deferUntilTitleReady, cancelPendingTitle } +} diff --git a/src/app/renderer/shell/hooks/useAgentStandbyNotificationWatcher.ts b/src/app/renderer/shell/hooks/useAgentStandbyNotificationWatcher.ts index 4da40caf9..9b5fb25bc 100644 --- a/src/app/renderer/shell/hooks/useAgentStandbyNotificationWatcher.ts +++ b/src/app/renderer/shell/hooks/useAgentStandbyNotificationWatcher.ts @@ -1,6 +1,7 @@ import { useEffect, useRef } from 'react' import type { TerminalSessionState, TerminalSessionStateEvent } from '@shared/contracts/dto' import type { AgentRuntimeStatus } from '@contexts/agent/domain/types' +import { isAgentNodeAwaitingSessionTitle } from '@contexts/workspace/presentation/renderer/utils/agentSessionTitleSync' import { useAppStore } from '../store/useAppStore' import { getPtyEventHub } from '../utils/ptyEventHub' @@ -13,12 +14,14 @@ function normalizeSessionId(rawValue: unknown): string | null { return trimmed.length > 0 ? trimmed : null } -function resolveAgentNodeForSessionId(sessionId: string): { +export function resolveAgentNodeForSessionId(sessionId: string): { + sessionId: string workspaceId: string workspaceName: string workspacePath: string nodeId: string title: string + awaitingSessionTitle: boolean runtimeStatus: AgentRuntimeStatus | null executionDirectory: string taskId: string | null @@ -32,15 +35,18 @@ function resolveAgentNodeForSessionId(sessionId: string): { } const taskId = node.data.agent?.taskId ?? null + const agent = node.data.agent const resolvedExecutionDirectory = - node.data.executionDirectory ?? node.data.agent?.executionDirectory ?? workspace.path + node.data.executionDirectory ?? agent?.executionDirectory ?? workspace.path return { + sessionId, workspaceId: workspace.id, workspaceName: workspace.name, workspacePath: workspace.path, nodeId: node.id, title: node.data.title, + awaitingSessionTitle: isAgentNodeAwaitingSessionTitle(node), runtimeStatus: node.data.status, executionDirectory: resolvedExecutionDirectory, taskId, @@ -58,6 +64,7 @@ export type AgentStandbyNotificationPayload = { workspacePath: string nodeId: string title: string + awaitingSessionTitle: boolean executionDirectory: string taskId: string | null } @@ -127,6 +134,7 @@ export function useAgentStandbyNotificationWatcher({ workspacePath: resolved.workspacePath, nodeId: resolved.nodeId, title: resolved.title, + awaitingSessionTitle: resolved.awaitingSessionTitle, executionDirectory: resolved.executionDirectory, taskId: resolved.taskId, }) diff --git a/src/app/renderer/shell/hooks/useAgentStandbyNotifications.ts b/src/app/renderer/shell/hooks/useAgentStandbyNotifications.ts index d60f15bf6..ff446c2de 100644 --- a/src/app/renderer/shell/hooks/useAgentStandbyNotifications.ts +++ b/src/app/renderer/shell/hooks/useAgentStandbyNotifications.ts @@ -8,6 +8,7 @@ import { type AgentStandbyNotificationPayload, useAgentStandbyNotificationWatcher, } from './useAgentStandbyNotificationWatcher' +import { useAgentStandbyNotificationTitleGate } from './useAgentStandbyNotificationTitleGate' function normalizeComparablePath(pathValue: string, platform?: string): string { const normalized = pathValue @@ -151,10 +152,7 @@ export function useAgentStandbyNotifications({ dismiss: (id: string) => void } { const { t } = useTranslation() - const platform = - typeof window !== 'undefined' && window.opencoveApi?.meta?.platform - ? window.opencoveApi.meta.platform - : undefined + const platform = window.opencoveApi?.meta?.platform const workspaces = useAppStore(state => state.workspaces) const areSystemNotificationsEnabled = useAppStore( state => state.agentSettings.systemNotificationsEnabled, @@ -183,7 +181,6 @@ export function useAgentStandbyNotifications({ const prInFlightByKeyRef = useRef>>( new Map(), ) - const workspacesById = useMemo(() => { return new Map(workspaces.map(workspace => [workspace.id, workspace])) }, [workspaces]) @@ -271,7 +268,7 @@ export function useAgentStandbyNotifications({ [platform], ) - const handleAgentEnteredStandby = useCallback( + const publishAgentEnteredStandby = useCallback( (payload: AgentStandbyNotificationPayload) => { if (!isStandbyBannerEnabled && !areSystemNotificationsEnabled) { return @@ -321,31 +318,28 @@ export function useAgentStandbyNotifications({ return previous } - if (!shouldResolveBranch && !shouldResolvePullRequest) { - const updated = [next, ...previous] - return updated.length > maxVisible ? updated.slice(0, maxVisible) : updated - } - const updated = [next, ...previous] return updated.length > maxVisible ? updated.slice(0, maxVisible) : updated }) }, - [ - areSystemNotificationsEnabled, - isStandbyBannerEnabled, - maxVisible, - shouldResolveBranch, - shouldResolvePullRequest, - t, - workspacesById, - ], + [areSystemNotificationsEnabled, isStandbyBannerEnabled, maxVisible, t, workspacesById], ) - const handleAgentEnteredWorking = useCallback((sessionId: string) => { - setNotifications(previous => - previous.filter(notification => notification.sessionId !== sessionId), - ) - }, []) + const { deferUntilTitleReady: handleAgentEnteredStandby, cancelPendingTitle } = + useAgentStandbyNotificationTitleGate({ + enabled: isStandbyBannerEnabled || areSystemNotificationsEnabled, + onReady: publishAgentEnteredStandby, + }) + + const handleAgentEnteredWorking = useCallback( + (sessionId: string) => { + cancelPendingTitle(sessionId) + setNotifications(previous => + previous.filter(notification => notification.sessionId !== sessionId), + ) + }, + [cancelPendingTitle], + ) useEffect(() => { if (isStandbyBannerEnabled) { @@ -490,7 +484,7 @@ export function useAgentStandbyNotifications({ ]) useAgentStandbyNotificationWatcher({ - enabled: isStandbyBannerEnabled, + enabled: isStandbyBannerEnabled || areSystemNotificationsEnabled, onAgentEnteredStandby: handleAgentEnteredStandby, onAgentEnteredWorking: handleAgentEnteredWorking, }) diff --git a/src/app/renderer/shell/utils/agentStandbyNotificationTitleGate.ts b/src/app/renderer/shell/utils/agentStandbyNotificationTitleGate.ts new file mode 100644 index 000000000..d0cde9c42 --- /dev/null +++ b/src/app/renderer/shell/utils/agentStandbyNotificationTitleGate.ts @@ -0,0 +1,66 @@ +import type { AgentStandbyNotificationPayload } from '../hooks/useAgentStandbyNotificationWatcher' + +export const AGENT_STANDBY_TITLE_WAIT_TIMEOUT_MS = 1_000 + +export async function waitForAgentStandbyNotificationTitle({ + initial, + resolveLatest, + subscribe, + signal, + timeoutMs = AGENT_STANDBY_TITLE_WAIT_TIMEOUT_MS, +}: { + initial: AgentStandbyNotificationPayload + resolveLatest: () => AgentStandbyNotificationPayload | null + subscribe: (listener: () => void) => () => void + signal?: AbortSignal + timeoutMs?: number +}): Promise { + if (signal?.aborted) { + return null + } + + if (!initial.awaitingSessionTitle) { + return initial + } + + return await new Promise(resolve => { + let settled = false + let timeoutId: number | null = null + let unsubscribe: (() => void) | null = null + + const finish = (payload: AgentStandbyNotificationPayload | null): void => { + if (settled) { + return + } + + settled = true + if (timeoutId !== null) { + window.clearTimeout(timeoutId) + } + unsubscribe?.() + signal?.removeEventListener('abort', handleAbort) + resolve(payload) + } + + const handleAbort = (): void => { + finish(null) + } + + const resolveIfReady = (): void => { + const latest = resolveLatest() + if (!latest || !latest.awaitingSessionTitle || latest.title !== initial.title) { + finish(latest) + } + } + + unsubscribe = subscribe(resolveIfReady) + signal?.addEventListener('abort', handleAbort, { once: true }) + timeoutId = window.setTimeout( + () => { + finish(resolveLatest()) + }, + Math.max(0, timeoutMs), + ) + resolveIfReady() + }) +} diff --git a/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/hooks/useCanvasRuntimeBindings.ts b/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/hooks/useCanvasRuntimeBindings.ts index d7eff0b50..d9313d263 100644 --- a/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/hooks/useCanvasRuntimeBindings.ts +++ b/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/hooks/useCanvasRuntimeBindings.ts @@ -60,7 +60,12 @@ export function useWorkspaceCanvasRuntimeBindings({ reactFlow: Parameters[0]['reactFlow'] onShowMessage?: Parameters[0]['onShowMessage'] }): void { - useWorkspaceCanvasPtyTaskCompletion({ setNodes, onRequestPersistFlush }) + useWorkspaceCanvasPtyTaskCompletion({ + nodesRef, + setNodes, + listAgentSessions, + onRequestPersistFlush, + }) const copyAgentLastMessage = useWorkspaceCanvasAgentLastMessageCopy({ nodesRef, diff --git a/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/hooks/usePtyTaskCompletion.ts b/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/hooks/usePtyTaskCompletion.ts index 79978ec70..1d6eb53f4 100644 --- a/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/hooks/usePtyTaskCompletion.ts +++ b/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/hooks/usePtyTaskCompletion.ts @@ -1,8 +1,16 @@ -import { useEffect } from 'react' +import { useEffect, type MutableRefObject } from 'react' import { getPtyEventHub } from '@app/renderer/shell/utils/ptyEventHub' import type { Node } from '@xyflow/react' import { resolveObservedResumeSessionBindingUpdate } from '@contexts/agent/domain/agentResumeBinding' +import type { AgentSessionSummary, TerminalSessionMetadataEvent } from '@shared/contracts/dto' import type { AgentRuntimeStatus, TerminalNodeData } from '../../../types' +import { + applyAgentSessionTitleToNodes, + isAgentNodeAwaitingSessionTitle, + isAgentSessionTitleSyncTargetCurrent, + loadAgentSessionTitle, + resolveAgentSessionTitleSyncTarget, +} from '../../../utils/agentSessionTitleSync' export function applyAgentStateToNodes( prevNodes: Node[], @@ -114,25 +122,98 @@ export function applyAgentMetadataToNodes( } export function useWorkspaceCanvasPtyTaskCompletion({ + nodesRef, setNodes, + listAgentSessions, onRequestPersistFlush, }: { + nodesRef?: MutableRefObject[]> setNodes: ( updater: (prevNodes: Node[]) => Node[], options?: { syncLayout?: boolean }, ) => void + listAgentSessions?: (nodeId: string, limit?: number) => Promise onRequestPersistFlush?: () => void }): void { useEffect(() => { const ptyEventHub = getPtyEventHub() + const titleSyncByNodeId = new Map() + const metadataByRuntimeSessionId = new Map() + let disposed = false + + const startTitleSync = (event: TerminalSessionMetadataEvent, restart: boolean): void => { + const titleSyncTarget = nodesRef + ? resolveAgentSessionTitleSyncTarget(nodesRef.current, event) + : null + + if (!titleSyncTarget || !nodesRef || !listAgentSessions) { + return + } + + if ( + restart && + !nodesRef.current.some( + node => node.id === titleSyncTarget.nodeId && isAgentNodeAwaitingSessionTitle(node), + ) + ) { + return + } + + const syncKey = `${titleSyncTarget.runtimeSessionId}:${titleSyncTarget.resumeSessionId}` + const previousSync = titleSyncByNodeId.get(titleSyncTarget.nodeId) + if (previousSync?.key === syncKey && !restart) { + return + } + + previousSync?.abortController.abort() + const abortController = new AbortController() + titleSyncByNodeId.set(titleSyncTarget.nodeId, { + key: syncKey, + abortController, + }) + + void loadAgentSessionTitle({ + resumeSessionId: titleSyncTarget.resumeSessionId, + listSessions: () => listAgentSessions(titleSyncTarget.nodeId, 20), + isCurrent: () => + !disposed && isAgentSessionTitleSyncTargetCurrent(nodesRef.current, titleSyncTarget), + signal: abortController.signal, + }).then(sessionTitle => { + if (!sessionTitle || disposed || abortController.signal.aborted) { + return + } + + let titleDidChange = false + setNodes( + prevNodes => { + const result = applyAgentSessionTitleToNodes(prevNodes, titleSyncTarget, sessionTitle) + titleDidChange = result.didChange + return result.nextNodes + }, + { syncLayout: false }, + ) + + if (titleDidChange) { + onRequestPersistFlush?.() + } + }) + } const unsubscribeState = ptyEventHub.onState(event => { setNodes(prevNodes => applyAgentStateToNodes(prevNodes, event).nextNodes, { syncLayout: false, }) + + if (event.state === 'standby') { + const metadata = metadataByRuntimeSessionId.get(event.sessionId) + if (metadata) { + startTitleSync(metadata, true) + } + } }) const unsubscribeMetadata = ptyEventHub.onMetadata(event => { + metadataByRuntimeSessionId.set(event.sessionId, event) let didChange = false setNodes( @@ -147,6 +228,8 @@ export function useWorkspaceCanvasPtyTaskCompletion({ if (didChange) { onRequestPersistFlush?.() } + + startTitleSync(event, false) }) const unsubscribeExit = ptyEventHub.onExit(event => { @@ -167,9 +250,13 @@ export function useWorkspaceCanvasPtyTaskCompletion({ }) return () => { + disposed = true + titleSyncByNodeId.forEach(sync => sync.abortController.abort()) + titleSyncByNodeId.clear() + metadataByRuntimeSessionId.clear() unsubscribeState() unsubscribeMetadata() unsubscribeExit() } - }, [onRequestPersistFlush, setNodes]) + }, [listAgentSessions, nodesRef, onRequestPersistFlush, setNodes]) } diff --git a/src/contexts/workspace/presentation/renderer/utils/agentSessionTitleSync.ts b/src/contexts/workspace/presentation/renderer/utils/agentSessionTitleSync.ts new file mode 100644 index 000000000..4a344ccd9 --- /dev/null +++ b/src/contexts/workspace/presentation/renderer/utils/agentSessionTitleSync.ts @@ -0,0 +1,207 @@ +import type { Node } from '@xyflow/react' +import { isResumeSessionBindingVerified } from '@contexts/agent/domain/agentResumeBinding' +import type { AgentProvider } from '@contexts/settings/domain/agentSettings' +import type { AgentSessionSummary, TerminalSessionMetadataEvent } from '@shared/contracts/dto' +import type { TerminalNodeData } from '../types' +import { buildAgentNodeTitle } from './agentTitle' + +export const AGENT_SESSION_TITLE_RETRY_DELAYS_MS = [0, 200, 600, 1_200, 2_500] as const + +export interface AgentSessionTitleSyncTarget { + nodeId: string + runtimeSessionId: string + resumeSessionId: string + provider: AgentProvider +} + +interface LoadAgentSessionTitleInput { + resumeSessionId: string + listSessions: () => Promise + isCurrent: () => boolean + retryDelaysMs?: readonly number[] + signal?: AbortSignal + sleep?: (delayMs: number, signal?: AbortSignal) => Promise +} + +function normalizeOptionalString(value: string | null | undefined): string | null { + const normalized = value?.trim() ?? '' + return normalized.length > 0 ? normalized : null +} + +function waitForRetry(delayMs: number, signal?: AbortSignal): Promise { + if (delayMs <= 0 || signal?.aborted) { + return Promise.resolve() + } + + return new Promise(resolve => { + const timeout = window.setTimeout(() => { + signal?.removeEventListener('abort', handleAbort) + resolve() + }, delayMs) + const handleAbort = (): void => { + window.clearTimeout(timeout) + resolve() + } + signal?.addEventListener('abort', handleAbort, { once: true }) + }) +} + +function isDirectAgentTitleTarget(node: Node, runtimeSessionId: string): boolean { + return ( + node.data.kind === 'agent' && + node.data.agent !== null && + node.data.sessionId === runtimeSessionId && + node.data.agent.launchMode === 'new' && + node.data.agent.taskId === null && + node.data.titlePinnedByUser !== true + ) +} + +export function isAgentNodeAwaitingSessionTitle(node: Node): boolean { + if ( + node.data.kind !== 'agent' || + !node.data.agent || + node.data.agent.launchMode !== 'new' || + node.data.agent.taskId !== null || + node.data.titlePinnedByUser === true + ) { + return false + } + + return ( + node.data.title === + buildAgentNodeTitle( + node.data.agent.provider, + node.data.agent.effectiveModel ?? node.data.agent.model, + ) + ) +} + +export function resolveAgentSessionTitleSyncTarget( + nodes: readonly Node[], + event: TerminalSessionMetadataEvent, +): AgentSessionTitleSyncTarget | null { + const runtimeSessionId = normalizeOptionalString(event.sessionId) + const resumeSessionId = normalizeOptionalString(event.resumeSessionId) + if (!runtimeSessionId || !resumeSessionId) { + return null + } + + const node = nodes.find(candidate => candidate.data.sessionId === runtimeSessionId) + if (!node || !isDirectAgentTitleTarget(node, runtimeSessionId) || !node.data.agent) { + return null + } + + if ( + isResumeSessionBindingVerified(node.data.agent) && + node.data.agent.resumeSessionId !== resumeSessionId + ) { + return null + } + + return { + nodeId: node.id, + runtimeSessionId, + resumeSessionId, + provider: node.data.agent.provider, + } +} + +export function isAgentSessionTitleSyncTargetCurrent( + nodes: readonly Node[], + target: AgentSessionTitleSyncTarget, +): boolean { + const node = nodes.find(candidate => candidate.id === target.nodeId) + if (!node || !isDirectAgentTitleTarget(node, target.runtimeSessionId) || !node.data.agent) { + return false + } + + return ( + node.data.agent.provider === target.provider && + (!isResumeSessionBindingVerified(node.data.agent) || + node.data.agent.resumeSessionId === target.resumeSessionId) + ) +} + +export function applyAgentSessionTitleToNodes( + prevNodes: Node[], + target: AgentSessionTitleSyncTarget, + sessionTitle: string, +): { nextNodes: Node[]; didChange: boolean } { + const normalizedTitle = normalizeOptionalString(sessionTitle) + if (!normalizedTitle) { + return { nextNodes: prevNodes, didChange: false } + } + + let didChange = false + const nextNodes = prevNodes.map(node => { + if ( + node.id !== target.nodeId || + !isDirectAgentTitleTarget(node, target.runtimeSessionId) || + !node.data.agent || + node.data.agent.provider !== target.provider || + !isResumeSessionBindingVerified(node.data.agent) || + node.data.agent.resumeSessionId !== target.resumeSessionId + ) { + return node + } + + const title = buildAgentNodeTitle(target.provider, normalizedTitle) + if (node.data.title === title) { + return node + } + + didChange = true + return { + ...node, + data: { + ...node.data, + title, + }, + } + }) + + return { nextNodes: didChange ? nextNodes : prevNodes, didChange } +} + +export async function loadAgentSessionTitle({ + resumeSessionId, + listSessions, + isCurrent, + retryDelaysMs = AGENT_SESSION_TITLE_RETRY_DELAYS_MS, + signal, + sleep = waitForRetry, +}: LoadAgentSessionTitleInput): Promise { + for (const delayMs of retryDelaysMs) { + if (signal?.aborted) { + return null + } + + if (delayMs > 0) { + // eslint-disable-next-line no-await-in-loop -- title availability retries must stay ordered + await sleep(delayMs, signal) + } + + if (signal?.aborted || !isCurrent()) { + return null + } + + try { + // eslint-disable-next-line no-await-in-loop -- stop as soon as the exact session title exists + const sessions = await listSessions() + if (signal?.aborted || !isCurrent()) { + return null + } + + const summary = sessions.find(candidate => candidate.sessionId === resumeSessionId) + const title = normalizeOptionalString(summary?.title) + if (title) { + return title + } + } catch { + // Title resolution is best effort and retries without affecting the Agent runtime. + } + } + + return null +} diff --git a/tests/e2e/workspace-canvas.agent-launcher.spec.ts b/tests/e2e/workspace-canvas.agent-launcher.spec.ts index 3650e92cf..4d9537555 100644 --- a/tests/e2e/workspace-canvas.agent-launcher.spec.ts +++ b/tests/e2e/workspace-canvas.agent-launcher.spec.ts @@ -55,4 +55,82 @@ test.describe('Workspace Canvas - Agent Launcher', () => { await electronApp.close() } }) + + test('renames a directly launched Agent from its first session message', async ({ + browserName: _browserName, + }, testInfo) => { + const { electronApp, window } = await launchApp({ + windowMode: 'offscreen', + env: { + OPENCOVE_TEST_ENABLE_SESSION_STATE_WATCHER: '1', + OPENCOVE_TEST_AGENT_SESSION_SCENARIO: 'codex-title-from-first-input', + }, + }) + + try { + await clearAndSeedWorkspace(window, [], { + settings: { + defaultProvider: 'codex', + customModelEnabledByProvider: { + 'claude-code': false, + codex: true, + }, + customModelByProvider: { + 'claude-code': '', + codex: 'gpt-5.2-codex', + }, + customModelOptionsByProvider: { + 'claude-code': [], + codex: ['gpt-5.2-codex'], + }, + }, + }) + + const pane = window.locator('.workspace-canvas .react-flow__pane') + await pane.click({ button: 'right', position: { x: 320, y: 220 } }) + await window.locator('[data-testid="workspace-context-run-default-agent"]').click() + + const agentNode = window.locator('.terminal-node').first() + const title = agentNode.locator('[data-testid="terminal-node-title-display"]') + const terminalInput = agentNode.locator('.xterm-helper-textarea') + await expect(agentNode).toBeVisible() + await expect(title).toContainText('gpt-5.2-codex') + + await terminalInput.focus() + await expect(terminalInput).toBeFocused() + await window.keyboard.type('Rename direct Agent windows') + await window.keyboard.press('Enter') + + await expect(title).toHaveText('codex · Rename direct Agent windows', { timeout: 15_000 }) + const completionNotification = window + .locator('[data-testid="app-notifications"] .app-notification') + .first() + await expect(completionNotification).toBeVisible({ timeout: 15_000 }) + await expect(completionNotification.locator('.app-notification__title')).toHaveText( + 'codex · Rename direct Agent windows', + ) + await expect + .poll(async () => { + return await window.evaluate(async () => { + const raw = await window.opencoveApi.persistence.readWorkspaceStateRaw() + if (!raw) { + return null + } + + const state = JSON.parse(raw) as { + workspaces?: Array<{ nodes?: Array<{ title?: string }> }> + } + return state.workspaces?.[0]?.nodes?.[0]?.title ?? null + }) + }) + .toBe('codex · Rename direct Agent windows') + + await testInfo.attach('direct-agent-session-title', { + body: await window.screenshot(), + contentType: 'image/png', + }) + } finally { + await electronApp.close() + } + }) }) diff --git a/tests/unit/app/agentStandbyNotificationTitleGate.spec.ts b/tests/unit/app/agentStandbyNotificationTitleGate.spec.ts new file mode 100644 index 000000000..7409e50ba --- /dev/null +++ b/tests/unit/app/agentStandbyNotificationTitleGate.spec.ts @@ -0,0 +1,114 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { AgentStandbyNotificationPayload } from '../../../src/app/renderer/shell/hooks/useAgentStandbyNotificationWatcher' +import { + AGENT_STANDBY_TITLE_WAIT_TIMEOUT_MS, + waitForAgentStandbyNotificationTitle, +} from '../../../src/app/renderer/shell/utils/agentStandbyNotificationTitleGate' + +function createPayload( + overrides: Partial = {}, +): AgentStandbyNotificationPayload { + return { + sessionId: 'runtime-session-1', + workspaceId: 'workspace-1', + workspaceName: 'OpenCove', + workspacePath: '/tmp/opencove', + nodeId: 'agent-1', + title: 'codex · gpt-5.2-codex', + awaitingSessionTitle: true, + executionDirectory: '/tmp/opencove', + taskId: null, + ...overrides, + } +} + +function createPayloadSource(initial: AgentStandbyNotificationPayload | null) { + let current = initial + const listeners = new Set<() => void>() + + return { + resolveLatest: () => current, + subscribe: (listener: () => void) => { + listeners.add(listener) + return () => listeners.delete(listener) + }, + update(next: AgentStandbyNotificationPayload | null) { + current = next + listeners.forEach(listener => listener()) + }, + } +} + +describe('waitForAgentStandbyNotificationTitle', () => { + afterEach(() => { + vi.useRealTimers() + }) + + it('emits as soon as the session title replaces the launch title', async () => { + vi.useFakeTimers() + const initial = createPayload() + const source = createPayloadSource(initial) + const pending = waitForAgentStandbyNotificationTitle({ + initial, + resolveLatest: source.resolveLatest, + subscribe: source.subscribe, + }) + + source.update( + createPayload({ + title: 'codex · Rename direct Agent windows', + awaitingSessionTitle: false, + }), + ) + + await expect(pending).resolves.toMatchObject({ + title: 'codex · Rename direct Agent windows', + }) + }) + + it('falls back to the latest title after at most one second', async () => { + vi.useFakeTimers() + const initial = createPayload() + const source = createPayloadSource(initial) + const pending = waitForAgentStandbyNotificationTitle({ + initial, + resolveLatest: source.resolveLatest, + subscribe: source.subscribe, + }) + + await vi.advanceTimersByTimeAsync(AGENT_STANDBY_TITLE_WAIT_TIMEOUT_MS - 1) + let didSettle = false + void pending.then(() => { + didSettle = true + }) + await Promise.resolve() + expect(didSettle).toBe(false) + + await vi.advanceTimersByTimeAsync(1) + await expect(pending).resolves.toEqual(initial) + }) + + it('does not emit after the pending notification is cancelled', async () => { + vi.useFakeTimers() + const initial = createPayload() + const source = createPayloadSource(initial) + const abortController = new AbortController() + const pending = waitForAgentStandbyNotificationTitle({ + initial, + resolveLatest: source.resolveLatest, + subscribe: source.subscribe, + signal: abortController.signal, + }) + + abortController.abort() + source.update( + createPayload({ + title: 'codex · A stale title', + awaitingSessionTitle: false, + }), + ) + await vi.advanceTimersByTimeAsync(AGENT_STANDBY_TITLE_WAIT_TIMEOUT_MS) + + await expect(pending).resolves.toBeNull() + }) +}) diff --git a/tests/unit/contexts/agentLayoutSync.spec.tsx b/tests/unit/contexts/agentLayoutSync.spec.tsx index 49c359b79..1868d15f0 100644 --- a/tests/unit/contexts/agentLayoutSync.spec.tsx +++ b/tests/unit/contexts/agentLayoutSync.spec.tsx @@ -1,5 +1,5 @@ import React, { useEffect } from 'react' -import { render, waitFor } from '@testing-library/react' +import { act, render, waitFor } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' import type { Node } from '@xyflow/react' import type { TerminalNodeData } from '../../../src/contexts/workspace/presentation/renderer/types' @@ -177,4 +177,167 @@ describe('agent terminal layout sync', () => { expect(setNodes.mock.calls[0]?.[1]).toEqual({ syncLayout: false }) }) + + it('updates a directly launched Agent title after session metadata resolves', async () => { + let onMetadataListener: + | ((event: { sessionId: string; resumeSessionId: string | null }) => void) + | null = null + const node = createAgentNode() + node.data.sessionId = 'runtime-1' + const nodesRef = { + current: [node], + } as React.MutableRefObject[]> + const setNodes = vi.fn( + ( + updater: (prevNodes: Node[]) => Node[], + _options?: { syncLayout?: boolean }, + ) => { + nodesRef.current = updater(nodesRef.current) + }, + ) + const listAgentSessions = vi.fn(async () => [ + { + sessionId: 'resume-1', + provider: 'codex' as const, + cwd: '/tmp/project', + title: 'Rename direct Agent windows', + startedAt: '2026-07-12T00:00:00.000Z', + updatedAt: '2026-07-12T00:00:01.000Z', + source: 'codex-file' as const, + }, + ]) + const onRequestPersistFlush = vi.fn() + + Object.defineProperty(window, 'opencoveApi', { + configurable: true, + writable: true, + value: { + pty: { + onData: vi.fn(() => () => undefined), + onExit: vi.fn(() => () => undefined), + onState: vi.fn(() => () => undefined), + onMetadata: vi.fn((listener: typeof onMetadataListener) => { + onMetadataListener = listener + return () => { + onMetadataListener = null + } + }), + }, + }, + }) + + function Harness(): null { + useWorkspaceCanvasPtyTaskCompletion({ + nodesRef, + setNodes, + listAgentSessions, + onRequestPersistFlush, + }) + return null + } + + render() + + act(() => { + onMetadataListener?.({ sessionId: 'runtime-1', resumeSessionId: 'resume-1' }) + }) + + await waitFor(() => { + expect(nodesRef.current[0]?.data.title).toBe('codex · Rename direct Agent windows') + }) + + expect(listAgentSessions).toHaveBeenCalledWith('agent-1', 20) + expect(onRequestPersistFlush).toHaveBeenCalledTimes(2) + expect(setNodes.mock.calls.every(call => call[1]?.syncLayout === false)).toBe(true) + }) + + it('restarts an unresolved title lookup immediately when the Agent enters standby', async () => { + vi.useFakeTimers() + try { + let onStateListener: + | ((event: { sessionId: string; state: 'working' | 'standby' }) => void) + | null = null + let onMetadataListener: + | ((event: { sessionId: string; resumeSessionId: string | null }) => void) + | null = null + const node = createAgentNode() + node.data.sessionId = 'runtime-1' + node.data.title = 'codex · gpt-5.2-codex' + const nodesRef = { + current: [node], + } as React.MutableRefObject[]> + const setNodes = vi.fn( + ( + updater: (prevNodes: Node[]) => Node[], + _options?: { syncLayout?: boolean }, + ) => { + nodesRef.current = updater(nodesRef.current) + }, + ) + const listAgentSessions = vi + .fn() + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([ + { + sessionId: 'resume-1', + provider: 'codex' as const, + cwd: '/tmp/project', + title: 'Short first session', + startedAt: '2026-07-12T00:00:00.000Z', + updatedAt: '2026-07-12T00:00:01.000Z', + source: 'codex-file' as const, + }, + ]) + + Object.defineProperty(window, 'opencoveApi', { + configurable: true, + writable: true, + value: { + pty: { + onData: vi.fn(() => () => undefined), + onExit: vi.fn(() => () => undefined), + onState: vi.fn((listener: typeof onStateListener) => { + onStateListener = listener + return () => { + onStateListener = null + } + }), + onMetadata: vi.fn((listener: typeof onMetadataListener) => { + onMetadataListener = listener + return () => { + onMetadataListener = null + } + }), + }, + }, + }) + + function Harness(): null { + useWorkspaceCanvasPtyTaskCompletion({ + nodesRef, + setNodes, + listAgentSessions, + }) + return null + } + + render() + + await act(async () => { + onMetadataListener?.({ sessionId: 'runtime-1', resumeSessionId: 'resume-1' }) + await Promise.resolve() + }) + expect(listAgentSessions).toHaveBeenCalledTimes(1) + + await act(async () => { + onStateListener?.({ sessionId: 'runtime-1', state: 'standby' }) + await Promise.resolve() + }) + + expect(listAgentSessions).toHaveBeenCalledTimes(2) + expect(nodesRef.current[0]?.data.title).toBe('codex · Short first session') + } finally { + vi.useRealTimers() + } + }) }) diff --git a/tests/unit/contexts/agentSessionTitleSync.spec.ts b/tests/unit/contexts/agentSessionTitleSync.spec.ts new file mode 100644 index 000000000..2cf4cf6ef --- /dev/null +++ b/tests/unit/contexts/agentSessionTitleSync.spec.ts @@ -0,0 +1,212 @@ +import { describe, expect, it, vi } from 'vitest' +import type { Node } from '@xyflow/react' +import type { AgentSessionSummary } from '../../../src/shared/contracts/dto' +import type { TerminalNodeData } from '../../../src/contexts/workspace/presentation/renderer/types' +import { + applyAgentSessionTitleToNodes, + isAgentNodeAwaitingSessionTitle, + loadAgentSessionTitle, + resolveAgentSessionTitleSyncTarget, +} from '../../../src/contexts/workspace/presentation/renderer/utils/agentSessionTitleSync' + +function createAgentNode( + overrides: Partial & { + agent?: Partial> + } = {}, +): Node { + const { agent: agentOverrides, ...dataOverrides } = overrides + + return { + id: 'agent-1', + type: 'terminalNode', + position: { x: 0, y: 0 }, + data: { + sessionId: 'runtime-1', + title: 'codex · default', + titlePinnedByUser: false, + width: 520, + height: 360, + kind: 'agent', + status: 'running', + startedAt: '2026-07-12T00:00:00.000Z', + endedAt: null, + exitCode: null, + lastError: null, + scrollback: null, + agent: { + provider: 'codex', + prompt: '', + model: null, + effectiveModel: null, + launchMode: 'new', + resumeSessionId: null, + resumeSessionIdVerified: false, + executionDirectory: '/tmp/project', + expectedDirectory: '/tmp/project', + directoryMode: 'workspace', + customDirectory: null, + shouldCreateDirectory: false, + taskId: null, + ...agentOverrides, + }, + task: null, + note: null, + image: null, + document: null, + website: null, + ...dataOverrides, + }, + } +} + +function createSessionSummary(title: string | null): AgentSessionSummary { + return { + sessionId: 'resume-1', + provider: 'codex', + cwd: '/tmp/project', + title, + startedAt: '2026-07-12T00:00:00.000Z', + updatedAt: '2026-07-12T00:00:01.000Z', + source: 'codex-file', + } +} + +describe('direct Agent session title sync', () => { + it('waits only while an eligible direct Agent still has its launch title', () => { + expect( + isAgentNodeAwaitingSessionTitle( + createAgentNode({ + title: 'codex · gpt-5.2-codex', + agent: { model: 'gpt-5.2-codex', effectiveModel: 'gpt-5.2-codex' }, + }), + ), + ).toBe(true) + expect( + isAgentNodeAwaitingSessionTitle( + createAgentNode({ + title: 'codex · Rename direct Agent windows', + agent: { model: 'gpt-5.2-codex', effectiveModel: 'gpt-5.2-codex' }, + }), + ), + ).toBe(false) + }) + + it('creates a sync target only for an unpinned directly launched Agent', () => { + expect( + resolveAgentSessionTitleSyncTarget([createAgentNode()], { + sessionId: 'runtime-1', + resumeSessionId: 'resume-1', + }), + ).toEqual({ + nodeId: 'agent-1', + runtimeSessionId: 'runtime-1', + resumeSessionId: 'resume-1', + provider: 'codex', + }) + + expect( + resolveAgentSessionTitleSyncTarget([createAgentNode({ titlePinnedByUser: true })], { + sessionId: 'runtime-1', + resumeSessionId: 'resume-1', + }), + ).toBeNull() + + expect( + resolveAgentSessionTitleSyncTarget([createAgentNode({ agent: { taskId: 'task-1' } })], { + sessionId: 'runtime-1', + resumeSessionId: 'resume-1', + }), + ).toBeNull() + }) + + it('ignores metadata that conflicts with an existing verified session binding', () => { + const node = createAgentNode({ + agent: { + resumeSessionId: 'resume-current', + resumeSessionIdVerified: true, + }, + }) + + expect( + resolveAgentSessionTitleSyncTarget([node], { + sessionId: 'runtime-1', + resumeSessionId: 'resume-stale', + }), + ).toBeNull() + }) + + it('updates the title only while the runtime and durable session still match', () => { + const target = { + nodeId: 'agent-1', + runtimeSessionId: 'runtime-1', + resumeSessionId: 'resume-1', + provider: 'codex' as const, + } + const boundNode = createAgentNode({ + agent: { + resumeSessionId: 'resume-1', + resumeSessionIdVerified: true, + }, + }) + + const updated = applyAgentSessionTitleToNodes([boundNode], target, 'Fix Agent titles') + expect(updated.didChange).toBe(true) + expect(updated.nextNodes[0]?.data.title).toBe('codex · Fix Agent titles') + + const pinned = applyAgentSessionTitleToNodes( + [{ ...boundNode, data: { ...boundNode.data, titlePinnedByUser: true } }], + target, + 'Stale title', + ) + expect(pinned.didChange).toBe(false) + + const switched = applyAgentSessionTitleToNodes( + [ + createAgentNode({ + sessionId: 'runtime-2', + agent: { resumeSessionId: 'resume-2', resumeSessionIdVerified: true }, + }), + ], + target, + 'Stale title', + ) + expect(switched.didChange).toBe(false) + }) + + it('retries bounded session catalog reads until the exact session has a title', async () => { + const listSessions = vi + .fn<() => Promise>() + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([createSessionSummary(null)]) + .mockResolvedValueOnce([createSessionSummary('Fix Agent titles')]) + const sleep = vi.fn(async () => undefined) + + const title = await loadAgentSessionTitle({ + resumeSessionId: 'resume-1', + listSessions, + retryDelaysMs: [0, 25, 75], + sleep, + isCurrent: () => true, + }) + + expect(title).toBe('Fix Agent titles') + expect(listSessions).toHaveBeenCalledTimes(3) + expect(sleep).toHaveBeenCalledTimes(2) + }) + + it('stops retrying when the target becomes stale', async () => { + const listSessions = vi.fn(async () => [] as AgentSessionSummary[]) + const isCurrent = vi.fn().mockReturnValueOnce(true).mockReturnValue(false) + + const title = await loadAgentSessionTitle({ + resumeSessionId: 'resume-1', + listSessions, + retryDelaysMs: [0, 25, 75], + sleep: async () => undefined, + isCurrent, + }) + + expect(title).toBeNull() + expect(listSessions).toHaveBeenCalledTimes(1) + }) +}) From 1e0f42e1c933be3878f0ec5316c9717462ddfe41 Mon Sep 17 00:00:00 2001 From: deadwavewave Date: Sun, 12 Jul 2026 18:11:34 +0800 Subject: [PATCH 2/2] document agent session title fix --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3381d3e06..b7fcd9d45 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -94,6 +94,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), - Spaces: allow empty Spaces (no last-node warning/auto-close), add pane context menu action to create an empty Space, and allow archiving a Space without saving its history. (#171) ### 🐞 Fixed +- Agent: derive directly launched Agent titles from their first bound session and keep short-turn completion notifications aligned with the resolved title. (#309) - Worktrees: prevent background Git status polling from acquiring index locks or leaving stale locks when commands time out. (#305) - Worktree create: keep branch dropdowns above the Space worktree dialog so branches remain visible and selectable. (#304) - Sidebar: keep the collapsed hover-reveal open while Project, Space, or Agent context menus are active, so menu interactions do not leave the sidebar collapsed underneath them. (#303)