diff --git a/packages/acp-bridge/src/bridge.test.ts b/packages/acp-bridge/src/bridge.test.ts index cfb4eec57bc..ce7c6a188d6 100644 --- a/packages/acp-bridge/src/bridge.test.ts +++ b/packages/acp-bridge/src/bridge.test.ts @@ -6600,6 +6600,99 @@ describe('createAcpSessionBridge', () => { }); }); + describe('extNotification — session title update', () => { + const titleFactory = + (capture: (conn: AgentSideConnection) => void): ChannelFactory => + async () => { + const { clientStream, agentStream } = createInMemoryChannel(); + capture(new AgentSideConnection(() => new FakeAgent(), agentStream)); + return { + stream: clientStream, + exited: new Promise< + | { exitCode: number | null; signalCode: NodeJS.Signals | null } + | undefined + >(() => {}), + kill: async () => {}, + killSync: () => {}, + }; + }; + + it('rebroadcasts a child title-update as session_metadata_updated', async () => { + let capturedConn: AgentSideConnection | undefined; + const bridge = makeBridge({ + channelFactory: titleFactory((c) => (capturedConn = c)), + }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + + const abort = new AbortController(); + const iter = bridge.subscribeEvents(session.sessionId, { + signal: abort.signal, + }); + + void capturedConn!.extNotification('qwen/notify/session/title-update', { + v: 1, + sessionId: session.sessionId, + title: 'Fix login button on mobile', + titleSource: 'auto', + }); + + const collected: Array<{ type: string; data: unknown }> = []; + for await (const e of iter) { + collected.push({ type: e.type, data: e.data }); + if (collected.length === 1) break; + } + expect(collected[0]?.type).toBe('session_metadata_updated'); + expect(collected[0]?.data).toMatchObject({ + sessionId: session.sessionId, + displayName: 'Fix login button on mobile', + titleSource: 'auto', + }); + abort.abort(); + await bridge.shutdown(); + }); + + it('drops malformed title-update payloads', async () => { + let capturedConn: AgentSideConnection | undefined; + const bridge = makeBridge({ + channelFactory: titleFactory((c) => (capturedConn = c)), + }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + const abort = new AbortController(); + const iter = bridge.subscribeEvents(session.sessionId, { + signal: abort.signal, + }); + const seen: string[] = []; + const collecting = (async () => { + for await (const e of iter) seen.push(e.type); + })(); + + // Missing title / empty title / non-string title / missing sessionId. + void capturedConn!.extNotification('qwen/notify/session/title-update', { + v: 1, + sessionId: session.sessionId, + }); + void capturedConn!.extNotification('qwen/notify/session/title-update', { + v: 1, + sessionId: session.sessionId, + title: '', + }); + void capturedConn!.extNotification('qwen/notify/session/title-update', { + v: 1, + sessionId: session.sessionId, + title: 123 as unknown as string, + }); + void capturedConn!.extNotification('qwen/notify/session/title-update', { + v: 1, + title: 'orphan', + }); + await new Promise((r) => setTimeout(r, 10)); + abort.abort(); + await collecting; + expect(seen.filter((t) => t === 'session_metadata_updated')).toEqual([]); + await bridge.shutdown(); + }); + }); + describe('maxSessions cap (chiga0 Rec 3)', () => { it('refuses NEW spawns past the cap with SessionLimitExceededError', async () => { let n = 0; diff --git a/packages/acp-bridge/src/bridgeClient.ts b/packages/acp-bridge/src/bridgeClient.ts index c240a508a40..aa0eb97bc4e 100644 --- a/packages/acp-bridge/src/bridgeClient.ts +++ b/packages/acp-bridge/src/bridgeClient.ts @@ -460,9 +460,10 @@ export class BridgeClient implements Client { private readonly inFlightRestoreIds = new Set(); /** - * Handle child->bridge ACP `extNotification` calls. Five methods are + * Handle child->bridge ACP `extNotification` calls. Six methods are * recognized — `qwen/notify/session/model-update`, * `qwen/notify/session/mode-update`, + * `qwen/notify/session/title-update` (auto/in-process session titles), * `qwen/notify/session/prompt-suggestion` (followup assist), * `qwen/notify/session/terminal-sequence`, and * `qwen/notify/session/mcp-budget-event` — each translated into a @@ -481,6 +482,34 @@ export class BridgeClient implements Client { this.handleInSessionModeUpdate(params); return; } + if (method === 'qwen/notify/session/title-update') { + // Child-side title updates (auto-generated titles land in the child's + // chat recording — the bridge never sees the write) are rebroadcast as + // the canonical `session_metadata_updated` envelope, the same event + // manual HTTP renames publish, so clients have ONE signal for + // "this session's name changed". + const sessionId = params['sessionId']; + const title = params['title']; + if (typeof sessionId !== 'string' || typeof title !== 'string' || !title) + return; + const entry = this.resolveEntry(sessionId); + if (!entry) return; + try { + entry.events.publish({ + type: 'session_metadata_updated', + data: { + sessionId, + displayName: title, + ...(typeof params['titleSource'] === 'string' + ? { titleSource: params['titleSource'] } + : {}), + }, + }); + } catch { + /* bus already closed */ + } + return; + } if (method === 'qwen/notify/session/prompt-suggestion') { const sessionId = params['sessionId']; const suggestion = params['suggestion']; diff --git a/packages/cli/src/acp-integration/session/Session.test.ts b/packages/cli/src/acp-integration/session/Session.test.ts index 4007e130899..79da8f35d4e 100644 --- a/packages/cli/src/acp-integration/session/Session.test.ts +++ b/packages/cli/src/acp-integration/session/Session.test.ts @@ -199,6 +199,7 @@ describe('Session', () => { recordSlashCommand: ReturnType; recordNotification: ReturnType; rewindRecording: ReturnType; + setTitleRecordedCallback: ReturnType; }; let mockGeminiClient: { getChat: ReturnType; @@ -265,6 +266,7 @@ describe('Session', () => { recordSlashCommand: vi.fn(), recordNotification: vi.fn(), rewindRecording: vi.fn(), + setTitleRecordedCallback: vi.fn(), }; mockToolRegistry = { diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index bd40620f8ac..6411916952e 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -532,6 +532,7 @@ export class Session implements SessionContext { this.config.getBackgroundTaskRegistry().setNotificationCallback(undefined); this.config.getMonitorRegistry().setNotificationCallback(undefined); this.config.getBackgroundShellRegistry().setNotificationCallback(undefined); + this.config.getChatRecordingService()?.setTitleRecordedCallback(undefined); clearGoalTerminalObserver(this.sessionId); } @@ -2152,6 +2153,31 @@ export class Session implements SessionContext { kind: 'shell', }); }); + + // Session title recorded (auto-generated after a turn, or an in-process + // /rename) → notify attached clients. A title update is NOT an ACP + // `SessionUpdate` variant (the external @agentclientprotocol/sdk union + // would reject an unknown kind at validation), so — like + // `current_model_update` above — it goes over the agent→bridge + // `extNotification` side-channel. The bridge demuxes it into the + // canonical `session_metadata_updated` bus event so HTTP clients can + // refresh their session list immediately instead of discovering the + // new title on their next poll. + this.config + .getChatRecordingService() + ?.setTitleRecordedCallback((customTitle, titleSource) => { + void this.client + .extNotification('qwen/notify/session/title-update', { + v: 1, + sessionId: this.sessionId, + title: customTitle, + titleSource, + }) + .catch(() => { + // Best-effort: a dropped notification only delays the title + // until the client's next session-list refresh. + }); + }); } #enqueueBackgroundNotification(item: BackgroundNotificationQueueItem): void { diff --git a/packages/cli/src/acp-integration/session/Session.worktree.test.ts b/packages/cli/src/acp-integration/session/Session.worktree.test.ts index 31cfb5c57e0..297ef6f3cff 100644 --- a/packages/cli/src/acp-integration/session/Session.worktree.test.ts +++ b/packages/cli/src/acp-integration/session/Session.worktree.test.ts @@ -113,6 +113,7 @@ describe('Session.pendingWorktreeNotice', () => { recordToolResult: vi.fn(), recordSlashCommand: vi.fn(), rewindRecording: vi.fn(), + setTitleRecordedCallback: vi.fn(), }), getToolRegistry: vi.fn().mockReturnValue({ getTool: vi.fn(), diff --git a/packages/core/src/services/chatRecordingService.ts b/packages/core/src/services/chatRecordingService.ts index b4cccc059f3..8acd39dfbb2 100644 --- a/packages/core/src/services/chatRecordingService.ts +++ b/packages/core/src/services/chatRecordingService.ts @@ -1233,6 +1233,26 @@ export class ChatRecordingService { } } + /** + * Observer invoked after a custom title record lands (manual or auto). + * The ACP session layer registers here to push a live title notification + * to connected daemon clients — without it, auto-generated titles are + * only discoverable via the next session-list poll (generation runs in + * this child process; the daemon bridge never sees it happen). + */ + private titleRecordedCallback?: ( + customTitle: string, + titleSource: TitleSource, + ) => void; + + setTitleRecordedCallback( + callback: + | ((customTitle: string, titleSource: TitleSource) => void) + | undefined, + ): void { + this.titleRecordedCallback = callback; + } + /** * Records a custom title for the session. * Appended as a system record so it persists with the session data. @@ -1258,6 +1278,11 @@ export class ChatRecordingService { this.appendRecord(record); this.currentCustomTitle = customTitle; this.currentTitleSource = titleSource; + try { + this.titleRecordedCallback?.(customTitle, titleSource); + } catch { + // Observer errors must never break title recording. + } return true; } catch (error) { debugLogger.error('Error saving custom title record:', error);