From f8c9a3ec09739072ae4ce9904c21b4104618d18c Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Tue, 1 Sep 2026 22:10:39 +0800 Subject: [PATCH 1/7] fix(web-shell): keep manual title across clear --- ...2026-09-01-web-shell-clear-manual-title.md | 11 +++ packages/acp-bridge/src/bridge.test.ts | 7 +- packages/acp-bridge/src/bridge.ts | 6 +- packages/acp-bridge/src/bridgeTypes.ts | 1 + .../standalone-session-service.ts | 3 + packages/cli/src/serve/server.test.ts | 22 +++++- packages/cli/src/serve/server/session-list.ts | 4 ++ packages/sdk-typescript/src/daemon/events.ts | 6 +- packages/sdk-typescript/src/daemon/types.ts | 1 + .../test/unit/daemonEvents.test.ts | 15 +++- packages/web-shell/client/App.test.tsx | 71 +++++++++++++++++++ packages/web-shell/client/App.tsx | 48 ++++++++++++- .../client/daemon/session/actions.test.ts | 2 + .../client/daemon/session/actions.ts | 2 + .../client/daemon/session/mappers.test.ts | 23 ++++++ .../client/daemon/session/mappers.ts | 8 ++- .../web-shell/client/daemon/session/types.ts | 1 + 17 files changed, 221 insertions(+), 10 deletions(-) create mode 100644 docs/design/2026-09-01-web-shell-clear-manual-title.md diff --git a/docs/design/2026-09-01-web-shell-clear-manual-title.md b/docs/design/2026-09-01-web-shell-clear-manual-title.md new file mode 100644 index 00000000000..ed6b601a5c4 --- /dev/null +++ b/docs/design/2026-09-01-web-shell-clear-manual-title.md @@ -0,0 +1,11 @@ +# Preserve manual titles across `/clear` + +`/clear` creates a deferred successor session. Remember the current title only +when its persisted provenance is `manual`, then rename the successor before it +attaches and before its first prompt. + +The existing session catalog is the durable source of title provenance after a +reload. Live rename events provide the same provenance without another read. +`/new`, `/reset`, session navigation, and workspace changes discard the carry. + +Automatic and legacy titles with unknown provenance are never carried. diff --git a/packages/acp-bridge/src/bridge.test.ts b/packages/acp-bridge/src/bridge.test.ts index 02d285c0fce..15096ced5b5 100644 --- a/packages/acp-bridge/src/bridge.test.ts +++ b/packages/acp-bridge/src/bridge.test.ts @@ -28788,9 +28788,10 @@ describe('createAcpSessionBridge', () => { (e) => e.type === 'session_metadata_updated', ); expect(metaEvent).toBeDefined(); - expect((metaEvent?.data as { displayName: string }).displayName).toBe( - 'Test Session', - ); + expect(metaEvent?.data).toMatchObject({ + displayName: 'Test Session', + titleSource: 'manual', + }); await bridge.closeSession(session.sessionId); await drain; diff --git a/packages/acp-bridge/src/bridge.ts b/packages/acp-bridge/src/bridge.ts index c97e1092ac8..5e0e9c67575 100644 --- a/packages/acp-bridge/src/bridge.ts +++ b/packages/acp-bridge/src/bridge.ts @@ -11374,7 +11374,11 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { try { entry.events.publish({ type: 'session_metadata_updated', - data: { sessionId, displayName: entry.displayName }, + data: { + sessionId, + displayName: entry.displayName, + ...(entry.displayName ? { titleSource: 'manual' } : {}), + }, ...(metadataOriginatorClientId ? { originatorClientId: metadataOriginatorClientId } : {}), diff --git a/packages/acp-bridge/src/bridgeTypes.ts b/packages/acp-bridge/src/bridgeTypes.ts index c21394b73cf..ae73d2f611f 100644 --- a/packages/acp-bridge/src/bridgeTypes.ts +++ b/packages/acp-bridge/src/bridgeTypes.ts @@ -711,6 +711,7 @@ export interface BridgeSessionSummary { createdAt: string; updatedAt?: string; displayName?: string; + titleSource?: 'manual' | 'auto'; /** Id of the session that spawned this one (via `create_sub_session`), or * absent for a top-level session. Lets a UI link a sub-session back to its * parent. Immutable — set when the session is created. */ diff --git a/packages/cli/src/serve/conversations/standalone-session-service.ts b/packages/cli/src/serve/conversations/standalone-session-service.ts index 736389d5812..a04472076a7 100644 --- a/packages/cli/src/serve/conversations/standalone-session-service.ts +++ b/packages/cli/src/serve/conversations/standalone-session-service.ts @@ -377,6 +377,9 @@ function toStandaloneSummary( createdAt: item.startTime, updatedAt: new Date(item.mtime).toISOString(), ...(displayName ? { displayName } : {}), + ...(item.customTitle && item.titleSource + ? { titleSource: item.titleSource } + : {}), sourceType: STANDALONE_SESSION_SOURCE_TYPE, context: { kind: 'standalone' }, ...(source.metadata.parentSessionId !== undefined diff --git a/packages/cli/src/serve/server.test.ts b/packages/cli/src/serve/server.test.ts index b14309c7fee..f50accc8231 100644 --- a/packages/cli/src/serve/server.test.ts +++ b/packages/cli/src/serve/server.test.ts @@ -15927,6 +15927,8 @@ describe('createServeApp', () => { timestamp: string; prompt: string; mtime: Date; + customTitle?: string; + titleSource?: 'manual' | 'auto'; state?: 'active' | 'archived'; parentSessionId?: string; sourceType?: string; @@ -15950,6 +15952,21 @@ describe('createServeApp', () => { cwd: input.cwd, }; const lines = [JSON.stringify(record)]; + if (input.customTitle !== undefined) { + lines.push( + JSON.stringify({ + ...record, + uuid: `${input.sessionId}-title-1`, + parentUuid: record.uuid, + type: 'system', + subtype: 'custom_title', + systemPayload: { + customTitle: input.customTitle, + ...(input.titleSource ? { titleSource: input.titleSource } : {}), + }, + }), + ); + } if (input.parentSessionId !== undefined) { // Mirror ChatRecordingService.recordParentSession: a single // `parent_session` system record near the head of the transcript that @@ -16229,6 +16246,8 @@ describe('createServeApp', () => { timestamp: '2026-05-17T12:00:00.000Z', prompt: 'stored only prompt', mtime: new Date('2026-05-17T12:10:00.000Z'), + customTitle: 'Manual title', + titleSource: 'manual', }); await writeStoredSession({ sessionId: liveAndStoredId, @@ -16267,7 +16286,8 @@ describe('createServeApp', () => { expect.objectContaining({ sessionId: storedOnlyId, workspaceCwd: WS_BOUND, - displayName: 'stored only prompt', + displayName: 'Manual title', + titleSource: 'manual', clientCount: 0, hasActivePrompt: false, }), diff --git a/packages/cli/src/serve/server/session-list.ts b/packages/cli/src/serve/server/session-list.ts index 15f93698884..e83d4b89b7f 100644 --- a/packages/cli/src/serve/server/session-list.ts +++ b/packages/cli/src/serve/server/session-list.ts @@ -472,6 +472,7 @@ function toSummary(item: { mtime: number; prompt: string; customTitle?: string; + titleSource?: 'manual' | 'auto'; parentSessionId?: string; sourceType?: string; sourceId?: string; @@ -483,6 +484,9 @@ function toSummary(item: { createdAt: item.startTime, updatedAt: new Date(item.mtime).toISOString(), displayName: item.customTitle || item.prompt, + ...(item.customTitle && item.titleSource + ? { titleSource: item.titleSource } + : {}), ...(item.parentSessionId ? { parentSessionId: item.parentSessionId } : {}), ...(item.sourceType ? { sourceType: item.sourceType } : {}), ...(item.sourceId !== undefined ? { sourceId: item.sourceId } : {}), diff --git a/packages/sdk-typescript/src/daemon/events.ts b/packages/sdk-typescript/src/daemon/events.ts index 86ae98c5ac4..13449c6d7b0 100644 --- a/packages/sdk-typescript/src/daemon/events.ts +++ b/packages/sdk-typescript/src/daemon/events.ts @@ -298,6 +298,7 @@ export interface DaemonSessionClosedData { export interface DaemonSessionMetadataUpdatedData { sessionId: string; displayName?: string; + titleSource?: 'manual' | 'auto'; prs?: DaemonSessionPrInfo[]; [key: string]: unknown; } @@ -2656,7 +2657,10 @@ function isSessionMetadataUpdatedData( if ( !isRecord(value) || !isNonEmptyString(value['sessionId']) || - !isOptionalStringOrNull(value['displayName']) + !isOptionalStringOrNull(value['displayName']) || + (value['titleSource'] !== undefined && + value['titleSource'] !== 'manual' && + value['titleSource'] !== 'auto') ) { return false; } diff --git a/packages/sdk-typescript/src/daemon/types.ts b/packages/sdk-typescript/src/daemon/types.ts index 18fdcb9fff9..1f93bf9c8cd 100644 --- a/packages/sdk-typescript/src/daemon/types.ts +++ b/packages/sdk-typescript/src/daemon/types.ts @@ -1293,6 +1293,7 @@ export interface DaemonSessionSummary { createdAt?: string; updatedAt?: string; displayName?: string; + titleSource?: 'manual' | 'auto'; /** Id of the session that spawned this one (via `create_sub_session`), or * absent for a top-level session. Lets a UI link a sub-session back to its * parent. */ diff --git a/packages/sdk-typescript/test/unit/daemonEvents.test.ts b/packages/sdk-typescript/test/unit/daemonEvents.test.ts index 2b3a2e63b29..4bfcdcb2cd4 100644 --- a/packages/sdk-typescript/test/unit/daemonEvents.test.ts +++ b/packages/sdk-typescript/test/unit/daemonEvents.test.ts @@ -964,7 +964,11 @@ describe('daemon event schema', () => { id: 1, v: 1, type: 'session_metadata_updated', - data: { sessionId: 's-1', displayName: 'My Session' }, + data: { + sessionId: 's-1', + displayName: 'My Session', + titleSource: 'manual', + }, }), ).toBeDefined(); @@ -985,6 +989,15 @@ describe('daemon event schema', () => { data: {}, }), ).toBeUndefined(); + + expect( + asKnownDaemonEvent({ + id: 4, + v: 1, + type: 'session_metadata_updated', + data: { sessionId: 's-1', titleSource: 'unknown' }, + }), + ).toBeUndefined(); }); it('validates mid_turn_message_injected events', () => { diff --git a/packages/web-shell/client/App.test.tsx b/packages/web-shell/client/App.test.tsx index f601af4e209..15e480faa4c 100644 --- a/packages/web-shell/client/App.test.tsx +++ b/packages/web-shell/client/App.test.tsx @@ -46,6 +46,7 @@ type MockConnection = { context?: { sessionId: string }; clientId: string; displayName: string | undefined; + titleSource?: 'manual' | 'auto'; workspaceCwd: string; currentModel: string; currentMode: string; @@ -5509,6 +5510,7 @@ beforeEach(() => { mockConnection.workspaceCwd = '/tmp/project'; mockConnection.status = 'connected'; mockConnection.displayName = 'Session One'; + mockConnection.titleSource = undefined; mockConnection.currentMode = 'default'; mockConnection.currentModel = 'qwen'; mockConnection.models = [{ id: 'qwen', label: 'Qwen' }]; @@ -14363,6 +14365,75 @@ describe('App session callbacks', () => { expect(editorFocus).toHaveBeenCalledOnce(); }); + it('renames a /clear successor from persisted manual title provenance', async () => { + mockWorkspace.client.listWorkspaceSessions.mockResolvedValue([ + { + sessionId: 'session-1', + workspaceCwd: '/tmp/project', + displayName: 'Bug hunt', + titleSource: 'manual', + }, + ]); + const { container, rerender } = renderApp(); + await flush(); + await flush(); + + testState.prompt = '/clear'; + await clickSubmit(container); + await vi.waitFor(() => { + expect(mockSessionActions.clearSession).toHaveBeenCalledOnce(); + }); + + act(() => { + mockConnection.sessionId = undefined; + mockConnection.displayName = undefined; + rerender(); + }); + act(() => { + testState.latestChatEditorProps?.onSubmit('first prompt'); + }); + await vi.waitFor(() => { + expect(mockSessionActions.sendPrompt).toHaveBeenCalled(); + }); + + expect(mockSessionActions.renameSession).toHaveBeenCalledWith('Bug hunt'); + expect( + mockSessionActions.renameSession.mock.invocationCallOrder[0], + ).toBeLessThan( + mockSessionActions.attachSession.mock.invocationCallOrder[0], + ); + expect( + mockSessionActions.renameSession.mock.invocationCallOrder[0], + ).toBeLessThan(mockSessionActions.sendPrompt.mock.invocationCallOrder[0]); + }); + + it('does not carry an automatic title across /clear', async () => { + mockConnection.titleSource = 'auto'; + const { container, rerender } = renderApp(); + await flush(); + + testState.prompt = '/clear'; + await clickSubmit(container); + await vi.waitFor(() => { + expect(mockSessionActions.clearSession).toHaveBeenCalledOnce(); + }); + + act(() => { + mockConnection.sessionId = undefined; + mockConnection.displayName = undefined; + mockConnection.titleSource = undefined; + rerender(); + }); + act(() => { + testState.latestChatEditorProps?.onSubmit('first prompt'); + }); + await vi.waitFor(() => { + expect(mockSessionActions.sendPrompt).toHaveBeenCalled(); + }); + + expect(mockSessionActions.renameSession).not.toHaveBeenCalled(); + }); + it('focuses a cleared new session without waiting for detach', async () => { const clear = deferred(); mockSessionActions.clearSession.mockReturnValueOnce(clear.promise); diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index 8c75e38f28a..241140e657e 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -2795,6 +2795,8 @@ export function App({ const [currentSessionSummary, setCurrentSessionSummary] = useState< DaemonSessionSummary | undefined >(undefined); + const currentSessionSummaryRef = useRef(currentSessionSummary); + currentSessionSummaryRef.current = currentSessionSummary; // Tracks the logical session from the latest effect run. In-flight fetches // compare their captured key against this ref on resolve: a match means // the response is still relevant and may set OR clear the worktree state; @@ -2911,6 +2913,7 @@ export function App({ setSessionStatusDisplayName( listedSession?.displayName ?? summary.displayName, ); + setCurrentSessionSummary(listedSession ?? summary); }) .catch(() => undefined); }) @@ -6633,6 +6636,9 @@ export function App({ const createSessionPromiseRef = useRef | null>( null, ); + const pendingManualTitleRef = useRef<{ displayName: string } | undefined>( + undefined, + ); const preparingSessionIdRef = useRef(null); useEffect(() => { if ( @@ -6692,6 +6698,7 @@ export function App({ return Promise.resolve(undefined); } if (currentSessionId) return Promise.resolve(undefined); + const pendingManualTitle = pendingManualTitleRef.current; const promise = (async () => { let allocatedSessionId: string | undefined; const modelId = @@ -6779,7 +6786,17 @@ export function App({ ? { name: gitModeIntentRef.current.name } : undefined, sessionSourceType: sessionSourceTypeRef.current, - onSessionCreated: onSessionCreatedRef.current, + onSessionCreated: async (sessionId) => { + if ( + pendingManualTitle && + pendingManualTitleRef.current === pendingManualTitle + ) { + await sessionActions.renameSession( + pendingManualTitle.displayName, + ); + } + await onSessionCreatedRef.current?.(sessionId); + }, onSessionAllocated: (sessionId) => { preparingSessionIdRef.current = sessionId; allocatedSessionId = sessionId; @@ -6796,6 +6813,9 @@ export function App({ }, getCurrentSessionId: () => connectionRef.current.sessionId, }).then((result) => { + if (pendingManualTitleRef.current === pendingManualTitle) { + pendingManualTitleRef.current = undefined; + } if (result.worktree) { setSessionWorktree(result.worktree); } @@ -9395,6 +9415,7 @@ export function App({ * prompt sees it even while the clear is still in flight. */ gitIntent?: SessionGitIntent; + carryManualTitle?: string; }, ) => { if ( @@ -9404,6 +9425,9 @@ export function App({ pushToast('warning', t('session.recoveryBlocksAction')); return false; } + pendingManualTitleRef.current = opts?.carryManualTitle + ? { displayName: opts.carryManualTitle } + : undefined; splitClassificationGenerationRef.current += 1; const invocation = ++sessionOpenInvocationRef.current; let nextContext: DaemonProductSessionContext | undefined; @@ -9423,6 +9447,7 @@ export function App({ } : undefined); if (nextContext?.kind === 'live') { + pendingManualTitleRef.current = undefined; gitModeIntentRef.current = { mode: 'current' }; setGitModeIntent({ mode: 'current' }); try { @@ -9587,6 +9612,7 @@ export function App({ // intent. Compared before the ref is overwritten below. `undefined` // is the primary selection on both sides. const sameTarget = workspaceCwd === selectedWorkspaceCwdRef.current; + if (!sameTarget) pendingManualTitleRef.current = undefined; composerSourceVersionRef.current += 1; selectedWorkspaceCwdRef.current = workspaceCwd; setSelectedWorkspaceCwd(workspaceCwd); @@ -10080,6 +10106,7 @@ export function App({ workspaceCwd?: string, sessionContext?: DaemonProductSessionContext, ) => { + pendingManualTitleRef.current = undefined; splitClassificationGenerationRef.current += 1; const invocation = ++sessionOpenInvocationRef.current; const previousPendingContext = pendingSessionContextRef.current; @@ -11846,7 +11873,23 @@ export function App({ return true; } if (cmd === 'clear') { - void createNewSession({ kind: 'inherit' }); + const current = connectionRef.current; + const summary = currentSessionSummaryRef.current; + const carryManualTitle = current.sessionId + ? current.titleSource === 'manual' + ? current.displayName + : current.titleSource === undefined && + summary?.sessionId === current.sessionId && + summary.titleSource === 'manual' + ? summary.displayName + : current.titleSource === undefined + ? pendingManualTitleRef.current?.displayName + : undefined + : pendingManualTitleRef.current?.displayName; + void createNewSession( + { kind: 'inherit' }, + carryManualTitle?.trim() ? { carryManualTitle } : undefined, + ); return true; } if (cmd === 'new' || cmd === 'reset') { @@ -11854,6 +11897,7 @@ export function App({ return true; } if (cmd === 'rename') { + pendingManualTitleRef.current = undefined; const renameArg = parseRenameArgument(text.slice(match[0].length)); if (renameArg.type === 'auto' || renameArg.type === 'delegate') { if (commandBlocked) { diff --git a/packages/web-shell/client/daemon/session/actions.test.ts b/packages/web-shell/client/daemon/session/actions.test.ts index f0fcfa9c771..1cff71aa608 100644 --- a/packages/web-shell/client/daemon/session/actions.test.ts +++ b/packages/web-shell/client/daemon/session/actions.test.ts @@ -31,6 +31,7 @@ describe('getConnectionAfterSessionClear', () => { sessionId: 'session-a', clientId: 'client-a', displayName: 'Session A', + titleSource: 'manual', tokenCount: 42, goalState: { v: 2, goal: null, activity: 'idle' }, commands: [commandInfo('old-command')], @@ -58,6 +59,7 @@ describe('getConnectionAfterSessionClear', () => { expect(next).not.toHaveProperty('sessionId'); expect(next).not.toHaveProperty('clientId'); expect(next).not.toHaveProperty('displayName'); + expect(next).not.toHaveProperty('titleSource'); expect(next).not.toHaveProperty('tokenCount'); expect(next).not.toHaveProperty('goalState'); expect(next).not.toHaveProperty('supportedCommands'); diff --git a/packages/web-shell/client/daemon/session/actions.ts b/packages/web-shell/client/daemon/session/actions.ts index 76c54242669..acc2e48aeb3 100644 --- a/packages/web-shell/client/daemon/session/actions.ts +++ b/packages/web-shell/client/daemon/session/actions.ts @@ -283,6 +283,7 @@ export function getConnectionAfterSessionClear( delete next.sessionId; delete next.clientId; delete next.displayName; + delete next.titleSource; delete next.tokenUsage; delete next.tokenCount; delete next.goalState; @@ -776,6 +777,7 @@ export function createDaemonSessionActions({ standaloneSession: undefined, clientId: undefined, displayName: undefined, + titleSource: undefined, goalState: undefined, error: undefined, errorStatus: undefined, diff --git a/packages/web-shell/client/daemon/session/mappers.test.ts b/packages/web-shell/client/daemon/session/mappers.test.ts index 97e3f610209..90bbdf04b70 100644 --- a/packages/web-shell/client/daemon/session/mappers.test.ts +++ b/packages/web-shell/client/daemon/session/mappers.test.ts @@ -77,6 +77,29 @@ const turnComplete: DaemonEvent = { data: { stopReason: 'end_turn' }, }; +describe('session title metadata', () => { + it('keeps manual provenance with a renamed session', () => { + expect( + applyEvent( + { status: 'connected' }, + { + id: 1, + v: 1, + type: 'session_metadata_updated', + data: { + sessionId: 'session-1', + displayName: 'Bug hunt', + titleSource: 'manual', + }, + }, + ), + ).toMatchObject({ + displayName: 'Bug hunt', + titleSource: 'manual', + }); + }); +}); + describe('mapReasoningControls', () => { it('maps toggle-only reasoning without exposing an effort list', () => { expect( diff --git a/packages/web-shell/client/daemon/session/mappers.ts b/packages/web-shell/client/daemon/session/mappers.ts index 3eca1463198..bae12d404b5 100644 --- a/packages/web-shell/client/daemon/session/mappers.ts +++ b/packages/web-shell/client/daemon/session/mappers.ts @@ -375,9 +375,15 @@ export function updateConnectionFromDaemonEvent( case 'session_metadata_updated': { const data = getRecord(event.data); if (Object.prototype.hasOwnProperty.call(data ?? {}, 'displayName')) { + const displayName = getString(data, 'displayName'); + const titleSource = getString(data, 'titleSource'); setConnection((current) => ({ ...current, - displayName: getString(data, 'displayName'), + displayName, + titleSource: + displayName && (titleSource === 'manual' || titleSource === 'auto') + ? titleSource + : undefined, })); } break; diff --git a/packages/web-shell/client/daemon/session/types.ts b/packages/web-shell/client/daemon/session/types.ts index 6caf9ec6f23..72b4186fbf4 100644 --- a/packages/web-shell/client/daemon/session/types.ts +++ b/packages/web-shell/client/daemon/session/types.ts @@ -114,6 +114,7 @@ export interface DaemonConnectionState { reasoning?: DaemonReasoningControls; currentMode?: string; displayName?: string; + titleSource?: 'manual' | 'auto'; /** Latest main-conversation model usage event. */ tokenUsage?: DaemonTokenUsage; /** Authoritative Goal v2 state for the current session. */ From e7cfafc226e767b20ec3021b2a358fbe50988b19 Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Wed, 2 Sep 2026 04:11:29 +0800 Subject: [PATCH 2/7] fix(acp-bridge): reject an empty session displayName on metadata update An empty displayName only cleared the live bridge entry: the sessionTitle persist runs for truthy names, so no tombstone reached the transcript. The persisted manual custom_title record then resurfaced through mergeLiveSessionSummary (live.displayName ?? existing.displayName) with manual provenance, and the /clear carry renamed the successor session back to the deleted name before persisting it there as manual. Reject empty or whitespace-only names in updateSessionMetadata, the choke point shared by the REST metadata routes, the SDK client, and the daemon-MCP session_update_metadata tool, mirroring the rejection the workspace-scoped metadata route already applies. All internal callers pass generated non-empty names, and every UI rename path trims and rejects empty input client-side. Co-authored-by: Qwen-Coder Patrol-Run: qwen-pr-closeout/jmtj23z78au --- packages/acp-bridge/src/bridge.test.ts | 42 ++++++++++++++++++++++++++ packages/acp-bridge/src/bridge.ts | 14 +++++++++ 2 files changed, 56 insertions(+) diff --git a/packages/acp-bridge/src/bridge.test.ts b/packages/acp-bridge/src/bridge.test.ts index 15096ced5b5..9fbc0c70bd2 100644 --- a/packages/acp-bridge/src/bridge.test.ts +++ b/packages/acp-bridge/src/bridge.test.ts @@ -28798,6 +28798,48 @@ describe('createAcpSessionBridge', () => { await bridge.shutdown(); }); + it('rejects an empty displayName instead of clearing only the live entry', async () => { + const bridge = makeBridge({ + channelFactory: async () => makeChannel().channel, + }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + + bridge.updateSessionMetadata(session.sessionId, { + displayName: 'Payments bug', + }); + + const events: BridgeEvent[] = []; + const sub = bridge.subscribeEvents(session.sessionId); + const drain = (async () => { + for await (const ev of sub) events.push(ev); + })(); + await new Promise((r) => setImmediate(r)); + + // A clear is never persisted (the `sessionTitle` persist skips + // falsy names), so accepting it would let the stale manual record + // resurface through the session-list merge and the `/clear` carry. + expect(() => + bridge.updateSessionMetadata(session.sessionId, { displayName: '' }), + ).toThrow(InvalidSessionMetadataError); + expect(() => + bridge.updateSessionMetadata(session.sessionId, { + displayName: ' ', + }), + ).toThrow(InvalidSessionMetadataError); + + await new Promise((r) => setImmediate(r)); + expect(bridge.getSessionSummary(session.sessionId)).toMatchObject({ + displayName: 'Payments bug', + }); + expect( + events.filter((e) => e.type === 'session_metadata_updated'), + ).toHaveLength(0); + + await bridge.closeSession(session.sessionId); + await drain; + await bridge.shutdown(); + }); + it('keeps the optimistic update and logs a generic persistence failure', async () => { const stderrSpy = vi .spyOn(process.stderr, 'write') diff --git a/packages/acp-bridge/src/bridge.ts b/packages/acp-bridge/src/bridge.ts index 5e0e9c67575..4790f9585ab 100644 --- a/packages/acp-bridge/src/bridge.ts +++ b/packages/acp-bridge/src/bridge.ts @@ -11334,6 +11334,20 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { 'must not contain control characters', ); } + // An empty name would only clear the live entry: the `sessionTitle` + // persist below runs for truthy names, so no tombstone reaches the + // transcript. The persisted manual record would then resurface + // through the session-list merge (`live.displayName ?? + // existing.displayName`) and be carried into a `/clear` successor as + // if the clear never happened. Reject the clear instead of serving a + // name the catalog no longer backs. Mirrors the workspace-scoped + // metadata route, which rejects empty names for the same reason. + if (metadata.displayName.trim() === '') { + throw new InvalidSessionMetadataError( + 'displayName', + 'must not be empty', + ); + } const nextDisplayName = metadata.displayName || undefined; if (entry.displayName !== nextDisplayName) { entry.displayName = nextDisplayName; From 2648ce5898b41a9494e3f36a779437c883fc0af0 Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Wed, 2 Sep 2026 10:22:07 +0800 Subject: [PATCH 3/7] fix(web-shell): keep title provenance when metadata events echo the same name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A `session_metadata_updated` event that echoes the unchanged displayName without an explicit titleSource — the bridge's pr-only publish when a PR is bound — reset the connection's provenance to undefined, wiping the 'manual' provenance the `/clear` carry reads. Preserve the prior provenance for an unchanged-name echo; only a changed name of unknown provenance resets it. Co-authored-by: Qwen-Coder Patrol-Run: qwen-pr-closeout/jmtjeyxvebk --- .../client/daemon/session/mappers.test.ts | 66 +++++++++++++++++++ .../client/daemon/session/mappers.ts | 9 ++- 2 files changed, 74 insertions(+), 1 deletion(-) diff --git a/packages/web-shell/client/daemon/session/mappers.test.ts b/packages/web-shell/client/daemon/session/mappers.test.ts index 90bbdf04b70..bd9b0058bf5 100644 --- a/packages/web-shell/client/daemon/session/mappers.test.ts +++ b/packages/web-shell/client/daemon/session/mappers.test.ts @@ -98,6 +98,72 @@ describe('session title metadata', () => { titleSource: 'manual', }); }); + + it('keeps manual provenance when a pr-only event echoes the same name', () => { + const renamed = applyEvent( + { status: 'connected' }, + { + id: 1, + v: 1, + type: 'session_metadata_updated', + data: { + sessionId: 'session-1', + displayName: 'Bug hunt', + titleSource: 'manual', + }, + }, + ); + expect(renamed).toMatchObject({ + displayName: 'Bug hunt', + titleSource: 'manual', + }); + expect( + applyEvent(renamed, { + id: 2, + v: 1, + type: 'session_metadata_updated', + // The bridge's pr-binding publish echoes the name without a + // provenance: binding a PR must not wipe the manual title. + data: { + sessionId: 'session-1', + displayName: 'Bug hunt', + prs: [ + { + number: 9260, + url: 'https://github.com/QwenLM/qwen-code/pull/9260', + }, + ], + }, + }), + ).toMatchObject({ + displayName: 'Bug hunt', + titleSource: 'manual', + }); + }); + + it('drops provenance when an unstamped event changes the name', () => { + const renamed = applyEvent( + { status: 'connected' }, + { + id: 1, + v: 1, + type: 'session_metadata_updated', + data: { + sessionId: 'session-1', + displayName: 'Bug hunt', + titleSource: 'manual', + }, + }, + ); + const next = applyEvent(renamed, { + id: 2, + v: 1, + type: 'session_metadata_updated', + data: { sessionId: 'session-1', displayName: 'New name' }, + }); + expect(next.displayName).toBe('New name'); + expect(next.titleSource).toBeUndefined(); + }); }); describe('mapReasoningControls', () => { diff --git a/packages/web-shell/client/daemon/session/mappers.ts b/packages/web-shell/client/daemon/session/mappers.ts index bae12d404b5..1a65204ba91 100644 --- a/packages/web-shell/client/daemon/session/mappers.ts +++ b/packages/web-shell/client/daemon/session/mappers.ts @@ -383,7 +383,14 @@ export function updateConnectionFromDaemonEvent( titleSource: displayName && (titleSource === 'manual' || titleSource === 'auto') ? titleSource - : undefined, + : // A metadata event that echoes the unchanged name without an + // explicit provenance (the bridge's pr-only publish) does not + // change the title, so it must not strip the provenance the + // `/clear` carry reads. Only a changed name of unknown + // provenance resets it. + displayName && displayName === current.displayName + ? current.titleSource + : undefined, })); } break; From e9f9f6b55b2870ceb72177b4e74deea86bd0bd68 Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Wed, 2 Sep 2026 10:23:30 +0800 Subject: [PATCH 4/7] fix(web-shell): discard an armed /clear carry when other paths bind a session Two session-binding paths bypassed the carry's consume/discard network while pendingManualTitleRef was armed: the shrink-fold landing of the first split pane on the sessionless chat connection, and resolveSessionForWorkspace's direct createSession (Commit-dialog flow). A cleared manual title could then resurface on the bound session's successor and persist as user-authored provenance. Discard the armed carry at both sites, mirroring loadSidebarSession. Co-authored-by: Qwen-Coder Patrol-Run: qwen-pr-closeout/jmtjeyxvebk --- packages/web-shell/client/App.test.tsx | 94 ++++++++++++++++++++++++++ packages/web-shell/client/App.tsx | 20 ++++-- 2 files changed, 110 insertions(+), 4 deletions(-) diff --git a/packages/web-shell/client/App.test.tsx b/packages/web-shell/client/App.test.tsx index f58fc55e7e0..bec7da1293c 100644 --- a/packages/web-shell/client/App.test.tsx +++ b/packages/web-shell/client/App.test.tsx @@ -14502,6 +14502,100 @@ describe('App session callbacks', () => { expect(mockSessionActions.renameSession).not.toHaveBeenCalled(); }); + it('discards an armed /clear carry when a shrink-fold lands on a split pane', async () => { + let large = true; + let changeHandler: ((event: { matches: boolean }) => void) | undefined; + Object.defineProperty(window, 'matchMedia', { + configurable: true, + value: vi.fn().mockImplementation((query: string) => ({ + get matches() { + return query.includes('min-width') ? large : false; + }, + media: query, + addEventListener: ( + _type: string, + cb: (event: { matches: boolean }) => void, + ) => { + if (query.includes('1024')) changeHandler = cb; + }, + removeEventListener: vi.fn(), + })), + }); + mockConnection.sessionId = 'session-1'; + mockConnection.titleSource = 'manual'; + mockConnection.displayName = 'Bug hunt'; + + const { container, rerender } = renderApp(); + await flush(); + + // Split view with the chat on session-1; /clear arms the carry and + // leaves the chat as a sessionless draft. + await act(async () => { + container + .querySelector('[data-testid="open-split-view"]') + ?.click(); + await Promise.resolve(); + }); + testState.prompt = '/clear'; + await clickSubmit(container); + await vi.waitFor(() => { + expect(mockSessionActions.clearSession).toHaveBeenCalledOnce(); + }); + act(() => { + mockConnection.sessionId = undefined; + mockConnection.displayName = undefined; + mockConnection.titleSource = undefined; + rerender(); + }); + + // Re-enter the split (the draft chat stays sessionless), then shrink: + // the fold lands the first pane on the chat connection while the carry + // is still armed. + await act(async () => { + container + .querySelector('[data-testid="open-split-view"]') + ?.click(); + await Promise.resolve(); + }); + mockSessionActions.loadSession.mockImplementationOnce(async () => { + // Mirrors the real loadSession(): the pane session binds the chat + // connection; an automatic title carries no provenance. + mockConnection.sessionId = 'session-1'; + mockConnection.displayName = 'Pane task'; + }); + await act(async () => { + large = false; + changeHandler?.({ matches: false }); + await Promise.resolve(); + }); + await vi.waitFor(() => { + expect(mockSessionActions.loadSession).toHaveBeenCalledWith('session-1'); + }); + rerender(); + + // The pane session has no manual provenance and no manual summary; a + // second /clear must not read the stale armed carry from the first + // session and rename the pane session's successor with it. + testState.prompt = '/clear'; + await clickSubmit(container); + await vi.waitFor(() => { + expect(mockSessionActions.clearSession).toHaveBeenCalledTimes(2); + }); + act(() => { + mockConnection.sessionId = undefined; + mockConnection.displayName = undefined; + rerender(); + }); + act(() => { + testState.latestChatEditorProps?.onSubmit('first prompt'); + }); + await vi.waitFor(() => { + expect(mockSessionActions.sendPrompt).toHaveBeenCalled(); + }); + + expect(mockSessionActions.renameSession).not.toHaveBeenCalled(); + }); + it('focuses a cleared new session without waiting for detach', async () => { const clear = deferred(); mockSessionActions.clearSession.mockReturnValueOnce(clear.promise); diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index ab7beb6bb38..403b4d40091 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -6156,6 +6156,14 @@ export function App({ // window is narrower than the large-screen breakpoint. Growing back past the // breakpoint restores it, so a transient resize doesn't drop the user's panes. const splitFoldedByShrinkRef = useRef(false); + // The manual title an armed `/clear` carries into the next created session + // (consumed by the deferred creation in ensureSessionForPrompt). Session + // binding outside the carry flow — sidebar navigation, workspace switches, + // the shrink-fold landing, workspace-resolver creation — must discard it, + // or a cleared title resurfaces on an unrelated session's successor. + const pendingManualTitleRef = useRef<{ displayName: string } | undefined>( + undefined, + ); useEffect(() => { if (isLargeScreen) { // Grew back above the breakpoint: restore a split that a shrink folded @@ -6189,6 +6197,10 @@ export function App({ // pane the single connection can't own) just leaves the empty chat. const firstPane = splitSessionIdsRef.current[0]; if (firstPane && !currentSessionIdRef.current) { + // Landing on a pane is session navigation: discard an armed + // `/clear` carry so the pane session's lineage never inherits the + // cleared session's title. + pendingManualTitleRef.current = undefined; void sessionActions.loadSession(firstPane).catch(() => undefined); } } @@ -6646,9 +6658,6 @@ export function App({ const createSessionPromiseRef = useRef | null>( null, ); - const pendingManualTitleRef = useRef<{ displayName: string } | undefined>( - undefined, - ); const preparingSessionIdRef = useRef(null); useEffect(() => { if ( @@ -7259,7 +7268,10 @@ export function App({ ); if (page.sessions.length > 0) return page.sessions[0].sessionId; } - // No session exists or forced: create one. + // No session exists or forced: create one. Creating binds the chat + // connection — session navigation that must discard an armed + // `/clear` carry, mirroring loadSidebarSession. + pendingManualTitleRef.current = undefined; const result = await ( sessionActions as typeof sessionActions & SessionActionsWithCreate ).createSession({ workspaceCwd: cwd }); From 41d73cd0bdde92e528db8a042965aec08269aa01 Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Wed, 2 Sep 2026 19:26:39 +0800 Subject: [PATCH 5/7] fix(serve): preserve automatic title provenance Co-authored-by: Qwen-Coder --- packages/acp-bridge/src/bridge.test.ts | 46 +++++++++++++++++++ packages/acp-bridge/src/bridge.ts | 15 +++++- packages/acp-bridge/src/bridgeTypes.ts | 1 + .../cli/src/serve/create-sub-session.test.ts | 15 ++++-- packages/cli/src/serve/create-sub-session.ts | 1 + .../live/live-session-coordinator.test.ts | 2 +- .../serve/live/live-session-coordinator.ts | 2 +- .../src/serve/routes/scheduled-tasks.test.ts | 25 ++++++++-- .../cli/src/serve/routes/scheduled-tasks.ts | 8 +++- .../serve/scheduled-task-keepalive.test.ts | 20 ++++++-- .../cli/src/serve/scheduled-task-keepalive.ts | 7 ++- 11 files changed, 125 insertions(+), 17 deletions(-) diff --git a/packages/acp-bridge/src/bridge.test.ts b/packages/acp-bridge/src/bridge.test.ts index 781961f1635..92b2162517a 100644 --- a/packages/acp-bridge/src/bridge.test.ts +++ b/packages/acp-bridge/src/bridge.test.ts @@ -29007,6 +29007,52 @@ describe('createAcpSessionBridge', () => { await bridge.shutdown(); }); + it('uses automatic provenance for programmatic renames', async () => { + const titleUpdates: unknown[] = []; + const bridge = makeBridge({ + channelFactory: async () => + makeChannel({ + extMethodImpl: (method, params) => { + if (method === SERVE_CONTROL_EXT_METHODS.sessionTitle) { + titleUpdates.push(params); + } + return { persisted: true }; + }, + }).channel, + }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + const events: BridgeEvent[] = []; + const sub = bridge.subscribeEvents(session.sessionId); + const drain = (async () => { + for await (const event of sub) events.push(event); + })(); + await new Promise((resolve) => setImmediate(resolve)); + + bridge.updateSessionMetadata(session.sessionId, { + displayName: 'Voice chat', + titleSource: 'auto', + }); + + await vi.waitFor(() => expect(titleUpdates).toHaveLength(1)); + expect(titleUpdates[0]).toMatchObject({ + displayName: 'Voice chat', + titleSource: 'auto', + }); + await vi.waitFor(() => + expect( + events.find((event) => event.type === 'session_metadata_updated') + ?.data, + ).toMatchObject({ + displayName: 'Voice chat', + titleSource: 'auto', + }), + ); + + await bridge.closeSession(session.sessionId); + await drain; + await bridge.shutdown(); + }); + it('rejects an empty displayName instead of clearing only the live entry', async () => { const bridge = makeBridge({ channelFactory: async () => makeChannel().channel, diff --git a/packages/acp-bridge/src/bridge.ts b/packages/acp-bridge/src/bridge.ts index b559dbba18a..0549b321367 100644 --- a/packages/acp-bridge/src/bridge.ts +++ b/packages/acp-bridge/src/bridge.ts @@ -11363,6 +11363,16 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { } } if (metadata.displayName !== undefined) { + if ( + metadata.titleSource !== undefined && + metadata.titleSource !== 'manual' && + metadata.titleSource !== 'auto' + ) { + throw new InvalidSessionMetadataError( + 'titleSource', + 'must be either `manual` or `auto`', + ); + } if ( typeof metadata.displayName !== 'string' || metadata.displayName.length > MAX_DISPLAY_NAME_LENGTH @@ -11393,6 +11403,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { ); } const nextDisplayName = metadata.displayName || undefined; + const titleSource = metadata.titleSource ?? 'manual'; if (entry.displayName !== nextDisplayName) { entry.displayName = nextDisplayName; // The catalog exposes display names; an actual rename is a @@ -11411,7 +11422,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { .extMethod(SERVE_CONTROL_EXT_METHODS.sessionTitle, { sessionId, displayName: nextDisplayName, - titleSource: 'manual', + titleSource, }) .then((res: unknown) => { const r = res as { persisted?: boolean } | undefined; @@ -11435,7 +11446,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { data: { sessionId, displayName: entry.displayName, - ...(entry.displayName ? { titleSource: 'manual' } : {}), + ...(entry.displayName ? { titleSource } : {}), }, ...(metadataOriginatorClientId ? { originatorClientId: metadataOriginatorClientId } diff --git a/packages/acp-bridge/src/bridgeTypes.ts b/packages/acp-bridge/src/bridgeTypes.ts index 3b7916b9741..f7dd75c01e2 100644 --- a/packages/acp-bridge/src/bridgeTypes.ts +++ b/packages/acp-bridge/src/bridgeTypes.ts @@ -812,6 +812,7 @@ export interface SessionPrIssueInfo { export interface SessionMetadataUpdate { displayName?: string; + titleSource?: 'manual' | 'auto'; /** Issues are daemon-derived, never client-bound — the input omits them. */ pr?: Omit; /** Full binding list after the update (return value only; ignored on input). */ diff --git a/packages/cli/src/serve/create-sub-session.test.ts b/packages/cli/src/serve/create-sub-session.test.ts index 88cacb88d8d..1a3dee313fa 100644 --- a/packages/cli/src/serve/create-sub-session.test.ts +++ b/packages/cli/src/serve/create-sub-session.test.ts @@ -81,7 +81,11 @@ function makeFakeBridge(opts?: { }> = []; const prompts: Array<{ sessionId: string; promptId?: string; text: string }> = []; - const names: Array<{ sessionId: string; displayName?: string }> = []; + const names: Array<{ + sessionId: string; + displayName?: string; + titleSource?: 'manual' | 'auto'; + }> = []; const closes: string[] = []; const relocations: Array<{ sessionId: string; @@ -131,9 +135,12 @@ function makeFakeBridge(opts?: { }, updateSessionMetadata: ( sessionId: string, - metadata: { displayName?: string }, + metadata: { + displayName?: string; + titleSource?: 'manual' | 'auto'; + }, ) => { - names.push({ sessionId, displayName: metadata.displayName }); + names.push({ sessionId, ...metadata }); return metadata; }, getSessionLastEventId: () => 0, @@ -339,6 +346,7 @@ describe('sub-session launcher', () => { ]); expect(fake.prompts[0]!.text).toBe('do the thing'); expect(fake.names[0]!.displayName).toContain('my task'); + expect(fake.names[0]!.titleSource).toBe('auto'); // 'sent' returns immediately but starts a background subscription to hold // the concurrency slot until the sub-session's turn finishes (so the cap // stays meaningful). The subscription is fire-and-forget — the launch @@ -386,6 +394,7 @@ describe('sub-session launcher', () => { sourceId: 'scheduled_task_run:task-1', }); expect(fake.names[0]?.displayName).toBe('Hourly review'); + expect(fake.names[0]?.titleSource).toBe('auto'); }); it('rejects a scheduled-task run when prompt admission fails', async () => { diff --git a/packages/cli/src/serve/create-sub-session.ts b/packages/cli/src/serve/create-sub-session.ts index 5e5a7f6c1c6..cc90fa4e0bd 100644 --- a/packages/cli/src/serve/create-sub-session.ts +++ b/packages/cli/src/serve/create-sub-session.ts @@ -911,6 +911,7 @@ export function createSubSessionLauncher( info.name ?? info.prompt, !isScheduledTaskRunSource(info), ), + titleSource: 'auto', }); } catch (err) { log.debug('sub-session: updateSessionMetadata failed', sessionId, err); diff --git a/packages/cli/src/serve/live/live-session-coordinator.test.ts b/packages/cli/src/serve/live/live-session-coordinator.test.ts index 7b226b7a859..16465918f92 100644 --- a/packages/cli/src/serve/live/live-session-coordinator.test.ts +++ b/packages/cli/src/serve/live/live-session-coordinator.test.ts @@ -394,7 +394,7 @@ describe('LiveSessionCoordinator', () => { }); expect(harness.bridge.updateSessionMetadata).toHaveBeenCalledWith( 'live-new', - { displayName: 'Voice chat' }, + { displayName: 'Voice chat', titleSource: 'auto' }, ); expect(harness.host.setCallState).toHaveBeenLastCalledWith(1, 'listening'); diff --git a/packages/cli/src/serve/live/live-session-coordinator.ts b/packages/cli/src/serve/live/live-session-coordinator.ts index 3da56b2f7f5..56d3f50ee32 100644 --- a/packages/cli/src/serve/live/live-session-coordinator.ts +++ b/packages/cli/src/serve/live/live-session-coordinator.ts @@ -520,7 +520,7 @@ export class LiveSessionCoordinator { try { context.runtime?.bridge.updateSessionMetadata( context.coordinator.sessionId, - { displayName: 'Voice chat' }, + { displayName: 'Voice chat', titleSource: 'auto' }, ); } catch { /* the session remains usable when a title write fails */ diff --git a/packages/cli/src/serve/routes/scheduled-tasks.test.ts b/packages/cli/src/serve/routes/scheduled-tasks.test.ts index f1e1f8cfe8d..77b19154aa3 100644 --- a/packages/cli/src/serve/routes/scheduled-tasks.test.ts +++ b/packages/cli/src/serve/routes/scheduled-tasks.test.ts @@ -64,7 +64,10 @@ interface StubBridge { ensureDefaultSessionPersisted(sessionId: string): Promise; updateSessionMetadata( sessionId: string, - metadata: { displayName?: string }, + metadata: { + displayName?: string; + titleSource?: 'manual' | 'auto'; + }, ): unknown; getSessionSummary(sessionId: string): { sessionId: string; @@ -95,7 +98,11 @@ interface StubBridge { prompts: Array<{ sessionId: string; text: string }>; closed: string[]; persisted: string[]; - named: Array<{ sessionId: string; displayName?: string }>; + named: Array<{ + sessionId: string; + displayName?: string; + titleSource?: 'manual' | 'auto'; + }>; failNext: boolean; persistenceError?: Error; } @@ -469,6 +476,7 @@ describe('scheduled-tasks routes', () => { displayName: expect.stringMatching( /^Review PRs · \d{2}-\d{2} \d{2}:\d{2}$/, ), + titleSource: 'auto', }); expect(h.bridge.prompts).toHaveLength(1); expect(h.bridge.prompts[0]).toMatchObject({ sessionId: childSessionId }); @@ -1416,13 +1424,18 @@ describe('scheduled-tasks routes', () => { prompt: 'summarize the day', }); expect(h.bridge.named).toEqual([ - { sessionId: named.body.sessionId, displayName: 'Digest' }, + { + sessionId: named.body.sessionId, + displayName: 'Digest', + titleSource: 'auto', + }, ]); const unnamed = await create({ cron: '0 9 * * *', prompt: 'do the thing' }); expect(h.bridge.named[1]).toEqual({ sessionId: unnamed.body.sessionId, displayName: 'do the thing', + titleSource: 'auto', }); }); @@ -2036,7 +2049,9 @@ describe('scheduled-tasks routes', () => { }); const id = created.body.id as string; const sid = created.body.sessionId as string; - expect(h.bridge.named).toEqual([{ sessionId: sid, displayName: 'Old' }]); + expect(h.bridge.named).toEqual([ + { sessionId: sid, displayName: 'Old', titleSource: 'auto' }, + ]); // Renaming the task re-labels its session. const rename = await request(h.app) @@ -2046,6 +2061,7 @@ describe('scheduled-tasks routes', () => { expect(h.bridge.named).toContainEqual({ sessionId: sid, displayName: 'New', + titleSource: 'auto', }); // A bare cron edit does NOT re-touch the session name. @@ -2060,6 +2076,7 @@ describe('scheduled-tasks routes', () => { expect(h.bridge.named).toContainEqual({ sessionId: sid, displayName: 'p', + titleSource: 'auto', }); }); diff --git a/packages/cli/src/serve/routes/scheduled-tasks.ts b/packages/cli/src/serve/routes/scheduled-tasks.ts index 52cddb3516b..77fee250e41 100644 --- a/packages/cli/src/serve/routes/scheduled-tasks.ts +++ b/packages/cli/src/serve/routes/scheduled-tasks.ts @@ -120,7 +120,10 @@ export interface ScheduledTasksSessionBridge { * session list (rather than a bare id). Best-effort. */ updateSessionMetadata( sessionId: string, - metadata: { displayName?: string }, + metadata: { + displayName?: string; + titleSource?: 'manual' | 'auto'; + }, ): unknown; getSessionSummary(sessionId: string): { workspaceCwd: string; @@ -550,6 +553,7 @@ async function dispatchTaskToFreshSession( task.name ?? task.prompt, triggeredAt, ), + titleSource: 'auto', }); } catch { // The prompt can still run with the generated session id as its label. @@ -1076,6 +1080,7 @@ function registerScheduledTaskCrudRoutes( displayName: scheduledTaskSessionName( nameResult.value ?? prompt, ), + titleSource: 'auto', }), ); } catch { @@ -1544,6 +1549,7 @@ function registerScheduledTaskCrudRoutes( displayName: scheduledTaskSessionName( updated.name ?? updated.prompt, ), + titleSource: 'auto', }); } catch { // non-critical — the schedule change already persisted diff --git a/packages/cli/src/serve/scheduled-task-keepalive.test.ts b/packages/cli/src/serve/scheduled-task-keepalive.test.ts index 33c6ef65c1c..3e2468fbb02 100644 --- a/packages/cli/src/serve/scheduled-task-keepalive.test.ts +++ b/packages/cli/src/serve/scheduled-task-keepalive.test.ts @@ -235,7 +235,9 @@ describe('scheduled-task keepalive', () => { condition: 'files_changed', } as unknown as Partial), ]); - const names: Array<[string, { displayName?: string }]> = []; + const names: Array< + [string, { displayName?: string; titleSource?: 'manual' | 'auto' }] + > = []; const naming = { ...bridge, recordHeartbeat: () => { @@ -243,7 +245,10 @@ describe('scheduled-task keepalive', () => { // be attempted for this session in the first place. throw new Error('unexpected heartbeat for legacy session'); }, - updateSessionMetadata: (id: string, m: { displayName?: string }) => { + updateSessionMetadata: ( + id: string, + m: { displayName?: string; titleSource?: 'manual' | 'auto' }, + ) => { names.push([id, m]); }, }; @@ -694,6 +699,7 @@ describe('scheduled-task keepalive', () => { expect(names).toHaveLength(1); expect(names[0]![0]).toBe('new-sess-1'); expect(names[0]![1].displayName).toBe('check build'); + expect(names[0]![1].titleSource).toBe('auto'); const tasks = await readCronTasks(workspace); expect(tasks[0]!.sessionId).toBe('new-sess-1'); }); @@ -707,10 +713,15 @@ describe('scheduled-task keepalive', () => { sessionOwnedByTask: false, }), ]); - const names: Array<[string, { displayName?: string }]> = []; + const names: Array< + [string, { displayName?: string; titleSource?: 'manual' | 'auto' }] + > = []; const naming = { ...bridge, - updateSessionMetadata: (id: string, m: { displayName?: string }) => { + updateSessionMetadata: ( + id: string, + m: { displayName?: string; titleSource?: 'manual' | 'auto' }, + ) => { names.push([id, m]); }, }; @@ -725,6 +736,7 @@ describe('scheduled-task keepalive', () => { expect(names).toHaveLength(1); expect(names[0]![0]).toBe('existing-sess'); expect(names[0]![1].displayName).toBe('lint'); + expect(names[0]![1].titleSource).toBe('auto'); }); it('does not bind disabled unbound tasks', async () => { diff --git a/packages/cli/src/serve/scheduled-task-keepalive.ts b/packages/cli/src/serve/scheduled-task-keepalive.ts index 2246cc24ac5..cf2fe18a012 100644 --- a/packages/cli/src/serve/scheduled-task-keepalive.ts +++ b/packages/cli/src/serve/scheduled-task-keepalive.ts @@ -100,7 +100,10 @@ export interface KeepaliveBridge { markSessionCatalogChanged?(): void; updateSessionMetadata( sessionId: string, - metadata: { displayName?: string }, + metadata: { + displayName?: string; + titleSource?: 'manual' | 'auto'; + }, ): unknown; } @@ -194,6 +197,7 @@ async function bindAndNameSessions( try { bridge.updateSessionMetadata(sessionId, { displayName: scheduledTaskSessionName(task.prompt), + titleSource: 'auto', }); renamed.add(sessionId); } catch { @@ -243,6 +247,7 @@ async function bindAndNameSessions( try { bridge.updateSessionMetadata(sessionId, { displayName: scheduledTaskSessionName(task.prompt), + titleSource: 'auto', }); renamed.add(sessionId); } catch (err) { From d08ed5261672c8642cb6ca4211f388b2cf4cef92 Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Wed, 2 Sep 2026 19:55:48 +0800 Subject: [PATCH 6/7] test(serve): type automatic title metadata Co-authored-by: Qwen-Coder --- packages/cli/src/serve/scheduled-task-keepalive.test.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/serve/scheduled-task-keepalive.test.ts b/packages/cli/src/serve/scheduled-task-keepalive.test.ts index 3e2468fbb02..ed97e1b5d3e 100644 --- a/packages/cli/src/serve/scheduled-task-keepalive.test.ts +++ b/packages/cli/src/serve/scheduled-task-keepalive.test.ts @@ -670,7 +670,9 @@ describe('scheduled-task keepalive', () => { task({ id: 'unbound-1', prompt: 'check build' }), ]); const spawns: unknown[] = []; - const names: Array<[string, { displayName?: string }]> = []; + const names: Array< + [string, { displayName?: string; titleSource?: 'manual' | 'auto' }] + > = []; const binding = { ...bridge, spawnOrAttach: async (req: unknown) => { @@ -678,7 +680,10 @@ describe('scheduled-task keepalive', () => { return { sessionId: 'new-sess-1' }; }, closeSession: async () => {}, - updateSessionMetadata: (id: string, m: { displayName?: string }) => { + updateSessionMetadata: ( + id: string, + m: { displayName?: string; titleSource?: 'manual' | 'auto' }, + ) => { names.push([id, m]); }, }; From 7d449b51448da52dfd7a24f7fb771c399e57938f Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Wed, 2 Sep 2026 20:18:15 +0800 Subject: [PATCH 7/7] fix(web-shell): keep clear recovery after rename failure Co-authored-by: Qwen-Coder --- packages/web-shell/client/App.test.tsx | 31 ++++++++++++++++++++++++++ packages/web-shell/client/App.tsx | 10 ++++++--- 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/packages/web-shell/client/App.test.tsx b/packages/web-shell/client/App.test.tsx index f34cc043cc5..ab89a011036 100644 --- a/packages/web-shell/client/App.test.tsx +++ b/packages/web-shell/client/App.test.tsx @@ -14631,6 +14631,37 @@ describe('App session callbacks', () => { ).toBeLessThan(mockSessionActions.sendPrompt.mock.invocationCallOrder[0]); }); + it('attaches a /clear successor when carrying its title fails', async () => { + mockConnection.titleSource = 'manual'; + mockConnection.displayName = 'Bug hunt'; + mockSessionActions.renameSession.mockRejectedValueOnce( + new Error('rename failed'), + ); + const { container, rerender } = renderApp(); + await flush(); + + testState.prompt = '/clear'; + await clickSubmit(container); + await vi.waitFor(() => { + expect(mockSessionActions.clearSession).toHaveBeenCalledOnce(); + }); + + act(() => { + mockConnection.sessionId = undefined; + mockConnection.displayName = undefined; + mockConnection.titleSource = undefined; + rerender(); + }); + act(() => { + testState.latestChatEditorProps?.onSubmit('first prompt'); + }); + + await vi.waitFor(() => { + expect(mockSessionActions.attachSession).toHaveBeenCalled(); + expect(mockSessionActions.sendPrompt).toHaveBeenCalled(); + }); + }); + it('does not carry an automatic title across /clear', async () => { mockConnection.titleSource = 'auto'; const { container, rerender } = renderApp(); diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index 6da55017bbe..b78f2831ffe 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -6818,9 +6818,13 @@ export function App({ pendingManualTitle && pendingManualTitleRef.current === pendingManualTitle ) { - await sessionActions.renameSession( - pendingManualTitle.displayName, - ); + try { + await sessionActions.renameSession( + pendingManualTitle.displayName, + ); + } catch { + pendingManualTitleRef.current = undefined; + } } await onSessionCreatedRef.current?.(sessionId); },