Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 93 additions & 0 deletions packages/acp-bridge/src/bridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
31 changes: 30 additions & 1 deletion packages/acp-bridge/src/bridgeClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -460,9 +460,10 @@ export class BridgeClient implements Client {
private readonly inFlightRestoreIds = new Set<string>();

/**
* 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
Expand All @@ -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'];
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/acp-integration/session/Session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,7 @@ describe('Session', () => {
recordSlashCommand: ReturnType<typeof vi.fn>;
recordNotification: ReturnType<typeof vi.fn>;
rewindRecording: ReturnType<typeof vi.fn>;
setTitleRecordedCallback: ReturnType<typeof vi.fn>;
};
let mockGeminiClient: {
getChat: ReturnType<typeof vi.fn>;
Expand Down Expand Up @@ -265,6 +266,7 @@ describe('Session', () => {
recordSlashCommand: vi.fn(),
recordNotification: vi.fn(),
rewindRecording: vi.fn(),
setTitleRecordedCallback: vi.fn(),
};

mockToolRegistry = {
Expand Down
26 changes: 26 additions & 0 deletions packages/cli/src/acp-integration/session/Session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
25 changes: 25 additions & 0 deletions packages/core/src/services/chatRecordingService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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);
Expand Down
Loading