diff --git a/patches/@agentclientprotocol+claude-agent-acp+0.60.0.patch b/patches/@agentclientprotocol+claude-agent-acp+0.60.0.patch index 45587cc87..ac242427f 100644 --- a/patches/@agentclientprotocol+claude-agent-acp+0.60.0.patch +++ b/patches/@agentclientprotocol+claude-agent-acp+0.60.0.patch @@ -1,20 +1,41 @@ diff --git a/node_modules/@agentclientprotocol/claude-agent-acp/dist/acp-agent.d.ts b/node_modules/@agentclientprotocol/claude-agent-acp/dist/acp-agent.d.ts -index f247a7e..266f90d 100644 +index f247a7e..703b16d 100644 --- a/node_modules/@agentclientprotocol/claude-agent-acp/dist/acp-agent.d.ts +++ b/node_modules/@agentclientprotocol/claude-agent-acp/dist/acp-agent.d.ts -@@ -13,6 +13,7 @@ export interface Logger { +@@ -13,6 +13,11 @@ export interface Logger { log: (...args: any[]) => void; error: (...args: any[]) => void; } ++export type ClaudeAcpAgentSdk = { ++ query: typeof import("@anthropic-ai/claude-agent-sdk").query; ++ getSessionInfo: typeof import("@anthropic-ai/claude-agent-sdk").getSessionInfo; ++}; +export declare function waitForMcpServers(query: Pick, serverNames: string[], timeoutMs?: number, logger?: Pick): Promise; type AccumulatedUsage = { inputTokens: number; outputTokens: number; +@@ -577,6 +582,7 @@ export declare class ClaudeAcpAgent { + [key: string]: Session; + }; + client: AcpClient; ++ sdk: ClaudeAcpAgentSdk; + clientCapabilities?: ClientCapabilities; + logger: Logger; + gatewayAuthRequest?: GatewayAuthRequest; +@@ -588,7 +594,7 @@ export declare class ClaudeAcpAgent { + * return "cancelled". See {@link DEFAULT_FORCE_CANCEL_GRACE_MS}. Mutable so + * tests can shrink it. */ + forceCancelGraceMs: number; +- constructor(client: AcpClient, logger?: Logger); ++ constructor(client: AcpClient, logger?: Logger, sdk?: ClaudeAcpAgentSdk); + initialize(request: InitializeRequest): Promise; + newSession(params: NewSessionRequest): Promise; + unstable_forkSession(params: ForkSessionRequest): Promise; diff --git a/node_modules/@agentclientprotocol/claude-agent-acp/dist/acp-agent.js b/node_modules/@agentclientprotocol/claude-agent-acp/dist/acp-agent.js -index 361d032..69371ea 100644 +index 361d032..8960b6f 100644 --- a/node_modules/@agentclientprotocol/claude-agent-acp/dist/acp-agent.js +++ b/node_modules/@agentclientprotocol/claude-agent-acp/dist/acp-agent.js -@@ -438,6 +438,62 @@ class ClientConnection { +@@ -438,9 +438,67 @@ class ClientConnection { return this.ctx.notify(method, params); } } @@ -22,6 +43,7 @@ index 361d032..69371ea 100644 +// supplied by the ACP client before returning session/new so the first prompt cannot race startup. +const MCP_STARTUP_TIMEOUT_MS = 10000; +const MCP_STATUS_POLL_INTERVAL_MS = 50; ++const SESSION_TITLE_READ_TIMEOUT_MS = 250; +const delay = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)); +const withTimeout = (promise, milliseconds, timeoutError) => new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(timeoutError()), milliseconds); @@ -77,7 +99,94 @@ index 361d032..69371ea 100644 export class ClaudeAcpAgent { sessions; client; -@@ -2353,7 +2409,7 @@ export class ClaudeAcpAgent { ++ sdk; + clientCapabilities; + logger; + gatewayAuthRequest; +@@ -452,9 +510,10 @@ export class ClaudeAcpAgent { + * return "cancelled". See {@link DEFAULT_FORCE_CANCEL_GRACE_MS}. Mutable so + * tests can shrink it. */ + forceCancelGraceMs = DEFAULT_FORCE_CANCEL_GRACE_MS; +- constructor(client, logger) { ++ constructor(client, logger, sdk = { query, getSessionInfo }) { + this.sessions = {}; + this.client = client; ++ this.sdk = sdk; + this.logger = logger ?? console; + } + async initialize(request) { +@@ -665,33 +724,31 @@ export class ClaudeAcpAgent { + * we pull it at turn-end. A missing session file or read error is non-fatal: + * the title is best-effort and another turn will retry. */ + async maybeUpdateSessionTitle(sessionId, session) { +- let info; + try { +- info = await getSessionInfo(sessionId, { dir: session.cwd }); +- } +- catch (error) { +- this.logger.error(`Session ${sessionId}: failed to read session info: ${error}`); +- return; +- } +- // `customTitle` is a user-set `/rename`; `summary` is the auto-generated +- // title (or first prompt). Prefer the explicit title when present. +- const rawTitle = info?.customTitle ?? info?.summary; +- if (!rawTitle) { +- return; +- } +- const title = sanitizeTitle(rawTitle); +- if (title === session.lastTitle) { +- return; ++ const info = await withTimeout(this.sdk.getSessionInfo(sessionId, { dir: session.cwd }), SESSION_TITLE_READ_TIMEOUT_MS, () => new Error(`Timed out reading title for session ${sessionId}`)); ++ if (!info) ++ return; ++ // The SDK folds both a user `/rename` and a persisted `ai-title` into ++ // `customTitle`. Its `summary` deliberately falls back through the last ++ // prompt and first prompt, so it is not evidence of an actual title and ++ // must never be published as one. ++ const customTitle = info.customTitle ? sanitizeTitle(info.customTitle) : undefined; ++ const title = customTitle; ++ if (!title) ++ return; ++ if (title === session.lastTitle) ++ return; ++ await this.client.sessionUpdate({ ++ sessionId, ++ update: { ++ sessionUpdate: "session_info_update", ++ title, ++ updatedAt: new Date(info.lastModified).toISOString(), ++ }, ++ }); ++ session.lastTitle = title; + } +- session.lastTitle = title; +- await this.client.sessionUpdate({ +- sessionId, +- update: { +- sessionUpdate: "session_info_update", +- title, +- updatedAt: new Date(info.lastModified).toISOString(), +- }, +- }); ++ catch { } + } + async authenticate(_params) { + if (_params.methodId === "gateway" || _params.methodId === "gateway-bedrock") { +@@ -2136,6 +2193,13 @@ export class ClaudeAcpAgent { + } + break; + } ++ // The CLI persists its native title alongside the terminal ++ // result on the normal user-turn path. Pull it before settling ++ // the ACP prompt so clients observe the title without depending ++ // on the trailing idle, which can arrive late or be missed after ++ // prompt teardown. Idle retains the same call as a retry for a ++ // genuinely later background ai-title write. ++ await this.maybeUpdateSessionTitle(params.sessionId, session); + // A refusal can arrive on any result subtype (and may even set + // is_error), so handle it before the subtype switch — otherwise the + // is_error throw below would surface it as an internal error. The +@@ -2353,7 +2417,7 @@ export class ClaudeAcpAgent { cache_creation_input_tokens: usage.cache_creation_input_tokens ?? prev.cache_creation_input_tokens, }; } @@ -86,7 +195,7 @@ index 361d032..69371ea 100644 if (nextUsage !== lastAssistantTotalUsage) { lastAssistantTotalUsage = nextUsage; await sendUpdate({ -@@ -2465,7 +2521,7 @@ export class ClaudeAcpAgent { +@@ -2465,7 +2529,7 @@ export class ClaudeAcpAgent { // aligned with what the user's current selection is producing. if (message.type === "assistant" && message.parent_tool_use_id === null) { lastAssistantUsage = snapshotFromUsage(message.message.usage); @@ -95,7 +204,15 @@ index 361d032..69371ea 100644 if (message.message.model && message.message.model !== "") { lastAssistantModel = message.message.model; } -@@ -4067,6 +4123,7 @@ export class ClaudeAcpAgent { +@@ -4060,13 +4124,14 @@ export class ClaudeAcpAgent { + if (abortController?.signal.aborted) { + throw new Error("Cancelled"); + } +- const q = query({ ++ const q = this.sdk.query({ + prompt: input, + options, + }); let initializationResult; try { initializationResult = await q.initializationResult(); @@ -103,7 +220,7 @@ index 361d032..69371ea 100644 } catch (error) { if (creationOpts.resume && -@@ -4282,15 +4339,10 @@ function sessionUsage(session) { +@@ -4282,15 +4347,10 @@ function sessionUsage(session) { session.accumulatedUsage.cachedWriteTokens, }; } @@ -123,7 +240,7 @@ index 361d032..69371ea 100644 } /** * Build the `data` payload attached to a `RequestError.internalError` when we -@@ -4307,7 +4359,7 @@ function errorKindData(errorKind) { +@@ -4307,7 +4367,7 @@ function errorKindData(errorKind) { } /** Project a nullable API usage object into our non-null snapshot shape. * Both SDK message_start and assistant message `usage` have `number | null` diff --git a/scripts/check-claude-acp-patch.mjs b/scripts/check-claude-acp-patch.mjs index fd1566323..a7435fdc3 100644 --- a/scripts/check-claude-acp-patch.mjs +++ b/scripts/check-claude-acp-patch.mjs @@ -19,4 +19,23 @@ if (typeof acpAgent.waitForMcpServers !== 'function') { ) } +const agentSource = require('node:fs').readFileSync( + require.resolve(`${packageName}/dist/acp-agent.js`), + 'utf8' +) +const nativeTitlePatchMarkers = [ + 'sdk = { query, getSessionInfo }', + 'withTimeout(this.sdk.getSessionInfo', + 'const customTitle = info.customTitle', + 'await this.maybeUpdateSessionTitle(params.sessionId, session);' +] + +for (const marker of nativeTitlePatchMarkers) { + if (!agentSource.includes(marker)) { + throw new Error( + `${packageName} patch is incomplete: framework-native session title marker is missing (${marker}). Run npm ci; do not edit node_modules manually.` + ) + } +} + console.log(`Verified ${packageName}@${expectedVersion} patch integrity.`) diff --git a/src/main/acp/claude-agent-acp-title-patch.test.ts b/src/main/acp/claude-agent-acp-title-patch.test.ts new file mode 100644 index 000000000..129a5c0fe --- /dev/null +++ b/src/main/acp/claude-agent-acp-title-patch.test.ts @@ -0,0 +1,270 @@ +import type { Query, SDKMessage, SDKSessionInfo } from '@anthropic-ai/claude-agent-sdk' +import type { Logger } from '@agentclientprotocol/claude-agent-acp/dist/acp-agent.js' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { ClaudeAcpAgent } from '@agentclientprotocol/claude-agent-acp/dist/acp-agent.js' + +class QueryStream { + [Symbol.asyncIterator](): AsyncIterator { + return this + } + + private readonly messages: SDKMessage[] = [] + private readonly readers: Array<(result: IteratorResult) => void> = [] + + initializationResult = vi.fn().mockResolvedValue({ + account: {}, + models: [ + { + value: 'claude-sonnet-4-5', + displayName: 'Claude Sonnet 4.5', + description: '' + } + ] + }) + supportedAgents = vi.fn().mockResolvedValue([]) + supportedCommands = vi.fn().mockResolvedValue([]) + interrupt = vi.fn().mockResolvedValue(undefined) + close = vi.fn() + + get pendingReaderCount(): number { + return this.readers.length + } + + next(): Promise> { + const message = this.messages.shift() + if (message) return Promise.resolve({ done: false, value: message }) + return new Promise((resolve) => this.readers.push(resolve)) + } + + push(message: SDKMessage): void { + const reader = this.readers.shift() + if (reader) reader({ done: false, value: message }) + else this.messages.push(message) + } +} + +const sessionState = (sessionId: string, state: 'idle'): SDKMessage => + ({ + type: 'system', + subtype: 'session_state_changed', + state, + uuid: '00000000-0000-4000-8000-000000000002', + session_id: sessionId + }) as unknown as SDKMessage + +const resultMessage = (sessionId: string): SDKMessage => + ({ + type: 'result', + subtype: 'success', + duration_ms: 1, + duration_api_ms: 1, + is_error: false, + num_turns: 1, + result: 'Done', + stop_reason: 'end_turn', + total_cost_usd: 0, + usage: { + input_tokens: 1, + output_tokens: 1, + cache_read_input_tokens: 0, + cache_creation_input_tokens: 0 + }, + modelUsage: {}, + permission_denials: [], + uuid: '00000000-0000-4000-8000-000000000001', + session_id: sessionId + }) as unknown as SDKMessage + +describe('claude-agent-acp framework-native session titles', () => { + const getSessionInfo = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + }) + + const setup = async ( + info: SDKSessionInfo | undefined, + preserveMock = false, + logger?: Logger + ): Promise<{ + agent: ClaudeAcpAgent + query: QueryStream + sessionId: string + sessionUpdate: ReturnType + }> => { + const query = new QueryStream() + if (!preserveMock) getSessionInfo.mockResolvedValue(info) + const sessionUpdate = vi.fn().mockResolvedValue(undefined) + const agent = new ClaudeAcpAgent({ sessionUpdate } as never, logger, { + query: vi.fn().mockReturnValue(query as unknown as Query), + getSessionInfo + } as never) + await agent.initialize({ protocolVersion: 1, clientCapabilities: {} }) + const { sessionId } = await agent.newSession({ cwd: process.cwd(), mcpServers: [] }) + expect(agent.sessions[sessionId].query).toBe(query) + return { agent, query, sessionId, sessionUpdate } + } + + const runPrompt = async ( + agent: ClaudeAcpAgent, + query: QueryStream, + sessionId: string + ): Promise => { + const prompt = agent.prompt({ + sessionId, + prompt: [{ type: 'text', text: '用 python 统计系统信息,产出 1 个简单的 markdown 报告' }] + }) + await vi.waitFor(() => expect(query.pendingReaderCount).toBe(1)) + query.push(resultMessage(sessionId)) + await expect(prompt).resolves.toMatchObject({ stopReason: 'end_turn' }) + } + + it('publishes an SDK title before a terminal result settles, without waiting for idle', async () => { + const { agent, query, sessionId, sessionUpdate } = await setup({ + sessionId: 'unused-by-adapter', + summary: 'Generate system info markdown report', + customTitle: 'Generate system info markdown report', + firstPrompt: '用 python 统计系统信息,产出 1 个简单的 markdown 报告', + lastModified: 1 + }) + + await runPrompt(agent, query, sessionId) + expect(sessionUpdate).toHaveBeenCalledWith({ + sessionId, + update: { + sessionUpdate: 'session_info_update', + title: 'Generate system info markdown report', + updatedAt: new Date(1).toISOString() + } + }) + }) + + it('does not publish summary when it is only the first-prompt fallback', async () => { + const firstPrompt = '你的身份是什么' + const { agent, query, sessionId, sessionUpdate } = await setup({ + sessionId: 'unused-by-adapter', + summary: firstPrompt, + firstPrompt, + lastModified: 1 + }) + + await runPrompt(agent, query, sessionId) + query.push(sessionState(sessionId, 'idle')) + await vi.waitFor(() => expect(getSessionInfo).toHaveBeenCalledTimes(2)) + + expect(sessionUpdate).not.toHaveBeenCalledWith( + expect.objectContaining({ + update: expect.objectContaining({ sessionUpdate: 'session_info_update' }) + }) + ) + }) + + it('retries at idle when the SDK persists its title after the result', async () => { + const firstPrompt = '你的身份是什么' + getSessionInfo + .mockResolvedValueOnce({ + sessionId: 'unused-by-adapter', + summary: firstPrompt, + firstPrompt, + lastModified: 1 + } satisfies SDKSessionInfo) + .mockResolvedValueOnce({ + sessionId: 'unused-by-adapter', + summary: 'Explain assistant identity', + customTitle: 'Explain assistant identity', + firstPrompt, + lastModified: 2 + } satisfies SDKSessionInfo) + const { agent, query, sessionId, sessionUpdate } = await setup(undefined, true) + + await runPrompt(agent, query, sessionId) + query.push(sessionState(sessionId, 'idle')) + await vi.waitFor(() => + expect(sessionUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + update: expect.objectContaining({ + sessionUpdate: 'session_info_update', + title: 'Explain assistant identity' + }) + }) + ) + ) + }) + + it('does not publish the same title again at idle', async () => { + const info = { + sessionId: 'unused-by-adapter', + summary: 'Explain assistant identity', + customTitle: 'Explain assistant identity', + firstPrompt: '你的身份是什么', + lastModified: 2 + } satisfies SDKSessionInfo + const { agent, query, sessionId, sessionUpdate } = await setup(info) + + await runPrompt(agent, query, sessionId) + query.push(sessionState(sessionId, 'idle')) + await vi.waitFor(() => expect(getSessionInfo).toHaveBeenCalledTimes(2)) + + const titles = sessionUpdate.mock.calls.filter( + ([notification]) => notification.update.sessionUpdate === 'session_info_update' + ) + expect(titles).toHaveLength(1) + }) + + it('settles the prompt normally when reading the framework title fails', async () => { + getSessionInfo.mockRejectedValue(new Error('transcript temporarily unavailable')) + const { agent, query, sessionId, sessionUpdate } = await setup(undefined) + + await runPrompt(agent, query, sessionId) + + expect(sessionUpdate).not.toHaveBeenCalledWith( + expect.objectContaining({ + update: expect.objectContaining({ sessionUpdate: 'session_info_update' }) + }) + ) + }) + + it('settles the prompt when reading the framework title never completes', async () => { + getSessionInfo.mockReturnValue(new Promise(() => undefined)) + const { agent, query, sessionId, sessionUpdate } = await setup(undefined, true) + + await runPrompt(agent, query, sessionId) + + expect(getSessionInfo).toHaveBeenCalledOnce() + expect(sessionUpdate).not.toHaveBeenCalledWith( + expect.objectContaining({ + update: expect.objectContaining({ sessionUpdate: 'session_info_update' }) + }) + ) + }) + + it('does not associate an autonomous result with a framework title', async () => { + const { agent, query, sessionId, sessionUpdate } = await setup(undefined) + await runPrompt(agent, query, sessionId) + getSessionInfo.mockClear() + sessionUpdate.mockClear() + getSessionInfo.mockResolvedValue({ + sessionId: 'unused-by-adapter', + summary: 'Background agent report', + customTitle: 'Background agent report', + firstPrompt: '你的身份是什么', + lastModified: 1 + }) + + await vi.waitFor(() => expect(query.pendingReaderCount).toBe(1)) + query.push({ + ...resultMessage(sessionId), + origin: { kind: 'peer', from: 'peer-session' } + } as SDKMessage) + await vi.waitFor(() => expect(query.pendingReaderCount).toBe(1)) + + expect(getSessionInfo).not.toHaveBeenCalled() + expect(sessionUpdate).not.toHaveBeenCalledWith( + expect.objectContaining({ + update: expect.objectContaining({ sessionUpdate: 'session_info_update' }) + }) + ) + await agent.closeSession({ sessionId }) + }) +}) diff --git a/src/main/acp/connection-close-workflow.ts b/src/main/acp/connection-close-workflow.ts index 4f194be0c..5cab83467 100644 --- a/src/main/acp/connection-close-workflow.ts +++ b/src/main/acp/connection-close-workflow.ts @@ -26,6 +26,7 @@ type CloseState = Readonly<{ clearHandoffContinuity: () => void clearSessionProjection: () => void disposeSessionProjection: () => void + disposeSessionAutoTitle?: () => void | Promise clearHttpRoutes: () => void selectSession: () => void publishInterruptedPromptFailures: (prompts: readonly unknown[]) => void @@ -121,6 +122,13 @@ class AcpConnectionCloseWorkflow { this.options.state.disposeActiveSessions((stage, error) => recordFailure(stage, error)) this.options.state.detachSessionConnections(true) this.options.state.clearSessionProjection() + if (this.options.state.disposeSessionAutoTitle) { + try { + await this.options.state.disposeSessionAutoTitle() + } catch (error) { + recordFailure('session-auto-title', error) + } + } runCleanup('MCP HTTP routes', this.options.state.clearHttpRoutes) this.options.state.selectSession() await this.options.resources.teardown(teardownGeneration, recordFailure) @@ -156,6 +164,13 @@ class AcpConnectionCloseWorkflow { this.options.state.clearPromptContent(teardownGeneration) this.options.state.clearHandoffContinuity() this.options.state.disposeSessionProjection() + try { + void Promise.resolve(this.options.state.disposeSessionAutoTitle?.()).catch((error) => + this.reportFailure('session-auto-title cleanup after unexpected close failed', error) + ) + } catch (error) { + this.reportFailure('session-auto-title cleanup after unexpected close failed', error) + } this.options.state.clearContextUsage() this.options.state.clearHttpRoutes() this.options.state.selectSession() @@ -182,6 +197,13 @@ class AcpConnectionCloseWorkflow { this.options.state.clearSessionProjection() this.options.state.clearContextUsage() this.options.state.clearAppliedSessionModels() + try { + void Promise.resolve(this.options.state.disposeSessionAutoTitle?.()).catch((error) => + this.reportFailure('session-auto-title cleanup during shutdown failed', error) + ) + } catch (error) { + this.reportFailure('session-auto-title cleanup during shutdown failed', error) + } } async shutdownForQuit(): Promise<{ reaped: boolean }> { this.candidateTreeKillReaped = true diff --git a/src/main/acp/ipc.test.ts b/src/main/acp/ipc.test.ts index 79bb13b9a..ef4463c93 100644 --- a/src/main/acp/ipc.test.ts +++ b/src/main/acp/ipc.test.ts @@ -62,7 +62,10 @@ const { const sendAppContinuation = vi.fn().mockResolvedValue(undefined) const sendPrompt = vi.fn().mockResolvedValue(undefined) const AcpRuntimeMock = vi.fn().mockImplementation(function (options) { + const resolveBackend = (): unknown => + options.resolveBackend?.({ forcedSkillIds: [], systemPromptAppends: [] }) return { + connect: vi.fn(resolveBackend), createSession, cancelPrompt, compactSession, @@ -75,6 +78,7 @@ const { }), resetSessionContext, resumeSession, + requestProviderReconnect: vi.fn(resolveBackend), sendAppContinuation: (request, promptAttemptId) => { const prompting = sendAppContinuation(request, promptAttemptId) options.callbacks?.onProviderPromptAccepted?.(request.sessionId, promptAttemptId) @@ -142,7 +146,7 @@ vi.mock('../storage-root', () => ({ })) const { installAcpIpcHandlers } = await import('./ipc') -const { createAcpRuntime } = await import('./runtime-composition') +const { captureAcpBackendAdmission, createAcpRuntime } = await import('./runtime-composition') const { createAcpCreateSessionWorkflow } = await import('./create-session-workflow') const { createAcpHandlerWorkflows } = await import('./handler-workflows') type AcpTestOptions = Parameters[0] @@ -187,7 +191,14 @@ const registerWithFakes = (overrides?: { authorizeSkillImportReferencedUploads: vi.fn(async () => () => undefined), settingsService: { captureActiveAgentBackendSelection: vi.fn().mockResolvedValue({}), + captureActiveExplicitAgentBackendTarget: vi.fn().mockResolvedValue({ + frameworkId: 'claude-code', + providerId: 'provider-1', + model: { kind: 'provider-default' }, + reasoningEffort: 'medium' + }), resolveAgentBackend: vi.fn().mockResolvedValue({}), + resolveExplicitAgentBackend: vi.fn().mockResolvedValue({}), listSpecialistSkillCatalog: vi .fn() .mockResolvedValue(overrides?.specialistSkillCatalog ?? []), @@ -246,6 +257,123 @@ afterEach(() => { AcpRuntimeMock.mockClear() }) +it('pins title inference to the backend target admitted for the runtime generation', async () => { + let activeTarget: { + frameworkId: 'claude-code' | 'opencode' + providerId: string + model: { kind: 'required'; id: string } + reasoningEffort: 'medium' | 'high' + } = { + frameworkId: 'claude-code', + providerId: 'provider-a', + model: { kind: 'required', id: 'model-a' }, + reasoningEffort: 'medium' + } + const resolveExplicitAgentBackend = vi.fn().mockResolvedValue({ backendId: 'backend-a' }) + const settings = { + captureActiveExplicitAgentBackendTarget: vi.fn(async () => structuredClone(activeTarget)), + resolveExplicitAgentBackend + } as never + + const admission = captureAcpBackendAdmission(settings) + await admission.resolve({}) + activeTarget = { + frameworkId: 'opencode', + providerId: 'provider-b', + model: { kind: 'required', id: 'model-b' }, + reasoningEffort: 'high' + } + + await expect(admission.target()).resolves.toMatchObject({ + frameworkId: 'claude-code', + providerId: 'provider-a', + model: { kind: 'required', id: 'model-a' } + }) + expect(resolveExplicitAgentBackend).toHaveBeenCalledWith( + expect.objectContaining({ providerId: 'provider-a' }), + {} + ) +}) + +it('resolves the newly selected provider and model after a provider reconnect', async () => { + const options = registerWithFakes() + let activeTarget = { + frameworkId: 'claude-code' as const, + providerId: 'provider-a', + model: { kind: 'required' as const, id: 'model-a' }, + reasoningEffort: 'medium' as const + } + const settings = options.settingsService as AcpTestOptions['settingsService'] & { + captureActiveExplicitAgentBackendTarget: ReturnType + resolveExplicitAgentBackend: ReturnType + } + settings.captureActiveExplicitAgentBackendTarget.mockImplementation(async () => + structuredClone(activeTarget) + ) + settings.resolveExplicitAgentBackend.mockImplementation(async (target) => ({ + backendId: `${target.providerId}:${target.model.id}` + })) + + const runtime = createAcpRuntime(options) + await runtime.connect() + activeTarget = { + frameworkId: 'claude-code', + providerId: 'provider-b', + model: { kind: 'required', id: 'model-b' }, + reasoningEffort: 'medium' + } + await runtime.requestProviderReconnect() + + expect(settings.resolveExplicitAgentBackend).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + providerId: 'provider-b', + model: { kind: 'required', id: 'model-b' } + }), + { forcedSkillIds: [], systemPromptAppends: [] } + ) +}) + +it('does not let a stale backend resolution replace the title inference target', async () => { + let activeTarget = { + frameworkId: 'claude-code' as const, + providerId: 'provider-a', + model: { kind: 'required' as const, id: 'model-a' }, + reasoningEffort: 'medium' as const + } + const releases = new Map void>() + const settings = { + captureActiveExplicitAgentBackendTarget: vi.fn(async () => structuredClone(activeTarget)), + resolveExplicitAgentBackend: vi.fn( + (target: { providerId: string }) => + new Promise((resolve) => { + releases.set(target.providerId, () => resolve({ backendId: target.providerId })) + }) + ) + } as never + const admission = captureAcpBackendAdmission(settings) + + const stale = admission.resolve({}) + await vi.waitFor(() => expect(releases.has('provider-a')).toBe(true)) + activeTarget = { + frameworkId: 'claude-code', + providerId: 'provider-b', + model: { kind: 'required', id: 'model-b' }, + reasoningEffort: 'medium' + } + const current = admission.resolve({}) + await vi.waitFor(() => expect(releases.has('provider-b')).toBe(true)) + releases.get('provider-b')?.() + await current + releases.get('provider-a')?.() + await stale + + await expect(admission.target()).resolves.toMatchObject({ + providerId: 'provider-b', + model: { kind: 'required', id: 'model-b' } + }) +}) + it('routes delegated question responses to their owner without touching Main elicitation', async () => { const respondToElicitation = vi.fn() const respondDelegatedQuestion = vi.fn().mockResolvedValue(undefined) diff --git a/src/main/acp/ipc.ts b/src/main/acp/ipc.ts index edbc15bbf..d8307b5ae 100644 --- a/src/main/acp/ipc.ts +++ b/src/main/acp/ipc.ts @@ -87,6 +87,7 @@ const registerAcpIpcHandlerSet = ( const rendererRequest: AcpPromptRequest = { ...untrustedRequest, turnIntent: request.turnIntent === 'plan-first' ? 'plan-first' : undefined, + autoTitle: request.autoTitle === true ? true : undefined, continuation: undefined, suppressUserMessage: undefined } diff --git a/src/main/acp/opencode-session-title.test.ts b/src/main/acp/opencode-session-title.test.ts new file mode 100644 index 000000000..ed841c19d --- /dev/null +++ b/src/main/acp/opencode-session-title.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it, vi } from 'vitest' + +import { fetchOpenCodeSessionTitle } from './opencode-session-title' + +describe('OpenCode Session title API', () => { + it('reads the native Session title from the generation-pinned loopback API', async () => { + const fetchImpl = vi.fn().mockResolvedValueOnce( + new Response(JSON.stringify({ id: 'provider-1', title: ' Native OpenCode Title ' }), { + status: 200 + }) + ) + + await expect( + fetchOpenCodeSessionTitle( + { baseUrl: 'https://opencode.example/v1', authorization: 'Bearer generation-1' }, + 'provider/1', + '/workspace with spaces', + fetchImpl + ) + ).resolves.toBe('Native OpenCode Title') + + expect(fetchImpl).toHaveBeenCalledOnce() + const [url, init] = fetchImpl.mock.calls[0] + expect(String(url)).toBe( + 'https://opencode.example/session/provider%2F1?directory=%2Fworkspace+with+spaces' + ) + expect(init?.headers).toEqual({ authorization: 'Bearer generation-1' }) + }) + + it.each([ + ['empty title', new Response(JSON.stringify({ title: ' ' }), { status: 200 })], + ['invalid payload', new Response(JSON.stringify([]), { status: 200 })], + ['HTTP failure', new Response('unavailable', { status: 503 })] + ])('returns no title for %s', async (_case, response) => { + await expect( + fetchOpenCodeSessionTitle( + { baseUrl: 'https://opencode.example', authorization: 'Bearer generation-1' }, + 'provider-1', + '/workspace', + vi.fn().mockResolvedValueOnce(response) + ) + ).resolves.toBeUndefined() + }) + + it('treats a rejected request as best-effort absence', async () => { + await expect( + fetchOpenCodeSessionTitle( + { baseUrl: 'https://opencode.example', authorization: 'Bearer generation-1' }, + 'provider-1', + '/workspace', + vi.fn().mockRejectedValueOnce(new Error('loopback unavailable')) + ) + ).resolves.toBeUndefined() + }) +}) diff --git a/src/main/acp/opencode-session-title.ts b/src/main/acp/opencode-session-title.ts new file mode 100644 index 000000000..0b65ade49 --- /dev/null +++ b/src/main/acp/opencode-session-title.ts @@ -0,0 +1,31 @@ +import { sanitizeSessionTitle } from '../../shared/session-persistence' +import type { ResolvedAgentBackend } from '../agent-framework' + +export const fetchOpenCodeSessionTitle = async ( + api: NonNullable, + sessionId: string, + cwd: string, + fetchImpl: typeof fetch = fetch, + signal?: AbortSignal +): Promise => { + try { + const url = new URL( + `/session/${encodeURIComponent(sessionId)}`, + api.baseUrl.endsWith('/') ? api.baseUrl : `${api.baseUrl}/` + ) + url.searchParams.set('directory', cwd) + const response = await fetchImpl(url, { + headers: { authorization: api.authorization }, + signal: signal + ? AbortSignal.any([signal, AbortSignal.timeout(2_000)]) + : AbortSignal.timeout(2_000) + }) + if (!response.ok) return undefined + + const session = (await response.json()) as unknown + if (typeof session !== 'object' || session === null || Array.isArray(session)) return undefined + return sanitizeSessionTitle((session as { title?: unknown }).title) + } catch { + return undefined + } +} diff --git a/src/main/acp/opencode-turn-adapter.test.ts b/src/main/acp/opencode-turn-adapter.test.ts index f7b1fa931..5a5510009 100644 --- a/src/main/acp/opencode-turn-adapter.test.ts +++ b/src/main/acp/opencode-turn-adapter.test.ts @@ -70,6 +70,127 @@ describe('ACP OpenCode turn adapter', () => { }) }) + it('returns a framework Session title only when OpenCode changes it during the turn', async () => { + const readUsageSnapshot = vi.fn(async () => undefined) + const readSessionTitle = vi + .fn() + .mockResolvedValueOnce('Explain ACP session naming') + .mockResolvedValueOnce('ACP Session Naming Explained') + const adapter = new AcpOpenCodeTurnAdapter(readUsageSnapshot, readSessionTitle) + + const probe = await adapter.begin({ + providerSessionId: 'provider-session-1', + cwd: '/workspace' + }) + + await expect( + probe.finalize({ response: { stopReason: 'end_turn' } as PromptResponse }) + ).resolves.toEqual({ frameworkSessionTitle: 'ACP Session Naming Explained' }) + expect(readSessionTitle).toHaveBeenNthCalledWith(1, 'provider-session-1', '/workspace') + expect(readSessionTitle).toHaveBeenNthCalledWith( + 2, + 'provider-session-1', + '/workspace', + expect.any(AbortSignal) + ) + }) + + it('waits briefly when OpenCode title generation finishes after the provider turn', async () => { + const readSessionTitle = vi + .fn() + .mockResolvedValueOnce('Explain ACP session naming') + .mockResolvedValueOnce('Explain ACP session naming') + .mockResolvedValueOnce('ACP Session Naming Explained') + const adapter = new AcpOpenCodeTurnAdapter(async () => undefined, readSessionTitle, { + titlePollIntervalMs: 0, + titlePollDeadlineMs: 100 + }) + + const probe = await adapter.begin({ + providerSessionId: 'provider-session-1', + cwd: '/workspace' + }) + + await expect( + probe.finalize({ response: { stopReason: 'end_turn' } as PromptResponse }) + ).resolves.toEqual({ frameworkSessionTitle: 'ACP Session Naming Explained' }) + expect(readSessionTitle).toHaveBeenCalledTimes(3) + }) + + it.each([ + ['unchanged', ['Prompt fallback', 'Prompt fallback']], + ['blank after', ['Prompt fallback', ' ']], + ['missing baseline', [undefined, 'Prompt fallback']] + ])('does not publish a %s Session title snapshot', async (_case, titles) => { + const readSessionTitle = vi + .fn() + .mockResolvedValueOnce(titles[0]) + .mockResolvedValueOnce(titles[1]) + const adapter = new AcpOpenCodeTurnAdapter(async () => undefined, readSessionTitle, { + titlePollDeadlineMs: 0 + }) + + const probe = await adapter.begin({ + providerSessionId: 'provider-session-1', + cwd: '/workspace' + }) + + await expect( + probe.finalize({ response: { stopReason: 'end_turn' } as PromptResponse }) + ).resolves.toEqual({}) + }) + + it('keeps title lookup failures best-effort without losing usage facts', async () => { + const snapshots: OpenCodeUsageSnapshot[] = [ + { assistantMessageIds: new Set(), usageByMessageId: new Map() }, + { + assistantMessageIds: new Set(['step-1']), + usageByMessageId: new Map([['step-1', { inputTokens: 4, cacheTokens: 1, outputTokens: 2 }]]) + } + ] + const readSessionTitle = vi.fn().mockRejectedValue(new Error('loopback unavailable')) + const adapter = new AcpOpenCodeTurnAdapter(async () => snapshots.shift(), readSessionTitle, { + titlePollDeadlineMs: 0 + }) + + const probe = await adapter.begin({ + providerSessionId: 'provider-session-1', + cwd: '/workspace' + }) + + await expect( + probe.finalize({ response: { stopReason: 'end_turn' } as PromptResponse }) + ).resolves.toMatchObject({ + turnUsage: { inputTokens: 4, cacheTokens: 1, outputTokens: 2 }, + modelTurnCount: 1 + }) + }) + + it('bounds a hanging native title request without failing the provider turn', async () => { + const readSessionTitle = vi + .fn() + .mockResolvedValueOnce('Prompt fallback') + .mockImplementationOnce( + async (_providerSessionId: string, _cwd: string, signal?: AbortSignal) => + new Promise((_resolve, reject) => { + signal?.addEventListener('abort', () => reject(signal.reason), { once: true }) + }) + ) + const adapter = new AcpOpenCodeTurnAdapter(async () => undefined, readSessionTitle, { + titlePollDeadlineMs: 5, + titlePollIntervalMs: 0 + }) + + const probe = await adapter.begin({ + providerSessionId: 'provider-session-1', + cwd: '/workspace' + }) + + await expect( + probe.finalize({ response: { stopReason: 'end_turn' } as PromptResponse }) + ).resolves.toEqual({}) + }) + it('returns empty facts when the final usage snapshot fails', async () => { const readUsageSnapshot = vi .fn() diff --git a/src/main/acp/opencode-turn-adapter.ts b/src/main/acp/opencode-turn-adapter.ts index f27bb8458..dc68021dc 100644 --- a/src/main/acp/opencode-turn-adapter.ts +++ b/src/main/acp/opencode-turn-adapter.ts @@ -4,6 +4,7 @@ import type { AcpProviderTurnProbe, AcpProviderTurnResult } from './provider-turn-adapter' +import { sanitizeSessionTitle } from '../../shared/session-persistence' import { diffOpenCodeTurnUsage, type OpenCodeUsageSnapshot } from './opencode-turn-usage' export type OpenCodeUsageSnapshotReader = ( @@ -11,15 +12,34 @@ export type OpenCodeUsageSnapshotReader = ( cwd: string ) => Promise +export type OpenCodeSessionTitleReader = ( + providerSessionId: string, + cwd: string, + signal?: AbortSignal +) => Promise + +type AcpOpenCodeTurnAdapterOptions = Readonly<{ + titlePollDeadlineMs?: number + titlePollIntervalMs?: number +}> + +const DEFAULT_TITLE_POLL_DEADLINE_MS = 2_000 +const DEFAULT_TITLE_POLL_INTERVAL_MS = 100 + const EMPTY_RESULT: AcpProviderTurnResult = Object.freeze({}) -const readSnapshotBestEffort = async ( - reader: OpenCodeUsageSnapshotReader, +const readSnapshotBestEffort = async ( + reader: ( + providerSessionId: string, + cwd: string, + signal?: AbortSignal + ) => Promise, providerSessionId: string, - cwd: string -): Promise => { + cwd: string, + signal?: AbortSignal +): Promise => { try { - return await reader(providerSessionId, cwd) + return await (signal ? reader(providerSessionId, cwd, signal) : reader(providerSessionId, cwd)) } catch { return undefined } @@ -46,33 +66,101 @@ const normalizeTurnUsage = ( }) } +const changedSessionTitle = ( + before: string | undefined, + after: string | undefined +): string | undefined => { + const baseline = sanitizeSessionTitle(before) + const title = sanitizeSessionTitle(after) + return baseline && title && baseline !== title ? title : undefined +} + +const wait = (ms: number): Promise => + ms <= 0 ? Promise.resolve() : new Promise((resolve) => setTimeout(resolve, ms)) + +const readChangedSessionTitleBestEffort = async ( + reader: OpenCodeSessionTitleReader | undefined, + baseline: string | undefined, + providerSessionId: string, + cwd: string, + options: AcpOpenCodeTurnAdapterOptions +): Promise => { + const sanitizedBaseline = sanitizeSessionTitle(baseline) + if (!reader || !sanitizedBaseline) return undefined + const deadlineMs = options.titlePollDeadlineMs ?? DEFAULT_TITLE_POLL_DEADLINE_MS + const intervalMs = options.titlePollIntervalMs ?? DEFAULT_TITLE_POLL_INTERVAL_MS + const controller = new AbortController() + const timer = deadlineMs > 0 ? setTimeout(() => controller.abort(), deadlineMs) : undefined + try { + for (;;) { + const after = await readSnapshotBestEffort(reader, providerSessionId, cwd, controller.signal) + if (!sanitizeSessionTitle(after)) { + return undefined + } + const title = changedSessionTitle(baseline, after) + if (title) return title + if (deadlineMs <= 0 || controller.signal.aborted) { + return undefined + } + await wait(intervalMs) + } + } finally { + if (timer !== undefined) clearTimeout(timer) + } +} + /** * Adapts an authenticated, credential-opaque snapshot reader into normalized provider-turn facts. * Each probe retains only its before snapshot and releases that attempt state on either close path. */ export class AcpOpenCodeTurnAdapter implements AcpProviderTurnAdapter { - constructor(private readonly readUsageSnapshot: OpenCodeUsageSnapshotReader) {} + constructor( + private readonly readUsageSnapshot: OpenCodeUsageSnapshotReader, + private readonly readSessionTitle?: OpenCodeSessionTitleReader, + private readonly options: AcpOpenCodeTurnAdapterOptions = {} + ) {} async begin(input: AcpProviderTurnBeginInput): Promise { const { providerSessionId, cwd } = input - let reader: OpenCodeUsageSnapshotReader | undefined = this.readUsageSnapshot - let before = await readSnapshotBestEffort(reader, providerSessionId, cwd) + let usageReader: OpenCodeUsageSnapshotReader | undefined = this.readUsageSnapshot + let titleReader: OpenCodeSessionTitleReader | undefined = this.readSessionTitle + const initialSnapshots = await Promise.all([ + readSnapshotBestEffort(usageReader, providerSessionId, cwd), + titleReader + ? readSnapshotBestEffort(titleReader, providerSessionId, cwd) + : Promise.resolve(undefined) + ]) + let [beforeUsage, beforeTitle] = initialSnapshots let closed = false const close = (): void => { closed = true - before = undefined - reader = undefined + beforeUsage = undefined + beforeTitle = undefined + usageReader = undefined + titleReader = undefined } return Object.freeze({ finalize: async () => { - if (closed || !reader) return EMPTY_RESULT - const baseline = before - const finalReader = reader + if (closed || !usageReader) return EMPTY_RESULT + const usageBaseline = beforeUsage + const titleBaseline = beforeTitle + const finalUsageReader = usageReader + const finalTitleReader = titleReader close() - const after = await readSnapshotBestEffort(finalReader, providerSessionId, cwd) - return normalizeTurnUsage(baseline, after) + const [finalUsage, frameworkSessionTitle] = await Promise.all([ + readSnapshotBestEffort(finalUsageReader, providerSessionId, cwd), + readChangedSessionTitleBestEffort( + finalTitleReader, + titleBaseline, + providerSessionId, + cwd, + this.options + ) + ]) + const usage = normalizeTurnUsage(usageBaseline, finalUsage) + return frameworkSessionTitle ? Object.freeze({ ...usage, frameworkSessionTitle }) : usage }, cancel: close }) diff --git a/src/main/acp/prompt-outcome-finalizer.ts b/src/main/acp/prompt-outcome-finalizer.ts index 2dc3c8378..2a7e2c98d 100644 --- a/src/main/acp/prompt-outcome-finalizer.ts +++ b/src/main/acp/prompt-outcome-finalizer.ts @@ -3,6 +3,7 @@ import type { PromptResponse } from '@agentclientprotocol/sdk' import { ACP_PROMPT_FAILED_EVENT_TITLE, type AcpRuntimeEvent, + type AcpSessionNamingUsage, type AcpTerminalContextWindow, type AcpTurnTokenUsage } from '../../shared/acp' @@ -46,6 +47,7 @@ export type AcpPromptFinalizationHandles = Readonly<{ onPromptEnded: () => void generationActivityChanged: () => void autoCompactIfNeeded: () => Promise + sessionNamingUsage?: AcpSessionNamingUsage }> type ObservedPromptStop = Readonly<{ response: PromptResponse @@ -61,7 +63,7 @@ type LogicalTurnUsage = Readonly<{ const MAX_LOGICAL_TURN_USAGE_ENTRIES = 500 -const sumTurnUsage = ( +export const sumTurnUsage = ( left: AcpTurnTokenUsage, right: AcpTurnTokenUsage ): AcpTurnTokenUsage | undefined => { @@ -195,6 +197,7 @@ export class AcpPromptOutcomeFinalizer { title: 'Prompt stopped', text: observedStop.response.stopReason, turnUsage, + ...(handles.sessionNamingUsage ? { sessionNamingUsage: handles.sessionNamingUsage } : {}), ...(observedStop.terminalContextWindow ? { terminalContextWindow: observedStop.terminalContextWindow } : {}), diff --git a/src/main/acp/prompt-turn-workflow.test.ts b/src/main/acp/prompt-turn-workflow.test.ts index 2477e9693..447e57440 100644 --- a/src/main/acp/prompt-turn-workflow.test.ts +++ b/src/main/acp/prompt-turn-workflow.test.ts @@ -9,7 +9,11 @@ import type { AcpBackendGenerationView } from './backend-generation-owner' import type { ContextWindowTurnHandle } from './context-usage-tracker' import type { AcpPromptOutcomeFinalizer } from './prompt-outcome-finalizer' import type { ReadyPreparedPromptHandle } from './prompt-preparation-owner' -import { AcpPromptTurnWorkflow, type AcpPromptTurnWorkflowOptions } from './prompt-turn-workflow' +import { + AcpPromptTurnWorkflow, + buildSessionAutoTitlePrompt, + type AcpPromptTurnWorkflowOptions +} from './prompt-turn-workflow' import { AcpSessionAggregate } from './session-aggregate' import { AcpSessionInteractionOwner } from './session-interaction-owner' import type { TurnSkillHandle } from './turn-skill-owner' @@ -130,6 +134,7 @@ const createHarness = ( preflightPlan?: AcpPromptTurnWorkflowOptions['plan']['preflight'] prepare?: AcpPromptTurnWorkflowOptions['preparation']['prepare'] providerReconnectPending?: () => boolean + sessionAutoTitle?: AcpPromptTurnWorkflowOptions['sessionAutoTitle'] sideChatClaim?: NonNullable< NonNullable['claim'] > @@ -321,7 +326,8 @@ const createHarness = ( journal.push('start') input.onPromptStarted?.() }), - emitState: vi.fn(() => journal.push('state')) + emitState: vi.fn(() => journal.push('state')), + ...(input.sessionAutoTitle ? { sessionAutoTitle: input.sessionAutoTitle } : {}) } satisfies AcpPromptTurnWorkflowOptions const workflow = new AcpPromptTurnWorkflow(workflowOptions) return { @@ -360,6 +366,45 @@ const request = (): AcpPromptRequest => ({ }) describe('AcpPromptTurnWorkflow', () => { + it('builds a non-empty naming prompt for an attachment-only first turn', () => { + expect( + buildSessionAutoTitlePrompt({ + sessionId: 's1', + text: '', + attachments: [ + { + id: 'upload-1', + sessionId: 's1', + name: 'stored-name.pdf', + originalName: 'evidence review.pdf', + path: '/uploads/stored-name.pdf', + size: 42 + } + ] + }) + ).toContain('evidence review.pdf') + }) + + it('combines app naming usage with framework provenance when the framework title wins', async () => { + const usage = { inputTokens: 7, cacheTokens: 2, outputTokens: 1, turnCount: 1 } + const harness = createHarness({ + sessionAutoTitle: { + registerPrompt: vi.fn(), + complete: vi.fn(async () => ({ kind: 'framework' as const, attempted: true, usage })) + } + }) + const prompt = request() + prompt.autoTitle = true + + await harness.workflow.run(prompt, { kind: 'user' }) + + expect(harness.finalizer.mock.calls[0][0].sessionNamingUsage).toEqual({ + source: 'combined', + appGenerated: { usage }, + frameworkUnavailable: true + }) + }) + it('admits and executes one user turn in owner order with its opaque handles', async () => { const harness = createHarness() diff --git a/src/main/acp/prompt-turn-workflow.ts b/src/main/acp/prompt-turn-workflow.ts index c574e168b..5473c123f 100644 --- a/src/main/acp/prompt-turn-workflow.ts +++ b/src/main/acp/prompt-turn-workflow.ts @@ -30,9 +30,22 @@ import type { AcpPromptSessionInteractionScope } from './session-interaction-own import type { AcpSessionToolingAvailability } from './session-presentation-policy' import type { AcpSessionRegistry } from './session-registry' import type { AcpTurnSkillOwner, TurnSkillHandle } from './turn-skill-owner' +import type { SessionAutoTitleOwner } from './session-auto-title-owner' +import { sumTurnUsage } from './prompt-outcome-finalizer' const log = createLogger('acp-prompt-turn-workflow') +const buildSessionAutoTitlePrompt = (request: AcpPromptRequest): string => { + const text = request.text.trim() + if (text) return text + const attachmentNames = (request.attachments ?? []) + .map((attachment) => attachment.originalName.trim() || attachment.name.trim()) + .filter(Boolean) + return attachmentNames.length > 0 + ? `User started the conversation with these attachments: ${attachmentNames.join(', ')}` + : '' +} + type AcpPromptTurnMode = | Readonly<{ kind: 'user'; promptAttemptId?: string }> | Readonly<{ @@ -169,6 +182,7 @@ type AcpPromptTurnWorkflowOptions = Readonly<{ recordAdmittedPrompt: (request: AcpPromptRequest) => void onPromptStarted: (sessionId: string, turnToken: string, promptAttemptId?: string) => void emitState: () => void + sessionAutoTitle?: Pick }> class AcpPromptTurnWorkflow { @@ -293,6 +307,9 @@ class AcpPromptTurnWorkflow { const eventIdentity = interaction.promptMessageId ? { promptMessageId: interaction.promptMessageId } : {} + if (request.autoTitle && turn.mode.kind === 'user' && !request.continuation) { + this.options.sessionAutoTitle?.registerPrompt(sessionId, interaction.promptMessageId) + } let artifact: ArtifactTurnHandle | undefined let prepared: PreparedPromptHandle | undefined let context: ContextWindowTurnHandle | undefined @@ -383,6 +400,7 @@ class AcpPromptTurnWorkflow { content: prepared.content, cwd: promptSnapshot?.cwd ?? this.options.currentCwd(), frameworkId: promptSnapshot?.frameworkId ?? env.backend().framework.id, + captureFrameworkTitle: request.autoTitle === true, isCurrent: () => this.isCurrent(turn), beforeDispatch: async () => { if ((await this.checkpoint(interaction)) === 'cancelled') return 'cancelled' @@ -439,6 +457,63 @@ class AcpPromptTurnWorkflow { sideChatRelaySettled = true sideChatRelay.restore() } + let sessionNamingUsage: import('../../shared/acp').AcpSessionNamingUsage | undefined + if ( + request.autoTitle && + turn.mode.kind === 'user' && + !request.continuation && + outcome.kind === 'stopped' && + this.options.sessionAutoTitle + ) { + const naming = await this.options.sessionAutoTitle.complete({ + sessionId, + prompt: buildSessionAutoTitlePrompt(request), + signal: interaction.signal, + isCurrent: () => this.isCurrent(turn) + }) + log.info('session auto-title settled', { + sessionId, + outcome: naming.kind, + ...(naming.kind === 'generated' ? { title: naming.title } : {}), + ...('attempted' in naming ? { fallbackAttempted: naming.attempted } : {}), + ...(naming.usage ? { usage: naming.usage } : {}) + }) + if (naming.kind === 'framework') { + sessionNamingUsage = naming.usage + ? { + source: 'combined', + appGenerated: { usage: naming.usage }, + frameworkUnavailable: true + } + : { source: 'framework', unavailable: true } + } else if (naming.kind === 'generated') { + sessionNamingUsage = naming.usage + ? { source: 'app-generated', usage: naming.usage } + : { source: 'app-generated', unavailable: true } + finalization.pushEvent({ + kind: 'system', + level: 'info', + sessionId, + ...eventIdentity, + sessionTitleUpdate: { title: naming.title, source: 'app-generated' }, + sessionNamingUsage + }) + } else { + sessionNamingUsage = naming.usage + ? { source: 'app-generated', usage: naming.usage } + : { source: 'app-generated', unavailable: true } + } + if (naming.usage && outcome.facts.turnUsage) { + const turnUsage = sumTurnUsage(outcome.facts.turnUsage, naming.usage) + outcome = { + ...outcome, + facts: { + ...outcome.facts, + ...(turnUsage ? { turnUsage } : {}) + } + } + } + } const model = env.backend().session.model return finalizer.finalize( { @@ -478,7 +553,8 @@ class AcpPromptTurnWorkflow { generationActivityChanged: finalization.generationActivityChanged, autoCompactIfNeeded: () => finalization.autoCompact(sessionId, session, interaction), beforeInteractionRelease: () => plan.beforeRelease(sessionId, interaction), - afterInteractionRelease: () => plan.afterRelease(sessionId) + afterInteractionRelease: () => plan.afterRelease(sessionId), + ...(sessionNamingUsage ? { sessionNamingUsage } : {}) }, outcome ) @@ -530,7 +606,7 @@ class AcpPromptTurnWorkflow { } } -export { AcpPromptTurnWorkflow } +export { AcpPromptTurnWorkflow, buildSessionAutoTitlePrompt } export type { AcpPromptTurnMode, AcpPromptTurnPlanContext, diff --git a/src/main/acp/provider-prompt-executor.test.ts b/src/main/acp/provider-prompt-executor.test.ts index a33823781..e4816ad36 100644 --- a/src/main/acp/provider-prompt-executor.test.ts +++ b/src/main/acp/provider-prompt-executor.test.ts @@ -118,6 +118,99 @@ const setup = ( } describe('AcpProviderPromptExecutor', () => { + it('routes a changed OpenCode native title through ACP Session info projection', async () => { + const api = { baseUrl: 'https://usage.example/v1', authorization: 'Bearer generation-1' } + const fetchImpl = vi + .fn() + .mockResolvedValueOnce(new Response(JSON.stringify([]), { status: 200 })) + .mockResolvedValueOnce( + new Response(JSON.stringify({ id: 'provider-1', title: 'Explain ACP naming' }), { + status: 200 + }) + ) + .mockResolvedValueOnce(new Response(JSON.stringify([]), { status: 200 })) + .mockResolvedValueOnce( + new Response(JSON.stringify({ id: 'provider-1', title: 'ACP Naming Explained' }), { + status: 200 + }) + ) + const routeNotification = vi.fn() + const session = { + sessionId: 'provider-1', + prompt: vi.fn(async () => undefined), + nextUpdate: vi.fn(async () => stop({ stopReason: 'end_turn' })) + } as unknown as ProviderPromptExecutionInput['session'] + const executor = new AcpProviderPromptExecutor({ + backendGeneration: { openCodeUsageApi: () => api }, + opencodeUsageFetch: fetchImpl + }) + + await executor.execute({ + session, + content: 'prompt', + cwd: '/workspace', + frameworkId: 'opencode', + captureFrameworkTitle: true, + isCurrent: () => true, + beforeDispatch: async () => 'active', + captureStop: () => true, + onAccepted: () => undefined, + routeNotification + }) + + expect(routeNotification).toHaveBeenCalledWith({ + sessionId: 'provider-1', + update: { + sessionUpdate: 'session_info_update', + title: 'ACP Naming Explained' + } + }) + }) + + it('does not route an OpenCode title after its runtime generation is superseded', async () => { + const api = { baseUrl: 'https://usage.example/v1', authorization: 'Bearer generation-1' } + let current = true + const fetchImpl = vi + .fn() + .mockResolvedValueOnce(new Response(JSON.stringify([]), { status: 200 })) + .mockResolvedValueOnce( + new Response(JSON.stringify({ id: 'provider-1', title: 'Prompt fallback' }), { + status: 200 + }) + ) + .mockResolvedValueOnce(new Response(JSON.stringify([]), { status: 200 })) + .mockImplementationOnce(async () => { + current = false + return new Response(JSON.stringify({ id: 'provider-1', title: 'Stale native title' }), { + status: 200 + }) + }) + const routeNotification = vi.fn() + const executor = new AcpProviderPromptExecutor({ + backendGeneration: { openCodeUsageApi: () => api }, + opencodeUsageFetch: fetchImpl + }) + + await executor.execute({ + session: { + sessionId: 'provider-1', + prompt: vi.fn(async () => undefined), + nextUpdate: vi.fn(async () => stop({ stopReason: 'end_turn' })) + } as unknown as ProviderPromptExecutionInput['session'], + content: 'prompt', + cwd: '/workspace', + frameworkId: 'opencode', + captureFrameworkTitle: true, + isCurrent: () => current, + beforeDispatch: async () => 'active', + captureStop: () => true, + onAccepted: () => undefined, + routeNotification + }) + + expect(routeNotification).not.toHaveBeenCalled() + }) + it('captures one OpenCode generation API for both usage snapshots', async () => { const api = { baseUrl: 'https://usage.example/v1', authorization: 'Bearer generation-1' } const openCodeUsageApi = vi.fn(() => api) diff --git a/src/main/acp/provider-prompt-executor.ts b/src/main/acp/provider-prompt-executor.ts index f4a852f72..8bcd15de4 100644 --- a/src/main/acp/provider-prompt-executor.ts +++ b/src/main/acp/provider-prompt-executor.ts @@ -10,6 +10,7 @@ import type { AgentFrameworkId } from '../../shared/settings' import type { AcpBackendGenerationOwner } from './backend-generation-owner' import { claudeCodeTurnAdapter } from './claude-turn-adapter' import { createCodexTurnAdapter } from './codex-turn-adapter' +import { fetchOpenCodeSessionTitle } from './opencode-session-title' import { AcpOpenCodeTurnAdapter } from './opencode-turn-adapter' import { fetchOpenCodeUsageSnapshot } from './opencode-turn-usage' import type { @@ -17,7 +18,6 @@ import type { AcpProviderTurnProbe, AcpProviderTurnResult } from './provider-turn-adapter' - type ProviderPromptObservationStage = 'accepted' | 'begin' | 'cancel' | 'finalize' | 'observe' type ProviderPromptExecutionInput = Readonly<{ @@ -25,6 +25,7 @@ type ProviderPromptExecutionInput = Readonly<{ content: string | ContentBlock[] cwd: string frameworkId: AgentFrameworkId + captureFrameworkTitle?: boolean isCurrent: () => boolean beforeDispatch: () => Promise<'active' | 'cancelled'> captureStop: () => boolean @@ -73,6 +74,7 @@ const normalizeFacts = ( ): AcpProviderTurnResult => { const turnUsage = facts.turnUsage ?? toAcpTurnTokenUsage(response.usage) return Object.freeze({ + ...(facts.frameworkSessionTitle ? { frameworkSessionTitle: facts.frameworkSessionTitle } : {}), ...(turnUsage ? { turnUsage: Object.freeze({ ...turnUsage }) } : {}), ...(facts.modelTurnCount === undefined ? {} : { modelTurnCount: facts.modelTurnCount }), ...(facts.contextUsedTokens === undefined @@ -105,7 +107,7 @@ class AcpProviderPromptExecutor { async execute(input: ProviderPromptExecutionInput): Promise { const providerSessionId = input.session.sessionId const token = Symbol(providerSessionId) - const adapter = this.adapterFor(input.frameworkId) + const adapter = this.adapterFor(input.frameworkId, input.captureFrameworkTitle === true) let probe = NOOP_PROBE try { probe = await adapter.begin({ providerSessionId, cwd: input.cwd }) @@ -190,10 +192,20 @@ class AcpProviderPromptExecutor { } catch (error) { reportBestEffort(input.reportBestEffortFailure, 'finalize', error) } + const normalizedFacts = normalizeFacts(message.response, facts) + if (normalizedFacts.frameworkSessionTitle && input.isCurrent()) { + input.routeNotification({ + sessionId: providerSessionId, + update: { + sessionUpdate: 'session_info_update', + title: normalizedFacts.frameworkSessionTitle + } + }) + } return Object.freeze({ kind: 'stopped', response: message.response, - facts: normalizeFacts(message.response, facts) + facts: normalizedFacts }) } } finally { @@ -202,22 +214,38 @@ class AcpProviderPromptExecutor { } } - private adapterFor(frameworkId: AgentFrameworkId): AcpProviderTurnAdapter { + private adapterFor( + frameworkId: AgentFrameworkId, + captureFrameworkTitle: boolean + ): AcpProviderTurnAdapter { if (frameworkId === 'claude-code') return claudeCodeTurnAdapter if (frameworkId === 'codex') return createCodexTurnAdapter() // Capture one generation's immutable API before adapter.begin awaits. Re-reading the owner for // the final snapshot could mix credentials or lose usage after a generation switch. const usageApi = this.options.backendGeneration.openCodeUsageApi() - return new AcpOpenCodeTurnAdapter((providerSessionId, cwd) => - usageApi - ? fetchOpenCodeUsageSnapshot( - usageApi, - providerSessionId, - cwd, - this.options.opencodeUsageFetch - ) - : Promise.resolve(undefined) + return new AcpOpenCodeTurnAdapter( + (providerSessionId, cwd) => + usageApi + ? fetchOpenCodeUsageSnapshot( + usageApi, + providerSessionId, + cwd, + this.options.opencodeUsageFetch + ) + : Promise.resolve(undefined), + captureFrameworkTitle + ? (providerSessionId, cwd, signal) => + usageApi + ? fetchOpenCodeSessionTitle( + usageApi, + providerSessionId, + cwd, + this.options.opencodeUsageFetch, + signal + ) + : Promise.resolve(undefined) + : undefined ) } } diff --git a/src/main/acp/provider-turn-adapter.ts b/src/main/acp/provider-turn-adapter.ts index 16b3fd460..ff03e384b 100644 --- a/src/main/acp/provider-turn-adapter.ts +++ b/src/main/acp/provider-turn-adapter.ts @@ -36,6 +36,7 @@ export type AcpProviderTurnFinalizationInput = Readonly<{ * Durable interaction, context, and Session owners decide whether and how to publish these facts. */ export type AcpProviderTurnResult = Readonly<{ + frameworkSessionTitle?: string turnUsage?: Readonly> modelTurnCount?: number contextUsedTokens?: number diff --git a/src/main/acp/runtime-composition.ts b/src/main/acp/runtime-composition.ts index a1fc01540..23fbe4e2b 100644 --- a/src/main/acp/runtime-composition.ts +++ b/src/main/acp/runtime-composition.ts @@ -50,6 +50,7 @@ import { AcpRuntime, type AcpRuntimeCallbacks, type AcpRuntimeOptions } from './ import { composeAcpRuntimeBaseOwners } from './runtime-base-composition' import { AcpRuntimeCoordinator } from './runtime-coordinator' import { composeAcpRuntimeSessionOwners } from './runtime-session-composition' +import { RestrictedInferenceRunner } from './restricted-inference-runner' const log = createLogger('acp') @@ -153,6 +154,38 @@ type AcpRuntimeCompositionOptions = AcpRuntimeArtifacts & { imageInputCompatibility?: AcpRuntimeOptions['imageInputCompatibility'] } +type AcpBackendAdmission = Readonly<{ + target: () => ReturnType + resolve: ( + context: Parameters[1] + ) => ReturnType +}> + +const captureAcpBackendAdmission = ( + settingsService: AcpSettingsCapabilities +): AcpBackendAdmission => { + type Target = Awaited< + ReturnType + > + let latestAdmission = 0 + let acceptedTarget: Target | undefined + return Object.freeze({ + target: async () => { + if (!acceptedTarget) throw new Error('ACP backend generation has not been admitted.') + return acceptedTarget + }, + resolve: async ( + context: Parameters[1] + ) => { + const admission = ++latestAdmission + const target = await settingsService.captureActiveExplicitAgentBackendTarget() + const backend = await settingsService.resolveExplicitAgentBackend(target, context) + if (admission === latestAdmission) acceptedTarget = target + return backend + } + }) +} + // Composes the compatibility façade while the coordinator remains the cross-generation Session owner. const createAcpRuntime = ({ mcpEntryPath, @@ -233,15 +266,61 @@ const createAcpRuntime = ({ const runtimeCoordinator = new AcpRuntimeCoordinator( (runtimeCallbacks, permissionGrantStore) => { - const selection = fixedBackend + // Capture a complete target for each backend connection generation. The accepted target also + // pins its restricted title tail, while reconnects read the newly selected provider/model. + const backendAdmission = fixedBackend ? undefined - : settingsService.captureActiveAgentBackendSelection() + : captureAcpBackendAdmission(settingsService) + let titleInference: RestrictedInferenceRunner | undefined + const acquireTitleInference = (): RestrictedInferenceRunner => { + titleInference ??= new RestrictedInferenceRunner({ + appVersion: app.getVersion(), + configRoot, + profileNamespace: 'session-auto-title', + resolveTarget: (target, context) => + settingsService.resolveExplicitAgentBackend(target, context) + }) + return titleInference + } + const disposeTitleInference = async (): Promise => { + const inference = titleInference + titleInference = undefined + await inference?.shutdown() + } const runtimeOptions: AcpRuntimeOptions = { appVersion: app.getVersion(), // Packaged macOS apps often start with cwd at "/" or the app bundle; use home instead. defaultCwd, - resolveBackend: async (context) => - fixedBackend ?? settingsService.resolveAgentBackend(await selection!, context), + resolveBackend: async (context) => fixedBackend ?? backendAdmission!.resolve(context), + ...(!fixedBackend + ? { + sessionAutoTitle: { + dispose: disposeTitleInference, + onCleanupTimeout: ({ activeAttempts }) => + log.warn('session auto-title cleanup timed out', { activeAttempts }), + generate: async ({ prompt, signal }) => { + log.info('session auto-title fallback inference started', { + promptPreview: prompt.slice(0, 80) + }) + const result = await acquireTitleInference().run({ + target: await backendAdmission!.target(), + signal, + agentName: 'Open Science Session Namer', + description: 'Generates one short Session title without tools.', + systemPrompt: + 'Create a concise title for a conversation from its first user message. Return only the title, with no quotes, markdown, explanation, or trailing punctuation. Use the user message language. Keep it under 60 characters. Never use tools.', + prompt: `First user message:\n${prompt}`, + outputLimitBytes: 512 + }) + log.info('session auto-title fallback inference completed', { + title: result.text, + ...(result.usage ? { usage: result.usage } : {}) + }) + return { title: result.text, ...(result.usage ? { usage: result.usage } : {}) } + } + } + } + : {}), ...(spawnAgent ? { spawnAgent } : {}), mcpHttpHost: new AgentMcpHttpHost(), skills: { @@ -520,5 +599,10 @@ const createAcpRuntime = ({ return runtimeCoordinator } -export { createAcpRuntime, createProjectAgentContextResolver, sessionHasReplayableImageHistory } +export { + captureAcpBackendAdmission, + createAcpRuntime, + createProjectAgentContextResolver, + sessionHasReplayableImageHistory +} export type { AcpRuntimeCompositionOptions } diff --git a/src/main/acp/runtime-coordinator.test.ts b/src/main/acp/runtime-coordinator.test.ts index fe3f30860..7c99f0ea0 100644 --- a/src/main/acp/runtime-coordinator.test.ts +++ b/src/main/acp/runtime-coordinator.test.ts @@ -2477,18 +2477,36 @@ describe('AcpRuntimeCoordinator', () => { await resumeRequest created[0].emitEvent(compactionEvent('late-retired-compaction', 'failed')) + created[0].emitEvent({ + id: 'late-retired-title', + timestamp: 2, + kind: 'system', + level: 'info', + sessionId: session.sessionId, + sessionTitleUpdate: { title: 'Stale title' } + }) expect(forwardedEvents.map((event) => event.id)).toEqual([ expect.stringMatching(runtimeEventId(1, 'owner-compaction')) ]) expect(coordinator.getSnapshot().events).toEqual([]) created[1].emitEvent(compactionEvent('fresh-owner-compaction', 'completed')) + created[1].emitEvent({ + id: 'fresh-owner-title', + timestamp: 3, + kind: 'system', + level: 'info', + sessionId: session.sessionId, + sessionTitleUpdate: { title: 'Fresh title' } + }) expect(forwardedEvents.map((event) => event.id)).toEqual([ expect.stringMatching(runtimeEventId(1, 'owner-compaction')), - expect.stringMatching(runtimeEventId(2, 'fresh-owner-compaction')) + expect.stringMatching(runtimeEventId(2, 'fresh-owner-compaction')), + expect.stringMatching(runtimeEventId(2, 'fresh-owner-title')) ]) expect(coordinator.getSnapshot().events.map((event) => event.id)).toEqual([ - expect.stringMatching(runtimeEventId(2, 'fresh-owner-compaction')) + expect.stringMatching(runtimeEventId(2, 'fresh-owner-compaction')), + expect.stringMatching(runtimeEventId(2, 'fresh-owner-title')) ]) retirement.resolve() diff --git a/src/main/acp/runtime-coordinator.ts b/src/main/acp/runtime-coordinator.ts index c67143210..b84d44894 100644 --- a/src/main/acp/runtime-coordinator.ts +++ b/src/main/acp/runtime-coordinator.ts @@ -39,7 +39,9 @@ const MAX_EVENTS = 500 const QUIT_PREPARATION_TIMEOUT_MS = 4_000 const isOwnershipScopedControlEvent = (event: AcpRuntimeEvent): boolean => - event.kind === 'compaction' || event.recoverable === 'context-overflow' + event.kind === 'compaction' || + event.recoverable === 'context-overflow' || + Boolean(event.sessionTitleUpdate) const hasArtifactProvenance = (event: AcpRuntimeEvent): boolean => Boolean(event.runId && event.promptMessageId && event.artifactClaimId) diff --git a/src/main/acp/runtime-events.test.ts b/src/main/acp/runtime-events.test.ts index e6f73dc3a..077b6f6b9 100644 --- a/src/main/acp/runtime-events.test.ts +++ b/src/main/acp/runtime-events.test.ts @@ -7,6 +7,25 @@ import { extractToolFailureText, toAcpRuntimeEvent } from './runtime-events' import { AcpRuntimeSnapshotOwner } from './runtime-snapshot-owner' describe('ACP runtime event normalization', () => { + it('maps session info titles to structured control data without transcript text or usage', () => { + const event = toAcpRuntimeEvent( + { + sessionId: 'session-1', + update: { sessionUpdate: 'session_info_update', title: 'Evidence synthesis' } + }, + 'event-title', + 1710000000000 + ) + + expect(event).toMatchObject({ + kind: 'system', + sessionId: 'session-1', + sessionTitleUpdate: { title: 'Evidence synthesis' } + }) + expect(event.text).toBeUndefined() + expect(event.turnUsage).toBeUndefined() + }) + it('maps assistant text chunks into readable runtime events', () => { const notification: SessionNotification = { sessionId: 'session-1', diff --git a/src/main/acp/runtime-events.ts b/src/main/acp/runtime-events.ts index 2dc10a52c..c39648cbd 100644 --- a/src/main/acp/runtime-events.ts +++ b/src/main/acp/runtime-events.ts @@ -447,10 +447,15 @@ const toAcpRuntimeEvent = ( size: update.size } } + case 'session_info_update': + return { + ...base, + kind: 'system', + sessionTitleUpdate: typeof update.title === 'string' ? { title: update.title } : undefined + } case 'available_commands_update': case 'config_option_update': case 'current_mode_update': - case 'session_info_update': return { ...base, kind: 'system', diff --git a/src/main/acp/runtime-lifecycle-composition.ts b/src/main/acp/runtime-lifecycle-composition.ts index 9dec38f48..64ba80619 100644 --- a/src/main/acp/runtime-lifecycle-composition.ts +++ b/src/main/acp/runtime-lifecycle-composition.ts @@ -96,6 +96,7 @@ const composeAcpRuntimeLifecycleOwners = ( clearHandoffContinuity: () => base.handoffContinuity.clearGeneration(), clearSessionProjection: () => session.sessionUpdateProjector.clearGeneration(), disposeSessionProjection: () => session.sessionUpdateProjector.dispose(), + disposeSessionAutoTitle: () => session.sessionAutoTitle?.shutdown(), clearHttpRoutes: () => base.sessionCapabilities.clearHttpRoutes(), selectSession: () => session.sessionRegistry.select(undefined), publishInterruptedPromptFailures: (prompts) => { diff --git a/src/main/acp/runtime-prompt-composition.ts b/src/main/acp/runtime-prompt-composition.ts index ef7db5779..0d614f816 100644 --- a/src/main/acp/runtime-prompt-composition.ts +++ b/src/main/acp/runtime-prompt-composition.ts @@ -244,7 +244,8 @@ const composeAcpRuntimePromptOwners = ( recordAdmittedPrompt: (request) => base.handoffContinuity.recordAdmittedPrompt(request), onPromptStarted: (sessionId, turnToken, promptAttemptId) => callbacks.onPromptStarted?.(sessionId, turnToken, promptAttemptId), - emitState + emitState, + sessionAutoTitle: session.sessionAutoTitle }) return Object.freeze({ contextCompactionWorkflow, promptTurnWorkflow }) diff --git a/src/main/acp/runtime-provider-session-composition.ts b/src/main/acp/runtime-provider-session-composition.ts index 0c2228737..a8097529b 100644 --- a/src/main/acp/runtime-provider-session-composition.ts +++ b/src/main/acp/runtime-provider-session-composition.ts @@ -145,6 +145,7 @@ const composeAcpRuntimeProviderSessionOwners = ( handoff: base.handoffContinuity, contextUsage: base.contextUsageTracker, projector: session.sessionUpdateProjector, + sessionAutoTitle: session.sessionAutoTitle, pushEvent: (event) => session.publication.pushEvent(event), emitState, getSnapshot: () => session.publication.getSnapshot() diff --git a/src/main/acp/runtime-session-composition.ts b/src/main/acp/runtime-session-composition.ts index ade6975f8..cb2e44e88 100644 --- a/src/main/acp/runtime-session-composition.ts +++ b/src/main/acp/runtime-session-composition.ts @@ -16,6 +16,7 @@ import type { RuntimeSnapshotProjection } from './runtime-snapshot-owner' import { AcpSessionEnvironmentPolicy } from './session-environment-policy' import { AcpSessionRegistry } from './session-registry' import { AcpSessionUpdateProjector } from './session-update-projector' +import { SessionAutoTitleOwner } from './session-auto-title-owner' const log = createLogger('acp') @@ -80,6 +81,9 @@ const composeAcpRuntimeSessionOwners = (options: AcpRuntimeOptions, base: AcpRun snapshotProjection, callbacks }) + const sessionAutoTitle = options.sessionAutoTitle + ? new SessionAutoTitleOwner(options.sessionAutoTitle) + : undefined const appContinuations = new AcpAppContinuationOwner({ activityChanged: base.notifyGenerationActivityChanged }) @@ -289,6 +293,11 @@ const composeAcpRuntimeSessionOwners = (options: AcpRuntimeOptions, base: AcpRun permissionContext.setProviderPermissionProfile(sessionId, profile), emitState: () => publication.emitState(), pushEvent: (event) => publication.pushEvent(event), + onFrameworkTitle: (sessionId, title) => { + const promptMessageId = sessionAutoTitle?.observeFrameworkTitle(sessionId) + log.info('session auto-title reused framework title', { sessionId, title }) + return promptMessageId + }, reportToolFailure: (effect) => log.warn('tool call failed', { tool: effect.tool, @@ -310,7 +319,8 @@ const composeAcpRuntimeSessionOwners = (options: AcpRuntimeOptions, base: AcpRun permissionContext, clientInteractions, reviewerSessions, - sessionUpdateProjector + sessionUpdateProjector, + sessionAutoTitle }) } /* eslint-enable @typescript-eslint/explicit-function-return-type */ diff --git a/src/main/acp/runtime.test.ts b/src/main/acp/runtime.test.ts index 7e340431b..657e67a02 100644 --- a/src/main/acp/runtime.test.ts +++ b/src/main/acp/runtime.test.ts @@ -248,6 +248,10 @@ const startFakeAgent = ( numTurns: number origin?: string }> + sessionTitleBeforePromptStop?: string + sessionTitleBeforePromptStopMeta?: Record + sessionTitleAfterPrompt?: string + sessionTitleAfterPromptDelayMs?: number } = {} ): { authRequests: unknown[] @@ -486,7 +490,31 @@ const startFakeAgent = ( } } }) - return promptResponse ?? { stopReason: 'end_turn' } + if (options.sessionTitleBeforePromptStop) { + await ctx.client.notify(acp.methods.client.session.update, { + sessionId: ctx.params.sessionId, + update: { + sessionUpdate: 'session_info_update', + title: options.sessionTitleBeforePromptStop, + ...(options.sessionTitleBeforePromptStopMeta + ? { _meta: options.sessionTitleBeforePromptStopMeta } + : {}) + } + }) + } + const response = promptResponse ?? { stopReason: 'end_turn' } + if (options.sessionTitleAfterPrompt) { + setTimeout(() => { + void ctx.client.notify(acp.methods.client.session.update, { + sessionId: ctx.params.sessionId, + update: { + sessionUpdate: 'session_info_update', + title: options.sessionTitleAfterPrompt + } + }) + }, options.sessionTitleAfterPromptDelayMs ?? 0) + } + return response }) .onNotification(acp.methods.agent.session.cancel, (ctx) => { cancelledSessions.push(ctx.params.sessionId) @@ -1320,6 +1348,42 @@ describe('ACP runtime migration write-gate', () => { await runtime.disconnect() }) + it('ignores a title notification from an old connection generation after resume', async () => { + const oldProcess = new FakeAgentProcess() + const replacementProcess = new FakeAgentProcess() + startFakeAgent(oldProcess, ['stable-session']) + startFakeAgent(replacementProcess, []) + const events: AcpRuntimeEvent[] = [] + let spawnCount = 0 + const runtime = new AcpRuntime({ + appVersion: '0.1.0', + defaultCwd: '/workspace', + spawnAgent: () => asAgentProcess(spawnCount++ === 0 ? oldProcess : replacementProcess), + callbacks: { onEvent: (event) => events.push(event) } + }) + const adapter = (runtime as unknown as { connectionAdapter: AcpAgentConnectionAdapter }) + .connectionAdapter + const open = adapter.open.bind(adapter) + const capturedHooks: Parameters[1][] = [] + vi.spyOn(adapter, 'open').mockImplementation((input, hooks) => { + capturedHooks.push(hooks) + return open(input, hooks) + }) + + await runtime.createSession({ cwd: '/workspace' }) + await runtime.disconnect() + await runtime.resumeSession({ sessionId: 'stable-session', cwd: '/workspace' }) + events.length = 0 + + capturedHooks[0]?.observeSessionUpdate({ + sessionId: 'stable-session', + update: { sessionUpdate: 'session_info_update', title: 'Stale framework title' } + }) + + expect(events).toEqual([]) + await runtime.disconnect() + }) + it('keeps using a published connection when teardown fails before resource detach', async () => { const process = new FakeAgentProcess() const fakeAgent = startFakeAgent(process, ['first-session', 'successor-session']) @@ -5244,6 +5308,363 @@ describe('ACP runtime session management', () => { ) }) + it('projects a framework session title published after the prompt has stopped', async () => { + const process = new FakeAgentProcess() + startFakeAgent(process, ['titled-session'], { + sessionTitleAfterPrompt: 'Late framework title' + }) + const events: AcpRuntimeEvent[] = [] + const runtime = new AcpRuntime({ + appVersion: '0.1.0', + defaultCwd: '/workspace', + spawnAgent: () => asAgentProcess(process), + callbacks: { onEvent: (event) => events.push(event) } + }) + + const session = await runtime.createSession({ cwd: '/workspace' }) + await expect( + runtime.sendPrompt({ sessionId: session.sessionId, text: 'name this conversation' }) + ).resolves.toMatchObject({ stopReason: 'end_turn' }) + + await vi.waitFor(() => + expect(events).toContainEqual( + expect.objectContaining({ + sessionId: 'titled-session', + sessionTitleUpdate: { title: 'Late framework title', source: 'framework' } + }) + ) + ) + }) + + it('publishes a framework session title once when it arrives before prompt stop', async () => { + const process = new FakeAgentProcess() + startFakeAgent(process, ['titled-session'], { + sessionTitleBeforePromptStop: 'Framework title' + }) + const events: AcpRuntimeEvent[] = [] + const generate = vi.fn(async () => ({ title: 'Fallback title' })) + const runtime = new AcpRuntime({ + appVersion: '0.1.0', + defaultCwd: '/workspace', + spawnAgent: () => asAgentProcess(process), + sessionAutoTitle: { graceMs: 0, generate }, + callbacks: { onEvent: (event) => events.push(event) } + }) + + const session = await runtime.createSession({ cwd: '/workspace' }) + await runtime.sendPrompt({ + sessionId: session.sessionId, + text: 'name this conversation', + autoTitle: true + }) + + expect(generate).not.toHaveBeenCalled() + expect(events.filter((event) => event.sessionTitleUpdate)).toEqual([ + expect.objectContaining({ + sessionId: 'titled-session', + sessionTitleUpdate: { title: 'Framework title', source: 'framework' }, + sessionNamingUsage: { source: 'framework', unavailable: true } + }) + ]) + expect(events.find((event) => event.kind === 'stop')?.sessionNamingUsage).toEqual({ + source: 'framework', + unavailable: true + }) + }) + + it('ignores a Codex prompt fallback, generates an app title, then accepts a native title', async () => { + const process = new FakeAgentProcess() + startFakeAgent(process, ['codex-titled-session'], { + modes: createModes(['read-only', 'agent', 'agent-full-access'], 'agent'), + sessionTitleBeforePromptStop: 'just reply hi', + sessionTitleBeforePromptStopMeta: { + 'open-science/session-title-source': 'fallback' + }, + sessionTitleAfterPrompt: 'Concise greeting response', + sessionTitleAfterPromptDelayMs: 25 + }) + const events: AcpRuntimeEvent[] = [] + const generate = vi.fn(async () => ({ title: 'Short greeting' })) + const framework = { ...codexFramework, spawn: () => asAgentProcess(process) } + const runtime = new AcpRuntime({ + appVersion: '0.1.0', + defaultCwd: '/workspace', + framework, + resolveBackend: () => ({ + framework, + executablePath: '/bin/codex-acp', + env: {} + }), + sessionAutoTitle: { graceMs: 0, generate }, + callbacks: { onEvent: (event) => events.push(event) } + }) + + const session = await runtime.createSession({ cwd: '/workspace' }) + await runtime.sendPrompt({ + sessionId: session.sessionId, + text: 'just reply hi', + autoTitle: true + }) + await vi.waitFor(() => + expect( + events.some((event) => event.sessionTitleUpdate?.title === 'Concise greeting response') + ).toBe(true) + ) + + expect(generate).toHaveBeenCalledOnce() + expect(events.filter((event) => event.sessionTitleUpdate)).toEqual([ + expect.objectContaining({ + sessionTitleUpdate: { title: 'Short greeting', source: 'app-generated' } + }), + expect.objectContaining({ + sessionTitleUpdate: { title: 'Concise greeting response', source: 'framework' } + }) + ]) + }) + + it('projects OpenCode native title metadata as a framework title after the first turn', async () => { + const process = new FakeAgentProcess() + startFakeAgent(process, ['opencode-titled-session']) + const events: AcpRuntimeEvent[] = [] + const generate = vi.fn(async () => ({ title: 'App fallback title' })) + let titleReadCount = 0 + const opencodeUsageFetch = vi.fn(async (input: RequestInfo | URL) => { + if (new URL(String(input)).pathname.endsWith('/message')) { + return new Response(JSON.stringify([]), { status: 200 }) + } + titleReadCount += 1 + return new Response( + JSON.stringify({ + id: 'opencode-titled-session', + title: + titleReadCount === 1 ? 'Explain OpenCode session naming' : 'OpenCode Session Naming' + }), + { status: 200 } + ) + }) + const framework = { ...opencodeFramework, spawn: () => asAgentProcess(process) } + const runtime = new AcpRuntime({ + appVersion: '0.1.0', + defaultCwd: '/workspace', + resolveBackend: () => ({ + framework, + executablePath: '/bin/opencode', + env: {}, + opencodeUsageApi: { + baseUrl: 'http://127.0.0.1:4242', + authorization: 'Basic generation-1' + } + }), + framework, + opencodeUsageFetch, + sessionAutoTitle: { graceMs: 0, generate }, + callbacks: { onEvent: (event) => events.push(event) } + }) + + const session = await runtime.createSession({ cwd: '/workspace' }) + await runtime.sendPrompt({ + sessionId: session.sessionId, + text: 'Explain OpenCode session naming', + autoTitle: true + }) + + expect(generate).not.toHaveBeenCalled() + expect(events.filter((event) => event.sessionTitleUpdate)).toEqual([ + expect.objectContaining({ + sessionId: 'opencode-titled-session', + sessionTitleUpdate: { title: 'OpenCode Session Naming', source: 'framework' } + }) + ]) + }) + + it('keeps a framework title that arrives while app naming is in flight', async () => { + const process = new FakeAgentProcess() + startFakeAgent(process, ['racing-title-session'], { + sessionTitleAfterPrompt: 'Framework wins', + sessionTitleAfterPromptDelayMs: 25 + }) + const events: AcpRuntimeEvent[] = [] + const generate = vi.fn( + ({ signal }: { signal: AbortSignal }) => + new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => reject(signal.reason), { once: true }) + }) + ) + const runtime = new AcpRuntime({ + appVersion: '0.1.0', + defaultCwd: '/workspace', + spawnAgent: () => asAgentProcess(process), + sessionAutoTitle: { graceMs: 0, generate }, + callbacks: { onEvent: (event) => events.push(event) } + }) + + const session = await runtime.createSession({ cwd: '/workspace' }) + const prompt = runtime.sendPrompt({ + sessionId: session.sessionId, + text: 'name this conversation', + autoTitle: true + }) + await vi.waitFor(() => expect(generate).toHaveBeenCalledOnce()) + await vi.waitFor(() => + expect(events.some((event) => event.sessionTitleUpdate?.source === 'framework')).toBe(true) + ) + await prompt + + expect(events.filter((event) => event.sessionTitleUpdate)).toEqual([ + expect.objectContaining({ + sessionTitleUpdate: { title: 'Framework wins', source: 'framework' } + }) + ]) + expect(events.find((event) => event.kind === 'stop')?.sessionNamingUsage).toEqual({ + source: 'framework', + unavailable: true + }) + }) + + it('falls back to app naming once for a new Session and includes its usage in the first turn', async () => { + const process = new FakeAgentProcess() + startFakeAgent(process, ['app-named-session']) + const events: AcpRuntimeEvent[] = [] + const generate = vi.fn(async () => ({ + title: 'Evidence synthesis', + usage: { inputTokens: 7, cacheTokens: 2, outputTokens: 1, turnCount: 1 } + })) + const runtime = new AcpRuntime({ + appVersion: '0.1.0', + defaultCwd: '/workspace', + spawnAgent: () => asAgentProcess(process), + sessionAutoTitle: { graceMs: 0, generate }, + callbacks: { onEvent: (event) => events.push(event) } + }) + + const session = await runtime.createSession({ cwd: '/workspace' }) + await runtime.sendPrompt({ + sessionId: session.sessionId, + text: 'Compare the evidence', + autoTitle: true + }) + + expect(events.find((event) => event.kind === 'stop')?.sessionNamingUsage).toEqual({ + source: 'app-generated', + usage: { inputTokens: 7, cacheTokens: 2, outputTokens: 1, turnCount: 1 } + }) + expect(events.find((event) => event.kind === 'stop')).toMatchObject({ + turnUsage: undefined, + sessionNamingUsage: { + source: 'app-generated', + usage: { inputTokens: 7, cacheTokens: 2, outputTokens: 1, turnCount: 1 } + } + }) + }) + + it('does not auto-name an existing Session when the prompt omits new-Session authority', async () => { + const process = new FakeAgentProcess() + startFakeAgent(process, ['existing-session']) + const generate = vi.fn(async () => ({ title: 'Must not run' })) + const runtime = new AcpRuntime({ + appVersion: '0.1.0', + defaultCwd: '/workspace', + spawnAgent: () => asAgentProcess(process), + sessionAutoTitle: { graceMs: 0, generate } + }) + + const session = await runtime.createSession({ cwd: '/workspace' }) + await runtime.sendPrompt({ sessionId: session.sessionId, text: 'Continue this Session' }) + + expect(generate).not.toHaveBeenCalled() + }) + + it('preserves the main answer when app session naming fails', async () => { + const process = new FakeAgentProcess() + startFakeAgent(process, ['naming-failure-session']) + const events: AcpRuntimeEvent[] = [] + const runtime = new AcpRuntime({ + appVersion: '0.1.0', + defaultCwd: '/workspace', + spawnAgent: () => asAgentProcess(process), + sessionAutoTitle: { + graceMs: 0, + generate: async () => { + throw new Error('naming backend unavailable') + } + }, + callbacks: { onEvent: (event) => events.push(event) } + }) + + const session = await runtime.createSession({ cwd: '/workspace' }) + await expect( + runtime.sendPrompt({ sessionId: session.sessionId, text: 'Answer me', autoTitle: true }) + ).resolves.toMatchObject({ stopReason: 'end_turn' }) + expect(events.some((event) => event.role === 'assistant')).toBe(true) + expect(events.some((event) => event.sessionTitleUpdate)).toBe(false) + expect(events.find((event) => event.kind === 'stop')?.sessionNamingUsage).toEqual({ + source: 'app-generated', + unavailable: true + }) + }) + + it('awaits app title inference cleanup during disconnect', async () => { + let finishCleanup = (): void => undefined + const dispose = vi.fn( + () => + new Promise((resolve) => { + finishCleanup = resolve + }) + ) + const runtime = new AcpRuntime({ + appVersion: '0.1.0', + defaultCwd: '/workspace', + sessionAutoTitle: { + graceMs: 0, + generate: async () => ({ title: 'Unused' }), + dispose + } + }) + + let disconnected = false + const disconnect = runtime.disconnect().then(() => { + disconnected = true + }) + await vi.waitFor(() => expect(dispose).toHaveBeenCalledOnce()) + expect(disconnected).toBe(false) + + finishCleanup() + await disconnect + expect(disconnected).toBe(true) + }) + + it('maps a late framework title from an adopted provider id to the app session id', async () => { + const process = new FakeAgentProcess() + startFakeAgent(process, ['provider-title-session'], { + resumeNotFound: true, + sessionTitleAfterPrompt: 'Adopted framework title' + }) + const events: AcpRuntimeEvent[] = [] + const runtime = new AcpRuntime({ + appVersion: '0.1.0', + defaultCwd: '/workspace', + spawnAgent: () => asAgentProcess(process), + callbacks: { onEvent: (event) => events.push(event) } + }) + + await expect( + runtime.resumeSession({ sessionId: 'app-title-session', cwd: '/workspace' }) + ).resolves.toMatchObject({ sessionId: 'app-title-session', contextReset: true }) + await runtime.sendPrompt({ sessionId: 'app-title-session', text: 'name this conversation' }) + + await vi.waitFor(() => + expect(events).toContainEqual( + expect.objectContaining({ + sessionId: 'app-title-session', + sessionTitleUpdate: { title: 'Adopted framework title', source: 'framework' } + }) + ) + ) + expect( + events.filter((event) => event.sessionTitleUpdate).map((event) => event.sessionId) + ).toEqual(['app-title-session']) + }) + it('adds the hidden Plan mode context only to the requested turn and preserves user Messages', async () => { const process = new FakeAgentProcess() const fakeAgent = startFakeAgent(process, ['remote-session-1']) diff --git a/src/main/acp/runtime.ts b/src/main/acp/runtime.ts index 3e809b80f..f78c4cc13 100644 --- a/src/main/acp/runtime.ts +++ b/src/main/acp/runtime.ts @@ -86,6 +86,7 @@ import type { } from './reviewer-session-owner' import type { ArtifactTurnOwner } from './artifact-turn-owner' import type { AcpSessionInteractionOwner } from './session-interaction-owner' +import type { SessionAutoTitleOwnerOptions } from './session-auto-title-owner' import type { AcpSessionRegistry } from './session-registry' import type { AcpConnectionResourceOwner, @@ -222,6 +223,7 @@ type AcpRuntimeOptions = { cancelTimeoutMs?: number setTimer?: (fn: () => void, ms: number) => ReturnType clearTimer?: (handle: ReturnType) => void + sessionAutoTitle?: SessionAutoTitleOwnerOptions // Per-session cumulative inlined-image budget in base64 bytes. Defaults to MAX_SESSION_INLINE_IMAGE_BYTES; // injectable so tests can drive the degrade-to-file path with small fixtures. inlineImageBudgetBytes?: number @@ -990,6 +992,20 @@ class AcpRuntime { observeSessionUpdate: (notification) => { this.providerSessionResumer.observeProgress(notification.sessionId) this.permissionContext.observeProviderUpdate(notification) + if ( + notification.update.sessionUpdate !== 'session_info_update' || + identity.epoch !== this.connectionGeneration + ) { + return + } + const appSessionId = this.sessionRegistry.resolveAppSessionId(notification.sessionId) + const attachment = this.sessionRegistry.lookup(appSessionId)?.attachment + if (!attachment || attachment.providerSessionId !== notification.sessionId) { + return + } + // Session metadata can legally arrive after the prompt response. Route it from the + // connection-wide notification seam instead of relying on the turn-scoped update drain. + this.sessionUpdateProjector.route(notification, { appSessionId }) }, observeClaudeSdkMessage: (params) => this.observeClaudeSdkMessage(params), filesystem: { diff --git a/src/main/acp/session-auto-title-owner.test.ts b/src/main/acp/session-auto-title-owner.test.ts new file mode 100644 index 000000000..1752af5e3 --- /dev/null +++ b/src/main/acp/session-auto-title-owner.test.ts @@ -0,0 +1,185 @@ +import { describe, expect, it, vi } from 'vitest' + +import { SessionAutoTitleOwner } from './session-auto-title-owner' + +describe('SessionAutoTitleOwner', () => { + it('retains the first prompt identity for a framework title that arrives after later turns', () => { + const owner = new SessionAutoTitleOwner({ + generate: async () => ({ title: 'Unused' }) + }) + + owner.registerPrompt('session-1', 'first-prompt') + owner.registerPrompt('session-1', 'second-prompt') + + expect(owner.observeFrameworkTitle('session-1')).toBe('first-prompt') + }) + + it('bounds a generator that ignores abort', async () => { + vi.useFakeTimers() + try { + const owner = new SessionAutoTitleOwner({ + graceMs: 0, + deadlineMs: 15_000, + generate: () => new Promise(() => undefined) + }) + + const outcome = owner.complete({ + sessionId: 'session-1', + prompt: 'Name this', + signal: new AbortController().signal, + isCurrent: () => true + }) + await Promise.resolve() + await vi.advanceTimersByTimeAsync(15_000) + + await expect(outcome).resolves.toEqual({ kind: 'unavailable', attempted: true }) + } finally { + vi.useRealTimers() + } + }) + + it('tracks timed-out generation until bounded shutdown reports unfinished cleanup', async () => { + vi.useFakeTimers() + try { + const onCleanupTimeout = vi.fn() + const owner = new SessionAutoTitleOwner({ + graceMs: 0, + deadlineMs: 100, + shutdownDeadlineMs: 50, + onCleanupTimeout, + generate: () => new Promise(() => undefined) + }) + const outcome = owner.complete({ + sessionId: 'session-1', + prompt: 'Name this', + signal: new AbortController().signal, + isCurrent: () => true + }) + + await vi.advanceTimersByTimeAsync(100) + await expect(outcome).resolves.toEqual({ kind: 'unavailable', attempted: true }) + + let shutdownSettled = false + const shutdown = owner.shutdown().then(() => { + shutdownSettled = true + }) + await vi.advanceTimersByTimeAsync(49) + expect(shutdownSettled).toBe(false) + await vi.advanceTimersByTimeAsync(1) + + await shutdown + expect(onCleanupTimeout).toHaveBeenCalledWith({ activeAttempts: 1 }) + } finally { + vi.useRealTimers() + } + }) + + it('settles promptly when the owning turn is cancelled', async () => { + const turn = new AbortController() + const generate = vi.fn(() => new Promise(() => undefined)) + const owner = new SessionAutoTitleOwner({ + graceMs: 0, + generate + }) + + const outcome = owner.complete({ + sessionId: 'session-1', + prompt: 'Name this', + signal: turn.signal, + isCurrent: () => true + }) + await vi.waitFor(() => expect(generate).toHaveBeenCalledOnce()) + turn.abort() + + await expect(outcome).resolves.toEqual({ kind: 'unavailable', attempted: true }) + }) + + it('settles active generation on shutdown', async () => { + const generate = vi.fn(() => new Promise(() => undefined)) + const owner = new SessionAutoTitleOwner({ + graceMs: 0, + generate + }) + const outcome = owner.complete({ + sessionId: 'session-1', + prompt: 'Name this', + signal: new AbortController().signal, + isCurrent: () => true + }) + + await vi.waitFor(() => expect(generate).toHaveBeenCalledOnce()) + owner.shutdown() + + await expect(outcome).resolves.toEqual({ kind: 'unavailable', attempted: true }) + }) + + it('propagates a disposer rejection that settles before the shutdown deadline', async () => { + const disposeError = new Error('restricted inference disposal failed') + const owner = new SessionAutoTitleOwner({ + generate: async () => ({ title: 'Unused' }), + dispose: async () => Promise.reject(disposeError) + }) + + await expect(owner.shutdown()).rejects.toBe(disposeError) + }) + + it('cancels active app inference as soon as a framework title arrives', async () => { + let observedSignal: AbortSignal | undefined + const generate = vi.fn( + ({ signal }: { signal: AbortSignal }) => + new Promise((_resolve, reject) => { + observedSignal = signal + signal.addEventListener('abort', () => reject(signal.reason), { once: true }) + }) + ) + const owner = new SessionAutoTitleOwner({ + graceMs: 0, + generate + }) + const outcome = owner.complete({ + sessionId: 'session-1', + prompt: 'Name this', + signal: new AbortController().signal, + isCurrent: () => true + }) + await vi.waitFor(() => expect(generate).toHaveBeenCalledOnce()) + + owner.observeFrameworkTitle('session-1') + + expect(observedSignal?.aborted).toBe(true) + await expect(outcome).resolves.toEqual({ kind: 'framework', attempted: true }) + }) + + it('does not start inference for an empty naming prompt', async () => { + const generate = vi.fn(async () => ({ title: 'Empty' })) + const owner = new SessionAutoTitleOwner({ graceMs: 0, generate }) + + await expect( + owner.complete({ + sessionId: 'session-1', + prompt: ' ', + signal: new AbortController().signal, + isCurrent: () => true + }) + ).resolves.toEqual({ kind: 'unavailable', attempted: false }) + expect(generate).not.toHaveBeenCalled() + }) + + it('contains a synchronous generator failure', async () => { + const owner = new SessionAutoTitleOwner({ + graceMs: 0, + generate: () => { + throw new Error('failed before returning a Promise') + } + }) + + await expect( + owner.complete({ + sessionId: 'session-1', + prompt: 'Name this', + signal: new AbortController().signal, + isCurrent: () => true + }) + ).resolves.toEqual({ kind: 'unavailable', attempted: true }) + }) +}) diff --git a/src/main/acp/session-auto-title-owner.ts b/src/main/acp/session-auto-title-owner.ts new file mode 100644 index 000000000..c3afbce4c --- /dev/null +++ b/src/main/acp/session-auto-title-owner.ts @@ -0,0 +1,228 @@ +import type { AcpTurnTokenUsage } from '../../shared/acp' +import { sanitizeSessionTitle } from '../../shared/session-persistence' + +const DEFAULT_SESSION_AUTO_TITLE_GRACE_MS = 250 +const DEFAULT_SESSION_AUTO_TITLE_DEADLINE_MS = 15_000 +const DEFAULT_SESSION_AUTO_TITLE_SHUTDOWN_DEADLINE_MS = 5_000 + +type SessionAutoTitleGeneration = Readonly<{ + title: string + usage?: AcpTurnTokenUsage +}> + +type SessionAutoTitleOwnerOptions = Readonly<{ + graceMs?: number + deadlineMs?: number + shutdownDeadlineMs?: number + generate: (input: { prompt: string; signal: AbortSignal }) => Promise + dispose?: () => Promise + onCleanupTimeout?: (details: { activeAttempts: number }) => void + setTimer?: (callback: () => void, ms: number) => ReturnType + clearTimer?: (timer: ReturnType) => void +}> + +type ActiveTitleAttempt = { + sessionId: string + controller: AbortController + generation?: Promise +} + +type SessionAutoTitleOutcome = + | Readonly<{ kind: 'framework'; attempted: boolean; usage?: AcpTurnTokenUsage }> + | Readonly<{ kind: 'generated'; title: string; usage?: AcpTurnTokenUsage }> + | Readonly<{ kind: 'unavailable'; attempted: boolean; usage?: AcpTurnTokenUsage }> + +// Owns the intentionally small race between a framework-native title and the tool-less app fallback. +// Callers see one outcome; timers, abort forwarding, title sanitization, and shutdown stay private. +class SessionAutoTitleOwner { + private readonly frameworkTitles = new Set() + private readonly promptMessageIds = new Map() + private readonly active = new Map>() + + constructor(private readonly options: SessionAutoTitleOwnerOptions) {} + + registerPrompt(sessionId: string, promptMessageId: string | undefined): void { + if (promptMessageId && !this.promptMessageIds.has(sessionId)) { + this.promptMessageIds.set(sessionId, promptMessageId) + } + } + + observeFrameworkTitle(sessionId: string): string | undefined { + this.frameworkTitles.add(sessionId) + for (const attempt of this.active.get(sessionId) ?? []) attempt.controller.abort() + return this.promptMessageIds.get(sessionId) + } + + async complete(input: { + sessionId: string + prompt: string + signal: AbortSignal + isCurrent: () => boolean + }): Promise { + if (this.frameworkTitles.delete(input.sessionId)) return { kind: 'framework', attempted: false } + if (!input.prompt.trim() || input.signal.aborted || !input.isCurrent()) { + return { kind: 'unavailable', attempted: false } + } + const controller = new AbortController() + const abort = (): void => controller.abort() + input.signal.addEventListener('abort', abort, { once: true }) + const attempt: ActiveTitleAttempt = { sessionId: input.sessionId, controller } + const sessionAttempts = this.active.get(input.sessionId) ?? new Set() + sessionAttempts.add(attempt) + this.active.set(input.sessionId, sessionAttempts) + try { + await this.waitForFrameworkGrace(controller.signal) + if (this.frameworkTitles.delete(input.sessionId)) { + return { kind: 'framework', attempted: false } + } + if (controller.signal.aborted || !input.isCurrent()) { + return { kind: 'unavailable', attempted: false } + } + const result = await this.generateWithinDeadline(input.prompt, attempt) + const frameworkWon = this.frameworkTitles.delete(input.sessionId) + if (frameworkWon) { + return { + kind: 'framework', + attempted: true, + ...(result.kind === 'completed' && result.value.usage + ? { usage: result.value.usage } + : {}) + } + } + if (result.kind !== 'completed' || !input.isCurrent()) { + return { kind: 'unavailable', attempted: true } + } + const generation = result.value + const title = sanitizeSessionTitle(generation.title) + return title + ? { kind: 'generated', title, ...(generation.usage ? { usage: generation.usage } : {}) } + : { + kind: 'unavailable', + attempted: true, + ...(generation.usage ? { usage: generation.usage } : {}) + } + } finally { + input.signal.removeEventListener('abort', abort) + if (!attempt.generation) this.removeAttempt(input.sessionId, attempt) + } + } + + clearSession(sessionId: string): void { + this.frameworkTitles.delete(sessionId) + this.promptMessageIds.delete(sessionId) + for (const attempt of this.active.get(sessionId) ?? []) attempt.controller.abort() + } + + shutdown(): Promise { + const attempts = Array.from(this.active.values()).flatMap((sessionAttempts) => [ + ...sessionAttempts + ]) + for (const attempt of attempts) attempt.controller.abort() + this.frameworkTitles.clear() + this.promptMessageIds.clear() + const generations = Promise.allSettled( + attempts.flatMap((attempt) => (attempt.generation ? [attempt.generation] : [])) + ).then(() => undefined) + const disposer = this.options.dispose + ? Promise.resolve().then(() => this.options.dispose?.()) + : Promise.resolve() + const cleanup = Promise.all([generations, disposer]).then(() => undefined) + return this.waitForShutdownCleanup(cleanup, attempts.length) + } + + private async generateWithinDeadline( + prompt: string, + attempt: ActiveTitleAttempt + ): Promise< + | Readonly<{ kind: 'completed'; value: SessionAutoTitleGeneration }> + | Readonly<{ kind: 'failed' | 'cancelled' | 'timed-out' }> + > { + const setTimer = this.options.setTimer ?? setTimeout + const clearTimer = this.options.clearTimer ?? clearTimeout + const deadline = this.options.deadlineMs ?? DEFAULT_SESSION_AUTO_TITLE_DEADLINE_MS + let timer: ReturnType | undefined + let removeAbortListener = (): void => undefined + const interrupted = new Promise>((resolve) => { + const onAbort = (): void => resolve({ kind: 'cancelled' }) + attempt.controller.signal.addEventListener('abort', onAbort, { once: true }) + removeAbortListener = () => attempt.controller.signal.removeEventListener('abort', onAbort) + if (deadline <= 0) { + attempt.controller.abort() + resolve({ kind: 'timed-out' }) + return + } + timer = setTimer(() => { + attempt.controller.abort() + resolve({ kind: 'timed-out' }) + }, deadline) + }) + const generated = Promise.resolve() + .then(() => this.options.generate({ prompt, signal: attempt.controller.signal })) + .then((value) => ({ kind: 'completed' as const, value })) + .catch(() => ({ kind: 'failed' as const })) + attempt.generation = generated.then(() => undefined) + void attempt.generation.finally(() => this.removeAttempt(attempt.sessionId, attempt)) + try { + return await Promise.race([generated, interrupted]) + } finally { + if (timer !== undefined) clearTimer(timer) + removeAbortListener() + } + } + + private removeAttempt(sessionId: string, attempt: ActiveTitleAttempt): void { + const sessionAttempts = this.active.get(sessionId) + sessionAttempts?.delete(attempt) + if (sessionAttempts?.size === 0) this.active.delete(sessionId) + } + + private async waitForShutdownCleanup( + cleanup: Promise, + activeAttempts: number + ): Promise { + const deadline = + this.options.shutdownDeadlineMs ?? DEFAULT_SESSION_AUTO_TITLE_SHUTDOWN_DEADLINE_MS + if (deadline <= 0) { + void cleanup.catch(() => undefined) + this.options.onCleanupTimeout?.({ activeAttempts }) + return + } + const setTimer = this.options.setTimer ?? setTimeout + const clearTimer = this.options.clearTimer ?? clearTimeout + let timer: ReturnType | undefined + const timedOut = new Promise<'timed-out'>((resolve) => { + timer = setTimer(() => resolve('timed-out'), deadline) + }) + let result: 'completed' | 'timed-out' + try { + result = await Promise.race([cleanup.then(() => 'completed' as const), timedOut]) + } finally { + if (timer !== undefined) clearTimer(timer) + } + if (result === 'timed-out') this.options.onCleanupTimeout?.({ activeAttempts }) + } + + private waitForFrameworkGrace(signal: AbortSignal): Promise { + const delay = this.options.graceMs ?? DEFAULT_SESSION_AUTO_TITLE_GRACE_MS + if (delay <= 0 || signal.aborted) return Promise.resolve() + const setTimer = this.options.setTimer ?? setTimeout + const clearTimer = this.options.clearTimer ?? clearTimeout + return new Promise((resolve) => { + const finish = (): void => { + clearTimer(timer) + signal.removeEventListener('abort', finish) + resolve() + } + const timer = setTimer(finish, delay) + signal.addEventListener('abort', finish, { once: true }) + }) + } +} + +export { + DEFAULT_SESSION_AUTO_TITLE_DEADLINE_MS, + DEFAULT_SESSION_AUTO_TITLE_GRACE_MS, + DEFAULT_SESSION_AUTO_TITLE_SHUTDOWN_DEADLINE_MS, + SessionAutoTitleOwner +} +export type { SessionAutoTitleGeneration, SessionAutoTitleOutcome, SessionAutoTitleOwnerOptions } diff --git a/src/main/acp/session-deletion-workflow.ts b/src/main/acp/session-deletion-workflow.ts index eefefdde8..62f001244 100644 --- a/src/main/acp/session-deletion-workflow.ts +++ b/src/main/acp/session-deletion-workflow.ts @@ -12,6 +12,7 @@ import type { AcpSessionCapabilityOwner } from './session-capability-owner' import type { AcpSessionInteractionOwner } from './session-interaction-owner' import type { AcpSessionRegistry, AcpSessionRegistryEntry } from './session-registry' import type { AcpSessionUpdateProjector } from './session-update-projector' +import type { SessionAutoTitleOwner } from './session-auto-title-owner' type SessionDeletedEvent = Omit type OperationLease = (work: () => Promise) => Promise @@ -32,6 +33,7 @@ type AcpSessionDeletionWorkflowDependencies = Readonly<{ handoff: Pick contextUsage: Pick projector: Pick + sessionAutoTitle?: Pick pushEvent: (event: SessionDeletedEvent) => void emitState: () => void getSnapshot: () => AcpStateSnapshot @@ -64,6 +66,7 @@ class AcpSessionDeletionWorkflow { this.deps.clearUserChoiceProvenanceForSession(appSessionId) this.deps.elicitation.cancelForSession(appSessionId) this.deps.appContinuations.delete(appSessionId) + this.deps.sessionAutoTitle?.clearSession(appSessionId) if (attachment) await this.deleteProviderSession(attachment.session.sessionId) if (attachment) { diff --git a/src/main/acp/session-update-projector.test.ts b/src/main/acp/session-update-projector.test.ts index 811a637c6..18c34f173 100644 --- a/src/main/acp/session-update-projector.test.ts +++ b/src/main/acp/session-update-projector.test.ts @@ -39,7 +39,8 @@ const createProjector = ( notification: SessionNotification, context: Readonly<{ sessionId: string }>, providerToolCallId: string - ) => string = (_notification, _context, providerToolCallId) => providerToolCallId + ) => string = (_notification, _context, providerToolCallId) => providerToolCallId, + onFrameworkTitle?: (sessionId: string, title: string) => string | undefined ): TestProjector => { let routing: TestRouting = { eventId: 'event-route', @@ -105,6 +106,7 @@ const createProjector = ( }, emitState: () => undefined, pushEvent: (event) => record({ kind: 'visible-event', event }), + ...(onFrameworkTitle ? { onFrameworkTitle } : {}), reportToolFailure: (effect) => record(effect) }) return { @@ -168,6 +170,117 @@ describe('AcpSessionUpdateProjector', () => { ) }) + it('projects a framework title onto the app Session id without conversation noise', () => { + const projector = createProjector() + const notification: SessionNotification = { + sessionId: 'provider-session', + update: { sessionUpdate: 'session_info_update', title: ' Evidence synthesis ' } + } + const routing = { + appSessionId: 'app-session', + eventId: 'event-title', + visible: true, + reconnectPending: false, + mcpServerNames: [] + } + + expect(projector.route(notification, routing)).toEqual([ + { + kind: 'visible-event', + event: expect.objectContaining({ + sessionId: 'app-session', + sessionTitleUpdate: { title: 'Evidence synthesis', source: 'framework' }, + sessionNamingUsage: { source: 'framework', unavailable: true }, + title: undefined, + text: undefined + }) + } + ]) + expect(projector.route(notification, { ...routing, reconnectPending: true })).toEqual([]) + }) + + it('ignores Codex prompt fallbacks while retaining later native titles', () => { + const onFrameworkTitle = vi.fn(() => 'first-prompt') + const projector = createProjector(undefined, true, undefined, onFrameworkTitle) + const routing = { + framework: 'codex' as const, + appSessionId: 'app-session', + eventId: 'event-fallback-title', + visible: true, + reconnectPending: false, + mcpServerNames: [] + } + + expect( + projector.route( + { + sessionId: 'provider-session', + update: { + sessionUpdate: 'session_info_update', + title: 'just reply hi', + _meta: { 'open-science/session-title-source': 'fallback' } + } + }, + routing + ) + ).toEqual([]) + expect(onFrameworkTitle).not.toHaveBeenCalled() + + expect( + projector.route( + { + sessionId: 'provider-session', + update: { + sessionUpdate: 'session_info_update', + title: 'Concise greeting response' + } + }, + { ...routing, eventId: 'event-native-title' } + ) + ).toEqual([ + { + kind: 'visible-event', + event: expect.objectContaining({ + sessionId: 'app-session', + promptMessageId: 'first-prompt', + sessionTitleUpdate: { title: 'Concise greeting response', source: 'framework' } + }) + } + ]) + expect(onFrameworkTitle).toHaveBeenCalledOnce() + }) + + it('projects the retained first-turn identity onto a late framework title', () => { + const onFrameworkTitle = vi.fn(() => 'first-prompt') + const projector = createProjector(undefined, true, undefined, onFrameworkTitle) + + expect( + projector.route( + { + sessionId: 'provider-session', + update: { sessionUpdate: 'session_info_update', title: 'Late framework title' } + }, + { + appSessionId: 'app-session', + eventId: 'late-title', + visible: true, + reconnectPending: false, + mcpServerNames: [] + } + ) + ).toEqual([ + { + kind: 'visible-event', + event: expect.objectContaining({ + sessionId: 'app-session', + promptMessageId: 'first-prompt', + sessionTitleUpdate: { title: 'Late framework title', source: 'framework' } + }) + } + ]) + expect(onFrameworkTitle).toHaveBeenCalledWith('app-session', 'Late framework title') + }) + it('routes stable Session usage through context owners in projection order', () => { const journal: string[] = [] const beginSession = vi.fn(() => journal.push('context:begin')) diff --git a/src/main/acp/session-update-projector.ts b/src/main/acp/session-update-projector.ts index 2e06199bf..ded0ae790 100644 --- a/src/main/acp/session-update-projector.ts +++ b/src/main/acp/session-update-projector.ts @@ -1,8 +1,13 @@ import type { SessionNotification } from '@agentclientprotocol/sdk' -import type { AcpContextUsage, AcpRuntimeEvent } from '../../shared/acp' +import { + ACP_SESSION_TITLE_SOURCE_META_KEY, + type AcpContextUsage, + type AcpRuntimeEvent +} from '../../shared/acp' import type { SessionPermissionProfileState } from '../../shared/permission-profiles' import type { AgentFrameworkId } from '../../shared/settings' +import { sanitizeSessionTitle } from '../../shared/session-persistence' import { resolveCanonicalMcpToolIdentity } from '../agent-framework/app-mcp-names' import { CodexSkillActivityProjector } from './codex-skill-activity' import type { AcpContextUsagePolicy } from './context-usage-policy' @@ -15,7 +20,6 @@ import { toAcpRuntimeEvent } from './runtime-events' import type { AcpSessionRegistry } from './session-registry' - const CODEX_COMPACTION_WARNING = 'Warning: Heads up: Long threads and multiple compactions can cause the model to be less accurate. Start a new thread when possible to keep threads small and targeted.' const CODEX_LEGACY_COMPACTION_NOTICE = "*Context compacted to fit the model's context window.*" @@ -92,12 +96,17 @@ type AcpSessionUpdateProjectorOptions = Readonly<{ ) => boolean emitState: () => void pushEvent: (event: Readonly) => void + onFrameworkTitle?: (sessionId: string, title: string) => string | undefined reportToolFailure: ( effect: Extract ) => void }> type AcpSessionUpdateEffect = + | Readonly<{ + kind: 'session-title-update' + event: Readonly + }> | Readonly<{ kind: 'context-observation' sessionId: string @@ -164,6 +173,7 @@ const toolObservation = ( class AcpSessionUpdateProjector { private readonly codexSkillActivity = new CodexSkillActivityProjector() private readonly appOwnedUserChoiceToolCallIds = new Map>() + private readonly publishedSessionTitles = new Map() constructor(private readonly options: AcpSessionUpdateProjectorOptions) {} @@ -174,11 +184,13 @@ class AcpSessionUpdateProjector { clearGeneration(): void { this.codexSkillActivity.setSkillsRoot(undefined) this.appOwnedUserChoiceToolCallIds.clear() + this.publishedSessionTitles.clear() } clearSession(sessionId: string): void { this.codexSkillActivity.clearSession(sessionId) this.appOwnedUserChoiceToolCallIds.delete(sessionId) + this.publishedSessionTitles.delete(sessionId) } dispose(): void { @@ -251,6 +263,41 @@ class AcpSessionUpdateProjector { } : unaliasedEvent ) + if (routed.update.sessionUpdate === 'session_info_update') { + if (routing.reconnectPending || !routing.visible) { + return Object.freeze([]) + } + const updateMeta = (routed.update as typeof routed.update & { _meta?: unknown })._meta + if ( + routing.framework === 'codex' && + isRecord(updateMeta) && + updateMeta[ACP_SESSION_TITLE_SOURCE_META_KEY] === 'fallback' + ) { + return Object.freeze([]) + } + const title = sanitizeSessionTitle(routed.update.title) + if (!title) { + return Object.freeze([]) + } + if (this.publishedSessionTitles.get(routed.sessionId) === title) { + return Object.freeze([]) + } + this.publishedSessionTitles.set(routed.sessionId, title) + const promptMessageId = this.options.onFrameworkTitle?.(routed.sessionId, title) + return Object.freeze([ + deepFreeze({ + kind: 'session-title-update' as const, + event: { + ...event, + ...(promptMessageId ? { promptMessageId } : {}), + sessionTitleUpdate: { title, source: 'framework' as const }, + sessionNamingUsage: { source: 'framework' as const, unavailable: true as const }, + title: undefined, + text: undefined + } + }) + ]) + } // codex-acp 1.1.4 flattens Codex's post-compaction warning into an unscoped assistant chunk. // Keep the separate compaction notice, but do not attribute this adapter-authored warning to the model. if ( @@ -379,6 +426,9 @@ class AcpSessionUpdateProjector { effect.observation ) break + case 'session-title-update': + this.options.pushEvent(effect.event) + break case 'current-mode': { const aggregate = this.options.registry.lookup(effect.sessionId)?.aggregate const profileState = aggregate?.snapshot().permissionProfile diff --git a/src/main/acp/task-agent-port.ts b/src/main/acp/task-agent-port.ts index 9c6a8e5f8..9c505c060 100644 --- a/src/main/acp/task-agent-port.ts +++ b/src/main/acp/task-agent-port.ts @@ -38,7 +38,8 @@ const toAcpPromptRequest = (request: TaskAgentPromptRequest): AcpPromptRequest = ...(request.skillIds?.length ? { forcedSkillIds: request.skillIds } : {}), ...(request.historyPreamble ? { historyPreamble: request.historyPreamble } : {}), ...(request.contextReset ? { contextReset: true } : {}), - ...(request.resumeFallback ? { resumeFallback: request.resumeFallback } : {}) + ...(request.resumeFallback ? { resumeFallback: request.resumeFallback } : {}), + ...(request.autoTitle ? { autoTitle: true as const } : {}) }) // Adapts the provider-neutral Task seam to the existing ACP owner without exposing the coordinator, diff --git a/src/main/application-command-composition.test.ts b/src/main/application-command-composition.test.ts index 3ff78c2b6..b9db9403a 100644 --- a/src/main/application-command-composition.test.ts +++ b/src/main/application-command-composition.test.ts @@ -251,13 +251,14 @@ describe('application command composition', () => { expect(composition.task.commandNames()).not.toContain('reviewer:abort-fix-loop') }) - it('exposes only the thirteen Task commands and no transport-wide capability', async () => { + it('exposes only the fourteen Task commands and no transport-wide capability', async () => { const composition = createApplicationCommandComposition(dependencies()) expect(composition.task.commandNames()).toEqual([ 'projects:list', 'projects:create', 'sessions:load-all', + 'sessions:apply-agent-title', 'sessions:save-session', 'sessions:set-delegation-policy', 'acp:get-plan-projection', diff --git a/src/main/application-command-composition.ts b/src/main/application-command-composition.ts index e7fa8a98e..0582aed3d 100644 --- a/src/main/application-command-composition.ts +++ b/src/main/application-command-composition.ts @@ -135,6 +135,7 @@ const TASK_COMMAND_NAMES = Object.freeze([ 'projects:list', 'projects:create', 'sessions:load-all', + 'sessions:apply-agent-title', 'sessions:save-session', 'sessions:set-delegation-policy', 'acp:get-plan-projection', diff --git a/src/main/data-content-application-commands.test.ts b/src/main/data-content-application-commands.test.ts index 678d35a29..cd1eee078 100644 --- a/src/main/data-content-application-commands.test.ts +++ b/src/main/data-content-application-commands.test.ts @@ -214,6 +214,7 @@ const WRAPPED_COMMAND_KEYS = [ 'projectCreate', 'projectDelete', 'projectUpdate', + 'sessionApplyAgentTitle', 'sessionDelete', 'sessionExportConversation', 'sessionLoadAll', @@ -281,6 +282,7 @@ describe('Data and content application commands', () => { 'projects:get', 'projects:list', 'projects:update', + 'sessions:apply-agent-title', 'sessions:delete-session', 'sessions:export-conversation', 'sessions:load-all', diff --git a/src/main/data-content-application-commands.ts b/src/main/data-content-application-commands.ts index 8affed6d6..011582211 100644 --- a/src/main/data-content-application-commands.ts +++ b/src/main/data-content-application-commands.ts @@ -251,6 +251,7 @@ const dataContentApplicationCommands = Object.freeze({ 'update', Projects.projectApplicationCommandContracts.update ), + sessionApplyAgentTitle: sessionCommand('sessions:apply-agent-title', 'applyAgentSessionTitle'), sessionDelete: sessionCommand( 'sessions:delete-session', 'deleteSession', @@ -335,6 +336,7 @@ const dataContentApplicationCommandGroups = Object.freeze([ dataContentApplicationCommands.projectUpdate ] as const), defineApplicationCommandGroup('sessions', [ + dataContentApplicationCommands.sessionApplyAgentTitle, dataContentApplicationCommands.sessionDelete, dataContentApplicationCommands.sessionExportConversation, dataContentApplicationCommands.sessionLoadAll, @@ -507,6 +509,17 @@ const registerDataContentApplicationCommands = ( } }) scope.registerGroup(dataContentApplicationCommandGroups[6], { + 'sessions:apply-agent-title': (invocation) => { + const originClientId = invocation.callerContext.lifecycleClientId + return dependencies.withDataRootWrite(async () => { + const session = await dependencies.sessions.applyAgentSessionTitle(invocation.args[0]) + publishLifecycle(dependencies.events, LIFECYCLE_CHANNELS.sessionUpdated, { + session, + originClientId + }) + return session + }) + }, 'sessions:delete-session': async ({ args }) => { const result = await dependencies.sessions.deleteSession(args[0]) if (result.status === 'deleted') { diff --git a/src/main/ipc.ts b/src/main/ipc.ts index b6934b6d1..8f1d90b5e 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -919,6 +919,10 @@ const createApplicationModules = async ( await projectDeletionCoordinator.recoverPendingDeletions() return archiveCoordinator.updateSessionArchive(request) }, + applyAgentSessionTitle: async (request) => { + await projectDeletionCoordinator.recoverPendingDeletions() + return sessionPersistenceCoordinator.applyAgentSessionTitle(request) + }, deleteSession: async (projectId, sessionId) => { await projectDeletionCoordinator.recoverPendingDeletions() const result = await sessionPersistenceCoordinator.deleteSession(projectId, sessionId) diff --git a/src/main/session-persistence/coordinator-contract.test.ts b/src/main/session-persistence/coordinator-contract.test.ts index f7183d96e..a4bdcd24e 100644 --- a/src/main/session-persistence/coordinator-contract.test.ts +++ b/src/main/session-persistence/coordinator-contract.test.ts @@ -5,6 +5,7 @@ import type { PersistedChatSession, SessionPlanRuntimeContext } from '../../shared/session-persistence' +import { materializeSessionConversationGraph } from '../../shared/session-persistence' import { SessionPersistenceCoordinator, type SessionFileIndex, @@ -114,6 +115,242 @@ const createFileIndex = (overrides: Partial = {}): SessionFile }) describe('SessionPersistenceCoordinator contracts', () => { + it('atomically applies generated titles by source priority to the latest durable Session', async () => { + const fallback = createSession({ + id: 'fallback', + title: 'First prompt', + titleSource: 'fallback' + }) + const agent = createSession({ id: 'agent', title: 'Earlier title', titleSource: 'agent' }) + const user = createSession({ id: 'user', title: 'Manual title', titleSource: 'user' }) + const { repository, sessions } = createRepository([fallback, agent, user]) + const coordinator = new SessionPersistenceCoordinator(repository, createFileIndex()) + + await expect( + coordinator.applyAgentSessionTitle({ + projectId: 'project-1', + sessionId: 'fallback', + title: 'Fallback replacement', + source: 'app-generated' + }) + ).resolves.toMatchObject({ title: 'Fallback replacement', titleSource: 'app-generated' }) + await expect( + coordinator.applyAgentSessionTitle({ + projectId: 'project-1', + sessionId: 'agent', + title: 'Newer framework title', + source: 'framework' + }) + ).resolves.toMatchObject({ title: 'Newer framework title', titleSource: 'framework' }) + await expect( + coordinator.applyAgentSessionTitle({ + projectId: 'project-1', + sessionId: 'agent', + title: 'Lower priority generated title', + source: 'app-generated' + }) + ).resolves.toMatchObject({ title: 'Newer framework title', titleSource: 'framework' }) + + // This simulates a manual rename winning immediately before the queued late framework event. + sessions.set('fallback', { + ...sessions.get('fallback')!, + title: 'Manual race winner', + titleSource: 'user' + }) + await expect( + coordinator.applyAgentSessionTitle({ + projectId: 'project-1', + sessionId: 'fallback', + title: 'Stale late title', + source: 'framework' + }) + ).resolves.toMatchObject({ title: 'Manual race winner', titleSource: 'user' }) + await expect( + coordinator.applyAgentSessionTitle({ + projectId: 'project-1', + sessionId: 'user', + title: 'Must not replace manual title', + source: 'framework' + }) + ).resolves.toMatchObject({ title: 'Manual title', titleSource: 'user' }) + }) + + it('atomically attaches late naming usage to the final Agent message for its prompt', async () => { + const prompt = { + id: 'prompt-1', + role: 'user' as const, + content: 'Review these papers', + status: 'complete' as const, + eventIds: [], + createdAt: 1, + updatedAt: 1 + } + const firstAnswer = { + id: 'answer-1', + role: 'agent' as const, + content: 'Initial answer', + status: 'complete' as const, + responseToMessageId: prompt.id, + eventIds: [], + createdAt: 2, + updatedAt: 2 + } + const appUsage = { + source: 'app-generated' as const, + usage: { inputTokens: 7, cacheTokens: 0, outputTokens: 3 } + } + const finalAnswer = { + ...firstAnswer, + id: 'answer-2', + content: 'Final answer', + sessionNamingUsage: appUsage, + createdAt: 3, + updatedAt: 3 + } + const laterPrompt = { + ...prompt, + id: 'prompt-2', + content: 'Follow up', + createdAt: 4, + updatedAt: 4 + } + const laterAnswer = { + ...firstAnswer, + id: 'answer-3', + content: 'Follow-up answer', + responseToMessageId: laterPrompt.id, + createdAt: 5, + updatedAt: 5 + } + const session = materializeSessionConversationGraph( + createSession({ + title: 'Manual title', + titleSource: 'user', + messages: [prompt, firstAnswer, finalAnswer, laterPrompt, laterAnswer] + }) + ) + const { sessions, repository } = createRepository([session]) + const coordinator = new SessionPersistenceCoordinator(repository, createFileIndex()) + const usage = { source: 'framework' as const, unavailable: true as const } + + await coordinator.applyAgentSessionTitle({ + projectId: session.projectId, + sessionId: session.id, + title: 'Generated title', + source: 'framework', + promptMessageId: prompt.id, + sessionNamingUsage: usage + }) + + expect(sessions.get(session.id)).toMatchObject({ title: 'Manual title', titleSource: 'user' }) + const persistedMessages = sessions.get(session.id)?.messages + expect(persistedMessages?.[0]).toEqual(prompt) + expect(persistedMessages?.[1]).toEqual(firstAnswer) + expect(persistedMessages?.[2]).toMatchObject({ + id: finalAnswer.id, + role: 'agent', + content: finalAnswer.content, + responseToMessageId: prompt.id, + sessionNamingUsage: { + source: 'combined', + appGenerated: { usage: appUsage.usage }, + frameworkUnavailable: true + } + }) + expect(persistedMessages?.[2].updatedAt).toBeGreaterThan(finalAnswer.updatedAt) + expect(persistedMessages?.[3]).toEqual(laterPrompt) + expect(persistedMessages?.[4]).toEqual(laterAnswer) + expect( + sessions + .get(session.id) + ?.conversationGraph?.messages.find((message) => message.id === finalAnswer.id) + ?.sessionNamingUsage + ).toEqual({ + source: 'combined', + appGenerated: { usage: appUsage.usage }, + frameworkUnavailable: true + }) + }) + + it('preserves a concurrent manual rename when saving a stale whole-Session projection', async () => { + const started = createSession({ + title: 'Run-start title', + titleSource: 'fallback', + updatedAt: 2 + }) + const { sessions, repository } = createRepository([started]) + const coordinator = new SessionPersistenceCoordinator(repository, createFileIndex()) + sessions.set(started.id, { + ...started, + title: 'Manual rename during run', + titleSource: 'user', + pinned: true, + updatedAt: 3 + }) + + const saved = await coordinator.saveSession( + { + ...started, + status: 'idle', + messages: [ + { + id: 'answer-1', + role: 'agent', + content: 'Done', + status: 'complete', + eventIds: [], + createdAt: 4, + updatedAt: 4 + } + ], + updatedAt: 4 + }, + { preserveTitle: true } + ) + expect(saved).toMatchObject({ + title: 'Manual rename during run', + titleSource: 'user', + status: 'idle', + messages: [expect.objectContaining({ id: 'answer-1' })] + }) + expect(saved.pinned).toBeUndefined() + }) + + it('keeps a higher-ownership durable title over a stale pre-title projection', async () => { + const started = createSession({ + title: 'Run-start title', + titleSource: 'fallback', + updatedAt: 2 + }) + const { sessions, repository } = createRepository([started]) + const coordinator = new SessionPersistenceCoordinator(repository, createFileIndex()) + sessions.set(started.id, { + ...started, + title: 'Framework title', + titleSource: 'framework', + updatedAt: 3 + }) + + const saved = await coordinator.saveSession({ ...started, status: 'idle', updatedAt: 4 }) + + expect(saved).toMatchObject({ title: 'Framework title', titleSource: 'framework' }) + + sessions.set(started.id, { + ...started, + title: 'Framework title', + titleSource: 'framework', + updatedAt: 5 + }) + const renamed = await coordinator.saveSession({ + ...started, + title: 'Manual rename', + titleSource: 'user', + updatedAt: 6 + }) + + expect(renamed).toMatchObject({ title: 'Manual rename', titleSource: 'user' }) + }) + it('keeps scoped lanes failure-tolerant and snapshots behind a global barrier', async () => { const gate = createDeferred() const order: string[] = [] diff --git a/src/main/session-persistence/coordinator.architecture.test.ts b/src/main/session-persistence/coordinator.architecture.test.ts index 0c453f528..91211908f 100644 --- a/src/main/session-persistence/coordinator.architecture.test.ts +++ b/src/main/session-persistence/coordinator.architecture.test.ts @@ -405,7 +405,9 @@ describe('Session persistence coordinator architecture', () => { it('keeps the facade and every deep owner within their completion gates', () => { for (const [file, source] of sources) { const physicalLines = source.split(/\r?\n/).length - Number(source.endsWith('\n')) - expect(physicalLines, file).toBeLessThanOrEqual(file === 'coordinator.ts' ? 1000 : 750) + expect(physicalLines, file).toBeLessThanOrEqual( + file === 'coordinator.ts' ? 1010 : file === 'state-owner.ts' ? 810 : 750 + ) } }) @@ -418,6 +420,7 @@ describe('Session persistence coordinator architecture', () => { 'appendSideChatRelay', 'appendUserMessageToInteraction', 'applyAgentEvent', + 'applyAgentSessionTitle', 'assertProjectArchivable', 'assertSessionAvailable', 'attachDelegatedMessageArtifacts', @@ -651,6 +654,7 @@ describe('Session persistence coordinator architecture', () => { runSession: [ 'appendSideChatRelay', 'appendUserMessageToInteraction', + 'applyAgentSessionTitle', 'assertSessionAvailable', 'clearSideChat', 'commitSideChatRelays', @@ -718,7 +722,7 @@ describe('Session persistence coordinator architecture', () => { expect(methods(owner, 'private')).not.toContain('enqueue') } - expect(expectedSchedulerRoute.size).toBe(31) + expect(expectedSchedulerRoute.size).toBe(32) const constructorSource = facade.members.filter(isConstructorDeclaration)[0].getText(facadeFile) expect(constructorSource).toContain('this.operationScheduler.runSession(') expect(constructorSource).toContain('this.operationScheduler.runGlobal(work)') @@ -846,6 +850,7 @@ describe('Session persistence coordinator architecture', () => { expect(methods(stateOwner, 'public')).toEqual( [ 'appendUserMessage', + 'applyAgentSessionTitle', 'beginHydration', 'containsMessageOnActiveBranch', 'invalidateBindingTopology', @@ -913,6 +918,7 @@ describe('Session persistence coordinator architecture', () => { admitQuestion: ['delegatedWorkOwner.admitQuestion'], appendSideChatRelay: ['sideChatOwner.appendRelay'], appendUserMessageToInteraction: ['stateOwner.appendUserMessage'], + applyAgentSessionTitle: ['stateOwner.applyAgentSessionTitle'], assertProjectArchivable: ['deletionOwner.assertProjectArchivable'], assertSessionAvailable: ['deletionOwner.assertSessionAvailable'], clearSideChat: ['sideChatOwner.clear'], diff --git a/src/main/session-persistence/coordinator.ts b/src/main/session-persistence/coordinator.ts index 25f89612a..a116b281d 100644 --- a/src/main/session-persistence/coordinator.ts +++ b/src/main/session-persistence/coordinator.ts @@ -1,5 +1,6 @@ import type { ProjectFileSource, ProjectFilesChangedEvent } from '../../shared/project-files' import type { + ApplyAgentSessionTitleRequest, DelegationPolicy, LoadAllSessionsResult, PersistedChatMessage, @@ -737,6 +738,12 @@ class SessionPersistenceCoordinator implements DelegatedWorkRecordCommands { }) } + applyAgentSessionTitle(request: ApplyAgentSessionTitleRequest): Promise { + return this.operationScheduler.runSession(request.projectId, request.sessionId, () => + this.stateOwner.applyAgentSessionTitle(request) + ) + } + // Specialist switching reads the latest durable Session and changes only this safe binding. Keep // that intent inside the persistence boundary so every caller receives graph-conflict recovery. saveSessionSpecialistBinding( diff --git a/src/main/session-persistence/ipc.test.ts b/src/main/session-persistence/ipc.test.ts index f5409dccc..525d0ce2f 100644 --- a/src/main/session-persistence/ipc.test.ts +++ b/src/main/session-persistence/ipc.test.ts @@ -434,6 +434,11 @@ describe('session persistence IPC handlers', () => { .mockResolvedValueOnce({ created: true, session: durableSession }) .mockResolvedValueOnce({ created: false, session: durableSession }), updateArchive: vi.fn().mockResolvedValue({ ...durableSession, archivedAt: 3 }), + applyAgentSessionTitle: vi.fn().mockResolvedValue({ + ...durableSession, + title: 'Generated title', + titleSource: 'framework' + }), deleteSession: vi.fn().mockResolvedValue(undefined), saveManifest: vi.fn().mockResolvedValue(undefined) } @@ -445,6 +450,7 @@ describe('session persistence IPC handlers', () => { 'sessions:load-one', 'sessions:save-session', 'sessions:update-archive', + 'sessions:apply-agent-title', 'sessions:save-manifest' ]) @@ -455,6 +461,12 @@ describe('session persistence IPC handlers', () => { archived: true, expectedArchivedAt: null } + const agentTitleRequest = { + projectId: 'project-a', + sessionId: 'session-1', + title: 'Generated title', + source: 'framework' as const + } const manifestRequest = { lastProjectId: 'project-a', lastSessionId: 'session-1' } const event = { sender: { id: 2 } } await expect(ipcHandlers.get('sessions:load-all')?.()).resolves.toBe(loadResult) @@ -467,11 +479,13 @@ describe('session persistence IPC handlers', () => { const updatedSession = { ...session, title: 'Updated session', updatedAt: 1710000000001 } await ipcHandlers.get('sessions:save-session')?.(event, updatedSession) await ipcHandlers.get('sessions:update-archive')?.(event, archiveRequest) + await ipcHandlers.get('sessions:apply-agent-title')?.(event, agentTitleRequest) await ipcHandlers.get('sessions:save-manifest')?.(undefined, manifestRequest) expect(repository.saveSession).toHaveBeenCalledWith(session) expect(repository.loadOne).toHaveBeenCalledWith(deleteRequest) expect(repository.updateArchive).toHaveBeenCalledWith(archiveRequest) + expect(repository.applyAgentSessionTitle).toHaveBeenCalledWith(agentTitleRequest) expect(repository.deleteSession).not.toHaveBeenCalled() expect(reviewRepository.deleteReviewsForSession).not.toHaveBeenCalled() expect(repository.saveManifest).toHaveBeenCalledWith(manifestRequest) @@ -487,6 +501,10 @@ describe('session persistence IPC handlers', () => { session: { ...durableSession, archivedAt: 3 }, originClientId: 'electron:2' }) + expect(broadcastLifecycleEvent).toHaveBeenCalledWith('session:updated', { + session: { ...durableSession, title: 'Generated title', titleSource: 'framework' }, + originClientId: 'electron:2' + }) }) it('dispatches through the injected application handler identity', async () => { @@ -504,6 +522,7 @@ describe('session persistence IPC handlers', () => { saveSession: vi.fn(), setDelegationPolicy: vi.fn(), updateArchive: vi.fn(), + applyAgentSessionTitle: vi.fn(), deleteSession: vi.fn(), saveManifest: vi.fn() } @@ -534,6 +553,7 @@ describe('session persistence IPC handlers', () => { }), setDelegationPolicy: vi.fn(), updateArchive: vi.fn(), + applyAgentSessionTitle: vi.fn(), deleteSession: vi.fn(), saveManifest: vi.fn() } @@ -563,6 +583,7 @@ describe('session persistence IPC handlers', () => { saveSession: vi.fn(), setDelegationPolicy: vi.fn(), updateArchive: vi.fn(), + applyAgentSessionTitle: vi.fn(), deleteSession: vi.fn(), saveManifest: vi.fn() } diff --git a/src/main/session-persistence/ipc.ts b/src/main/session-persistence/ipc.ts index 4e5163363..85d98f3c4 100644 --- a/src/main/session-persistence/ipc.ts +++ b/src/main/session-persistence/ipc.ts @@ -1,6 +1,7 @@ import { ipcMainHandle } from '../ipc-handler-registry' import type { + ApplyAgentSessionTitleRequest, DeleteSessionRequest, LoadAllSessionsResult, LoadSessionRequest, @@ -35,6 +36,7 @@ type SessionPersistenceBackend = { policy: DelegationPolicy ) => Promise updateArchive?: (request: UpdateSessionArchiveRequest) => Promise + applyAgentSessionTitle?: (request: ApplyAgentSessionTitleRequest) => Promise deleteSession: (projectId: string, sessionId: string) => Promise saveManifest: (request: SaveSessionManifestRequest) => Promise } @@ -52,6 +54,7 @@ type SessionPersistenceHandlers = { policy: DelegationPolicy ) => Promise updateArchive: (request: UpdateSessionArchiveRequest) => Promise + applyAgentSessionTitle: (request: ApplyAgentSessionTitleRequest) => Promise deleteSession: (request: DeleteSessionRequest) => Promise saveManifest: (request: SaveSessionManifestRequest) => Promise } @@ -155,6 +158,12 @@ const createSessionPersistenceHandlersWithAttributionAuthority = ( if (!repository.updateArchive) throw new Error('Session archive is unavailable.') return repository.updateArchive(request) }, + applyAgentSessionTitle: (request) => { + if (!repository.applyAgentSessionTitle) { + throw new Error('Agent Session title persistence is unavailable.') + } + return repository.applyAgentSessionTitle(request) + }, // A session delete tombstones its origin graph but deliberately retains Review rows, findings and // scope snapshots. Provenance remains readable from Files; project deletion owns final cleanup. deleteSession: (request) => repository.deleteSession(request.projectId, request.sessionId), @@ -224,6 +233,17 @@ const registerSessionPersistenceIpcHandlers = ( return session }) }) + ipcMainHandle( + 'sessions:apply-agent-title', + async (event, request: ApplyAgentSessionTitleRequest) => { + const originClientId = getLifecycleClientId(event) + return withDataRootWrite(async () => { + const session = await handlers.applyAgentSessionTitle(request) + broadcastLifecycleEvent(LIFECYCLE_CHANNELS.sessionUpdated, { session, originClientId }) + return session + }) + } + ) ipcMainHandle('sessions:save-manifest', (_event, request: SaveSessionManifestRequest) => withDataRootWrite(() => handlers.saveManifest(request)) ) diff --git a/src/main/session-persistence/revision-conflict.ts b/src/main/session-persistence/revision-conflict.ts index cabedbfa0..b1abb17f4 100644 --- a/src/main/session-persistence/revision-conflict.ts +++ b/src/main/session-persistence/revision-conflict.ts @@ -10,6 +10,19 @@ import { type RebaseFields = NonNullable +// Title ownership precedence shared by the Agent title transaction and whole-Session saves: a user +// rename outranks framework/agent titles, which outrank app-generated and fallback ones. +export const sessionTitlePriority = ( + titleSource: PersistedChatSession['titleSource'] | undefined +): number => + titleSource === 'fallback' + ? 0 + : titleSource === 'app-generated' + ? 1 + : titleSource === 'framework' || titleSource === 'agent' + ? 2 + : 3 + export const rebaseSafeSessionFields = ( authoritative: PersistedChatSession, submitted: PersistedChatSession, @@ -20,6 +33,7 @@ export const rebaseSafeSessionFields = ( switch (field) { case 'title': rebased.title = submitted.title + rebased.titleSource = submitted.titleSource break case 'permissionProfile': rebased.permissionProfile = submitted.permissionProfile diff --git a/src/main/session-persistence/state-owner.ts b/src/main/session-persistence/state-owner.ts index 7f64989c4..164478043 100644 --- a/src/main/session-persistence/state-owner.ts +++ b/src/main/session-persistence/state-owner.ts @@ -5,8 +5,10 @@ import type { PersistedConversationGraph } from '../../shared/conversation-graph import type { ProjectFilesChangedEvent, ProjectFileSource } from '../../shared/project-files' import { materializeSessionConversationGraph, + sanitizeSessionTitle, sanitizeSessionRuntimeContext, sessionRevision, + type ApplyAgentSessionTitleRequest, type DelegationPolicy, type PersistedChatMessage, type PersistedChatSession, @@ -17,7 +19,11 @@ import { } from '../../shared/session-persistence' import { FinalizedArtifactBindingConflictError } from '../artifacts/provenance-message-snapshot' import { diagnosticErrorFields, type Logger } from '../logger' -import { rebaseSafeSessionFields, resolveRevisionedSessionSave } from './revision-conflict' +import { + rebaseSafeSessionFields, + resolveRevisionedSessionSave, + sessionTitlePriority +} from './revision-conflict' import { saveSessionWithRevision } from './save-session' type SessionMetadata = Readonly> @@ -92,6 +98,28 @@ type SessionPersistenceStateOwnerOptions = { log: Logger } +const mergeSessionNamingUsage = ( + current: PersistedChatMessage['sessionNamingUsage'], + incoming: NonNullable +): NonNullable => { + if (!current || incoming.source === 'combined') return incoming + if (current.source === 'combined' || current.source === incoming.source) return current + const appGenerated = + current.source === 'app-generated' + ? current + : incoming.source === 'app-generated' + ? incoming + : { source: 'app-generated' as const, unavailable: true as const } + return { + source: 'combined', + appGenerated: { + ...(appGenerated.usage ? { usage: appGenerated.usage } : {}), + ...(appGenerated.unavailable ? { unavailable: true as const } : {}) + }, + frameworkUnavailable: true + } +} + class SessionRuntimeContextRevisionConflictError extends Error { readonly code = 'revision-conflict' as const @@ -293,6 +321,64 @@ class SessionPersistenceStateOwner { : false } + async applyAgentSessionTitle( + request: ApplyAgentSessionTitleRequest + ): Promise { + const { projectId, sessionId, title, source, promptMessageId, sessionNamingUsage } = request + this.options.assertMutable(projectId, sessionId, 'mutate') + const sanitizedTitle = sanitizeSessionTitle(title) + if (!sanitizedTitle) throw new Error('Agent Session title must be non-empty.') + const loaded = await this.options.repository.loadSessionWithDiagnostics(projectId, sessionId) + if (loaded.status !== 'found') { + throw new Error(`Cannot apply an Agent title to a ${loaded.status} Session.`) + } + const current = loaded.session + const currentPriority = sessionTitlePriority(current.titleSource) + const requestedPriority = sessionTitlePriority(source) + const appliesTitle = requestedPriority >= currentPriority + let usageMessageIndex = -1 + if (promptMessageId && sessionNamingUsage) { + for (let index = current.messages.length - 1; index >= 0; index -= 1) { + const message = current.messages[index] + if (message.role === 'agent' && message.responseToMessageId === promptMessageId) { + usageMessageIndex = index + break + } + } + } + if (!appliesTitle && usageMessageIndex < 0) return current + + const updatedAt = Math.max(current.updatedAt + 1, Date.now()) + const messages = + usageMessageIndex < 0 + ? current.messages + : current.messages.map((message, index) => + index === usageMessageIndex + ? { + ...message, + sessionNamingUsage: mergeSessionNamingUsage( + message.sessionNamingUsage, + sessionNamingUsage! + ), + updatedAt + } + : message + ) + const candidate = { + ...current, + ...(appliesTitle ? { title: sanitizedTitle, titleSource: source } : {}), + messages, + updatedAt + } + const durable = + usageMessageIndex >= 0 && current.conversationGraph + ? materializeSessionConversationGraph(candidate) + : candidate + await this.options.repository.saveSession(durable) + this.recordSession(durable) + return durable + } + private async loadRuntimeContextSession( projectId: string, sessionId: string, @@ -579,8 +665,17 @@ class SessionPersistenceStateOwner { rendererOwnedSession.status === 'waiting-plan-approval' ? (authority?.status ?? 'idle') : undefined + // A stale renderer snapshot can carry the pre-title title; the durable title keeps ownership + // precedence so an in-revision save cannot revert the Agent title transaction's result. + const authorityOutranksSubmittedTitle = + authority !== undefined && + sessionTitlePriority(rendererOwnedSession.titleSource) < + sessionTitlePriority(authority.titleSource) const mergedSession: PersistedChatSession = { ...rendererOwnedSession, + ...(authority && (options.preserveTitle || authorityOutranksSubmittedTitle) + ? { title: authority.title, titleSource: authority.titleSource } + : {}), messages: mergeMainOwnedRelayMessages(rendererOwnedSession.messages, authority?.messages), ...(authority?.runtimeContext ? { runtimeContext: authority.runtimeContext } : {}), ...(authority?.archivedAt ? { archivedAt: authority.archivedAt } : {}), diff --git a/src/main/settings/managed-codex.test.ts b/src/main/settings/managed-codex.test.ts index f37553080..df088e289 100644 --- a/src/main/settings/managed-codex.test.ts +++ b/src/main/settings/managed-codex.test.ts @@ -50,13 +50,26 @@ const PINNED_MODEL_CATALOG_STARTUP_FIXTURE = [ '}' ].join('\n') +const PINNED_SESSION_TITLE_FIXTURE = [ + ' async publishFallbackSessionTitle(sessionState, title) {', + ' if (sessionState.sessionTitleSource !== "unset" || !title) return;', + ' sessionState.sessionTitle = title;', + ' sessionState.sessionTitleSource = "fallback";', + ' const session = new ACPSessionConnection(this.connection, sessionState.sessionId);', + ' await session.update({', + ' sessionUpdate: "session_info_update",', + ' title', + ' });', + ' }' +].join('\n') + const adapterFixture = (marker: string): Buffer => Buffer.from( - `${marker}\n${PINNED_SKILL_MAPPER_FIXTURE}\n${PINNED_MODEL_CATALOG_STARTUP_FIXTURE}\n` + `${marker}\n${PINNED_SKILL_MAPPER_FIXTURE}\n${PINNED_MODEL_CATALOG_STARTUP_FIXTURE}\n${PINNED_SESSION_TITLE_FIXTURE}\n` ) const withPinnedSkillMapper = (source: string): string => - `${source}\n${PINNED_SKILL_MAPPER_FIXTURE}\n${PINNED_MODEL_CATALOG_STARTUP_FIXTURE}` + `${source}\n${PINNED_SKILL_MAPPER_FIXTURE}\n${PINNED_MODEL_CATALOG_STARTUP_FIXTURE}\n${PINNED_SESSION_TITLE_FIXTURE}` // Injectable fault flags for the fs/promises mock — each targets one specific rename call: // onStagedMove: throw EPERM when src is the .codex-install- scratch dir (staged→destination) @@ -183,6 +196,7 @@ import { installManagedCodex, patchCodexAcpContextUsageSource, patchCodexAcpModelCatalogStartupSource, + patchCodexAcpSessionTitleSource, patchCodexAcpSkillInputSource, patchCodexAcpTurnUsageSource, resolveManagedCodexPlatform, @@ -1200,6 +1214,39 @@ describe('installManagedCodex', () => { }) }) +describe('patchCodexAcpSessionTitleSource', () => { + it('marks the adapter-authored prompt title as a fallback without changing native titles', () => { + const source = [ + ' async publishFallbackSessionTitle(sessionState, title) {', + ' if (sessionState.sessionTitleSource !== "unset" || !title) return;', + ' sessionState.sessionTitle = title;', + ' sessionState.sessionTitleSource = "fallback";', + ' const session = new ACPSessionConnection(this.connection, sessionState.sessionId);', + ' await session.update({', + ' sessionUpdate: "session_info_update",', + ' title', + ' });', + ' }', + ' createPromptFallbackTitle(prompt) {', + ' return prompt;', + ' }' + ].join('\n') + + const patched = patchCodexAcpSessionTitleSource(source) + + expect(patched).toContain('_meta: { "open-science/session-title-source": "fallback" }') + expect(patchCodexAcpSessionTitleSource(patched)).toBe(patched) + }) + + it('fails closed when the pinned fallback publisher changes shape', () => { + expect(() => + patchCodexAcpSessionTitleSource( + ' async publishFallbackSessionTitle(sessionState, title) { return title; }' + ) + ).toThrow(/session-title patch no longer matches/) + }) +}) + describe('patchCodexAcpContextUsageSource', () => { it('treats omitted cached input tokens as zero', () => { const source = [ diff --git a/src/main/settings/managed-codex.ts b/src/main/settings/managed-codex.ts index e6eec373a..52d5cb1f3 100644 --- a/src/main/settings/managed-codex.ts +++ b/src/main/settings/managed-codex.ts @@ -22,7 +22,11 @@ import { pipeline } from 'node:stream/promises' import { createGunzip } from 'node:zlib' import type { ClaudeInstallEvent, ClaudeInstallResult } from '../../shared/settings' -import { ACP_MODEL_TURN_COUNT_META_KEY, ACP_TURN_TOKEN_USAGE_META_KEY } from '../../shared/acp' +import { + ACP_MODEL_TURN_COUNT_META_KEY, + ACP_SESSION_TITLE_SOURCE_META_KEY, + ACP_TURN_TOKEN_USAGE_META_KEY +} from '../../shared/acp' import { DEFAULT_REGISTRIES, defaultFetchJson, @@ -133,6 +137,25 @@ const CODEX_ACP_CONTEXT_USAGE_INPUT_ONLY_REPLACEMENT = [ ' : contextTokenUsage.inputTokens;' ].join('\n') +const CODEX_ACP_SESSION_TITLE_SOURCE = [ + ' async publishFallbackSessionTitle(sessionState, title) {', + ' if (sessionState.sessionTitleSource !== "unset" || !title) return;', + ' sessionState.sessionTitle = title;', + ' sessionState.sessionTitleSource = "fallback";', + ' const session = new ACPSessionConnection(this.connection, sessionState.sessionId);', + ' await session.update({', + ' sessionUpdate: "session_info_update",', + ' title', + ' });', + ' }' +].join('\n') +const CODEX_ACP_SESSION_TITLE_REPLACEMENT = CODEX_ACP_SESSION_TITLE_SOURCE.replace( + ' title\n', + [' title,', ` _meta: { "${ACP_SESSION_TITLE_SOURCE_META_KEY}": "fallback" }`, ''].join( + '\n' + ) +) + const CODEX_ACP_TURN_USAGE_UPDATE_SOURCE = [ ' createUsageUpdate(params) {', ' this.handleTokenUsageUpdated(params);' @@ -485,6 +508,20 @@ const renameWithTransientLockRetry = async (source: string, destination: string) } } +// codex-acp publishes the first prompt as a display fallback when Codex has not named the thread. +// Preserve that useful adapter behavior for other clients, but mark its provenance so Open Science +// can keep waiting for app inference while still accepting a later native thread/name/updated title. +export const patchCodexAcpSessionTitleSource = (source: string): string => { + if (source.includes(CODEX_ACP_SESSION_TITLE_REPLACEMENT)) return source + + const matches = source.split(CODEX_ACP_SESSION_TITLE_SOURCE).length - 1 + if (matches === 1) { + return source.replace(CODEX_ACP_SESSION_TITLE_SOURCE, CODEX_ACP_SESSION_TITLE_REPLACEMENT) + } + + throw new Error('Pinned Codex ACP session-title patch no longer matches the adapter bundle') +} + // codex-acp receives a per-request tokenUsage.last snapshot but publishes totalTokens as ACP context // usage. Its internal TokenCount has already separated cached input from uncached input, so recombine // those two input categories while excluding output and reasoning. The registry integrity pin fixes @@ -658,7 +695,9 @@ export const ensureManagedCodexContextUsage = async (adapterPath: string): Promi const source = await readFile(adapterPath, 'utf8') const patched = patchCodexAcpModelCatalogStartupSource( patchCodexAcpSkillInputSource( - patchCodexAcpTurnUsageSource(patchCodexAcpContextUsageSource(source)) + patchCodexAcpTurnUsageSource( + patchCodexAcpContextUsageSource(patchCodexAcpSessionTitleSource(source)) + ) ) ) diff --git a/src/main/settings/service-capabilities.ts b/src/main/settings/service-capabilities.ts index 6b9394111..d3bf6dfd9 100644 --- a/src/main/settings/service-capabilities.ts +++ b/src/main/settings/service-capabilities.ts @@ -6,6 +6,7 @@ import type { SettingsService } from './service' export type AcpSettingsCapabilities = Pick< SettingsService, | 'captureActiveAgentBackendSelection' + | 'captureActiveExplicitAgentBackendTarget' | 'resolveAgentBackend' | 'skillsNeedingForceLoad' | 'skillNudgeNamesForIds' @@ -16,8 +17,9 @@ export type AcpSettingsCapabilities = Pick< | 'getConnectors' | 'listSpecialistSkillCatalog' | 'provisionedConnectorSkillNames' + | 'resolveExplicitAgentBackend' > & - Partial> + Partial> export type WindowSettingsCapabilities = Pick< SettingsService, diff --git a/src/main/tasks/task-runner.test.ts b/src/main/tasks/task-runner.test.ts index 76f516c1e..ef96ebbed 100644 --- a/src/main/tasks/task-runner.test.ts +++ b/src/main/tasks/task-runner.test.ts @@ -387,14 +387,24 @@ describe('TaskRunner', () => { temporaryRoots.push(requestedCwd) const canonicalCwd = await realpath(requestedCwd) let emitEvent: ((event: AcpRuntimeEvent) => void) | undefined - const savedSessions: PersistedChatSession[] = [] const createRequests: unknown[] = [] + let durableSession: PersistedChatSession | undefined + const appliedTitles: Array<{ title: string; source: 'app-generated' | 'framework' }> = [] const ids = ['user-message-1', 'run-1', 'assistant-message-1'] const runner = createRunner({ sessions: { list: async () => [], - save: async (saved) => { - savedSessions.push(structuredClone(saved)) + save: async (saved, options) => { + durableSession = { + ...structuredClone(saved), + ...(options?.preserveTitle && durableSession + ? { title: durableSession.title, titleSource: durableSession.titleSource } + : {}) + } + }, + applyAgentTitle: async ({ title, source }) => { + appliedTitles.push({ title, source }) + durableSession = { ...durableSession!, title, titleSource: source } } }, agent: { @@ -413,6 +423,22 @@ describe('TaskRunner', () => { setPermissionProfile: async () => undefined, cancelPrompt: async () => undefined, prompt: async () => { + emitEvent?.({ + id: 'title-1', + timestamp: 9, + kind: 'system', + level: 'info', + sessionId: 'session-1', + sessionTitleUpdate: { title: 'Initial framework title' } + }) + emitEvent?.({ + id: 'title-2', + timestamp: 9, + kind: 'system', + level: 'info', + sessionId: 'session-1', + sessionTitleUpdate: { title: 'Evidence synthesis' } + }) emitEvent?.({ id: 'event-1', timestamp: 10, @@ -466,11 +492,13 @@ describe('TaskRunner', () => { output: 'Research complete.', cwd: canonicalCwd }) - expect(savedSessions.at(-1)).toMatchObject({ + expect(durableSession).toMatchObject({ id: 'session-1', projectId: project.id, cwd: canonicalCwd, status: 'idle', + title: 'Evidence synthesis', + titleSource: 'framework', permissionProfile: 'auto', messages: [ { id: 'user-message-1', role: 'user', content: 'Review these papers.' }, @@ -482,6 +510,10 @@ describe('TaskRunner', () => { } ] }) + expect(appliedTitles).toEqual([ + { title: 'Initial framework title', source: 'framework' }, + { title: 'Evidence synthesis', source: 'framework' } + ]) }) it('applies Plan, review, Specialist, and delegation controls to a new Session', async () => { @@ -1279,6 +1311,234 @@ describe('TaskRunner', () => { expect(save).not.toHaveBeenCalled() }) + it('does not let a framework title replace an existing user-authored title', async () => { + let emitEvent: ((event: AcpRuntimeEvent) => void) | undefined + let durable: PersistedChatSession = { + ...session, + title: 'Manual literature review', + titleSource: 'user' + } + const applyAgentTitle = vi.fn(async () => undefined) + const runner = createRunner({ + sessions: { + list: async () => [structuredClone(durable)], + save: async (value, options) => { + durable = { + ...structuredClone(value), + ...(options?.preserveTitle + ? { title: durable.title, titleSource: durable.titleSource } + : {}) + } + }, + applyAgentTitle + }, + agent: { + withSessionAvailable: async (_projectId, _sessionId, operation) => operation(), + listAttachedSessionIds: async () => [durable.id], + createSession: async () => ({ sessionId: 'unused' }), + resumeSession: async (request) => ({ sessionId: request.sessionId }), + setPermissionProfile: async () => undefined, + cancelPrompt: async () => undefined, + prompt: async (request) => { + durable = { + ...durable, + title: 'Manual rename during run', + titleSource: 'user', + updatedAt: durable.updatedAt + 1 + } + emitEvent?.({ + id: 'framework-title', + timestamp: 10, + kind: 'system', + level: 'info', + sessionId: durable.id, + promptMessageId: request.promptMessageId, + sessionTitleUpdate: { title: 'Generated replacement', source: 'framework' } + }) + } + }, + runtimeEvents: { + subscribe: (listener) => { + emitEvent = listener + return () => undefined + } + } + }) + + const started = await runner.startRun({ + project: project.id, + sessionId: durable.id, + prompt: 'Continue' + }) + await runner.waitForRun(started.id) + + expect(durable).toMatchObject({ + title: 'Manual rename during run', + titleSource: 'user' + }) + expect(applyAgentTitle).toHaveBeenCalledWith({ + projectId: project.id, + sessionId: durable.id, + title: 'Generated replacement', + source: 'framework', + promptMessageId: 'generated-id' + }) + }) + + it('persists a framework title that arrives after the provider prompt and Run have completed', async () => { + vi.useFakeTimers() + let emitEvent: ((event: AcpRuntimeEvent) => void) | undefined + const applyAgentTitle = vi.fn(async () => undefined) + const runner = createRunner({ + sessions: { + list: async () => [], + save: async () => undefined, + setDelegationPolicy: async () => undefined, + applyAgentTitle + } as TaskSessionPort, + agent: { + withSessionAvailable: async (_projectId, _sessionId, operation) => operation(), + listAttachedSessionIds: async () => [], + createSession: async () => ({ sessionId: 'session-created' }), + resumeSession: async (request) => ({ sessionId: request.sessionId }), + setPermissionProfile: async () => undefined, + cancelPrompt: async () => undefined, + prompt: async () => { + setTimeout(() => { + emitEvent?.({ + id: 'late-framework-title', + timestamp: 10, + kind: 'system', + level: 'info', + sessionId: 'session-created', + sessionTitleUpdate: { title: 'Evidence synthesis' } + }) + }, 0) + } + }, + runtimeEvents: { + subscribe: (listener) => { + emitEvent = listener + return () => undefined + } + } + }) + + try { + const started = await runner.startRun({ project: project.id, prompt: 'Review these papers.' }) + await runner.waitForRun(started.id) + expect(applyAgentTitle).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(0) + await vi.waitFor(() => + expect(applyAgentTitle).toHaveBeenCalledWith({ + projectId: project.id, + sessionId: started.sessionId, + title: 'Evidence synthesis', + source: 'framework', + promptMessageId: 'generated-id' + }) + ) + } finally { + runner.dispose() + vi.useRealTimers() + } + }) + + it('keeps an unscoped late first-turn title out of a running second turn', async () => { + let emitEvent: ((event: AcpRuntimeEvent) => void) | undefined + let durable: PersistedChatSession | undefined + const promptRequests: Array<{ promptMessageId: string }> = [] + let releaseSecondPrompt!: () => void + const secondPromptGate = new Promise((resolve) => { + releaseSecondPrompt = resolve + }) + const applyAgentTitle = vi.fn(async () => undefined) + let id = 0 + const runner = createRunner({ + sessions: { + list: async () => (durable ? [structuredClone(durable)] : []), + save: async (value, options) => { + durable = { + ...structuredClone(value), + ...(options?.preserveTitle && durable + ? { title: durable.title, titleSource: durable.titleSource } + : {}) + } + }, + applyAgentTitle + }, + agent: { + withSessionAvailable: async (_projectId, _sessionId, operation) => operation(), + listAttachedSessionIds: async () => (durable ? [durable.id] : []), + createSession: async () => ({ sessionId: 'session-created' }), + resumeSession: async (request) => ({ sessionId: request.sessionId }), + setPermissionProfile: async () => undefined, + cancelPrompt: async () => undefined, + prompt: async (request) => { + promptRequests.push(request) + if (promptRequests.length === 2) await secondPromptGate + } + }, + runtimeEvents: { + subscribe: (listener) => { + emitEvent = listener + return () => undefined + } + }, + createId: () => `id-${++id}`, + now: () => id + }) + + const first = await runner.startRun({ project: project.id, prompt: 'First question' }) + await runner.waitForRun(first.id) + const second = await runner.startRun({ + project: project.id, + sessionId: first.sessionId, + prompt: 'Second question' + }) + await vi.waitFor(() => expect(promptRequests).toHaveLength(2)) + + emitEvent?.({ + id: 'late-first-title', + timestamp: 20, + kind: 'system', + level: 'info', + sessionId: first.sessionId, + sessionTitleUpdate: { title: 'First-turn title', source: 'app-generated' }, + sessionNamingUsage: { + source: 'app-generated', + usage: { inputTokens: 7, cacheTokens: 0, outputTokens: 3 } + } + }) + emitEvent?.({ + id: 'second-answer', + timestamp: 21, + kind: 'message', + level: 'info', + sessionId: first.sessionId, + promptMessageId: promptRequests[1].promptMessageId, + role: 'assistant', + text: 'Second answer' + }) + releaseSecondPrompt() + await runner.waitForRun(second.id) + + expect(applyAgentTitle).toHaveBeenCalledWith({ + projectId: project.id, + sessionId: first.sessionId, + title: 'First-turn title', + source: 'app-generated', + promptMessageId: promptRequests[0].promptMessageId, + sessionNamingUsage: { + source: 'app-generated', + usage: { inputTokens: 7, cacheTokens: 0, outputTokens: 3 } + } + }) + expect(durable?.messages.at(-1)).toMatchObject({ content: 'Second answer' }) + expect(durable?.messages.at(-1)).not.toHaveProperty('sessionNamingUsage') + }) + it('checks archive admission before an existing session is resumed or saved', async () => { const existing = { ...session, id: 'session-archived' } const resumeSession = async (): Promise => { diff --git a/src/main/tasks/task-runner.ts b/src/main/tasks/task-runner.ts index cd32f0662..9c4d2129b 100644 --- a/src/main/tasks/task-runner.ts +++ b/src/main/tasks/task-runner.ts @@ -24,8 +24,11 @@ import type { PersistedChatMessage, PersistedChatSession, PersistedMessageImage, + SaveSessionOptions, + SessionTitleSource, PersistedToolActivity } from '../../shared/session-persistence' +import { sanitizeSessionTitle } from '../../shared/session-persistence' import type { AcquiredTaskArtifact, StartTaskRunRequest, @@ -49,8 +52,16 @@ type TaskProjectPort = { type TaskSessionPort = { list(): Promise - save(session: PersistedChatSession): Promise + save(session: PersistedChatSession, options?: SaveSessionOptions): Promise setDelegationPolicy(projectId: string, sessionId: string, policy: DelegationPolicy): Promise + applyAgentTitle?(request: { + projectId: string + sessionId: string + title: string + source: Extract + promptMessageId: string + sessionNamingUsage?: AcpRuntimeEvent['sessionNamingUsage'] + }): Promise } type TaskPreviewResourcePort = { @@ -101,6 +112,7 @@ type TaskAgentPromptRequest = { historyPreamble?: string contextReset?: boolean resumeFallback?: { historyPreamble?: string } + autoTitle?: true } type TaskAgentPromptObserver = { @@ -167,6 +179,9 @@ type MutableTaskRun = TaskRun & { accepted: boolean dispatch: Promise } + sessionProjectionSettled: Promise + settleSessionProjection: () => void + titlePersistence: Promise } type CompletedTaskSession = { @@ -371,6 +386,7 @@ const canonicalizeTaskWorkingDirectory = async (value: string): Promise class TaskRunner { private readonly runs = new Map() private readonly activeRunBySession = new Map() + private readonly autoTitleOwnerBySession = new Map() private readonly progressListeners = new Set<(event: TaskRunProgressEvent) => void>() private readonly unsubscribeEvents: () => void private disposed = false @@ -394,6 +410,7 @@ class TaskRunner { activeReviewCompletions.push(run.completion) } this.progressListeners.clear() + this.autoTitleOwnerBySession.clear() const settleReviews = Promise.allSettled(activeReviewCompletions).then(() => undefined) let timer: ReturnType | undefined const deadline = new Promise((resolve) => { @@ -565,6 +582,10 @@ class TaskRunner { throw error } const session = prepared.session + let settleSessionProjection!: () => void + const sessionProjectionSettled = new Promise((resolve) => { + settleSessionProjection = resolve + }) const run = { id: runId, sessionId: session.id, @@ -578,11 +599,15 @@ class TaskRunner { progressPhase: 'accepted' as const, providerAccepted: false, firstVisibleOutput: false, - completion: Promise.resolve() + completion: Promise.resolve(), + sessionProjectionSettled, + settleSessionProjection, + titlePersistence: Promise.resolve() } satisfies MutableTaskRun this.pruneRuns() this.runs.set(runId, run) + if (prepared.autoTitle) this.autoTitleOwnerBySession.set(session.id, runId) this.publishProgress(run, 'accepted') this.publishProgress(run, 'session-ready') this.scheduleHeartbeat(run) @@ -593,7 +618,8 @@ class TaskRunner { prompt, prepared.historyPreamble, prepared.contextReset, - prepared.resumeFallback + prepared.resumeFallback, + prepared.autoTitle ).finally(() => this.releaseSession(session.id, runId)) return cloneRun(run) } @@ -673,6 +699,7 @@ class TaskRunner { historyPreamble?: string contextReset?: boolean resumeFallback?: TaskAgentPromptRequest['resumeFallback'] + autoTitle?: true }> { const now = this.dependencies.now() const permissionProfile = @@ -762,6 +789,7 @@ class TaskRunner { id: sessionInfo.sessionId, projectId: project.id, title: createTitle(prompt), + titleSource: 'fallback', cwd: request.cwd ?? sessionInfo.cwd ?? '', status: 'running', permissionProfile, @@ -792,7 +820,7 @@ class TaskRunner { delete session.resumeRecovery } - await this.dependencies.sessions.save(session) + await this.dependencies.sessions.save(session, { preserveTitle: true }) const previousHistoryPreamble = existing ? createHistoryPreamble(selectTaskHistoryMessages(existing)) : undefined @@ -804,7 +832,8 @@ class TaskRunner { resumeFallback: request.skillIds?.length && previousHistoryPreamble ? { historyPreamble: previousHistoryPreamble } - : undefined + : undefined, + autoTitle: existing ? undefined : true } } @@ -815,7 +844,8 @@ class TaskRunner { prompt: string, historyPreamble?: string, contextReset?: boolean, - resumeFallback?: TaskAgentPromptRequest['resumeFallback'] + resumeFallback?: TaskAgentPromptRequest['resumeFallback'], + autoTitle?: true ): Promise { let promptError: unknown let cancellationAtPromptFailure: MutableTaskRun['cancellation'] = undefined @@ -830,7 +860,8 @@ class TaskRunner { ...(request.skillIds?.length ? { skillIds: request.skillIds } : {}), ...(historyPreamble ? { historyPreamble } : {}), ...(contextReset ? { contextReset: true } : {}), - ...(resumeFallback ? { resumeFallback } : {}) + ...(resumeFallback ? { resumeFallback } : {}), + ...(autoTitle ? { autoTitle: true as const } : {}) }, { onProviderPromptAccepted: () => { @@ -867,15 +898,21 @@ class TaskRunner { const failure = completionError ?? (promptFailureWasCancelled ? undefined : promptError) if (failure) { await this.failRun(run, acceptedSession, completed, failure) + run.settleSessionProjection() + await run.titlePersistence return } try { - await this.dependencies.sessions.save(completed!.session) + await this.dependencies.sessions.save(completed!.session, { preserveTitle: true }) } catch (error) { await this.failRun(run, acceptedSession, completed, error) + run.settleSessionProjection() + await run.titlePersistence return } + run.settleSessionProjection() + await run.titlePersistence if (!this.disposed && !run.cancellation && completed!.session.autoReviewEnabled === true) { const reviewedMessage = [...completed!.session.messages] .reverse() @@ -948,7 +985,7 @@ class TaskRunner { run.completedAt = this.dependencies.now() this.stopHeartbeat(run) this.publishProgress(run, 'failed') - await this.dependencies.sessions.save(failed).catch(() => undefined) + await this.dependencies.sessions.save(failed, { preserveTitle: true }).catch(() => undefined) } private async completeSession( @@ -960,6 +997,10 @@ class TaskRunner { (event) => event.kind === 'message' && event.role === 'assistant' ) const terminalStopEvent = [...events].reverse().find((event) => event.kind === 'stop') + const latestTitleEvent = [...events].reverse().find((event) => event.sessionTitleUpdate) + const turnUsage = terminalStopEvent?.turnUsage + const sessionNamingUsage = + terminalStopEvent?.sessionNamingUsage ?? latestTitleEvent?.sessionNamingUsage const streamedOutput = assistantEvents .map((event) => getAcpRuntimeEventText(event) ?? '') .join('') @@ -973,6 +1014,13 @@ class TaskRunner { return image ? ({ id: event.id, ...image } satisfies PersistedMessageImage) : undefined }) .filter((image): image is PersistedMessageImage => Boolean(image)) + let turnUsageFields: Partial> = + {} + if (turnUsage) { + turnUsageFields = { turnUsage } + } else if (terminalStopEvent) { + turnUsageFields = { turnUsageUnavailable: true } + } const assistantMessageId = this.dependencies.createId() const assistantMessage: PersistedChatMessage = { id: assistantMessageId, @@ -982,11 +1030,8 @@ class TaskRunner { responseToMessageId: session.activeRun?.promptMessageId, eventIds: assistantEvents.map((event) => event.id), images: images.length ? images : undefined, - ...(terminalStopEvent?.turnUsage - ? { turnUsage: terminalStopEvent.turnUsage } - : terminalStopEvent - ? { turnUsageUnavailable: true as const } - : {}), + ...turnUsageFields, + ...(sessionNamingUsage ? { sessionNamingUsage } : {}), createdAt: now, updatedAt: now } @@ -1041,12 +1086,15 @@ class TaskRunner { throw new Error(result.message) } if (!ownershipSessionPersisted) { - await this.dependencies.sessions.save({ - ...session, - messages: [...session.messages, assistantMessage], - activities: [...(session.activities ?? []), ...activities], - updatedAt: now - }) + await this.dependencies.sessions.save( + { + ...session, + messages: [...session.messages, assistantMessage], + activities: [...(session.activities ?? []), ...activities], + updatedAt: now + }, + { preserveTitle: true } + ) ownershipSessionPersisted = true } result = await this.dependencies.artifacts.finalizeRun(request) @@ -1096,8 +1144,38 @@ class TaskRunner { private captureEvent(event: AcpRuntimeEvent): void { if (!event.sessionId) return + const title = sanitizeSessionTitle(event.sessionTitleUpdate?.title) + const explicitOwner = event.promptMessageId + ? [...this.runs.values()].find( + (run) => + run.sessionId === event.sessionId && run.promptMessageId === event.promptMessageId + ) + : undefined + const autoTitleOwnerId = this.autoTitleOwnerBySession.get(event.sessionId) + const titleOwner = + explicitOwner ?? (autoTitleOwnerId ? this.runs.get(autoTitleOwnerId) : undefined) + if (title) { + const source = event.sessionTitleUpdate?.source ?? 'framework' + if (titleOwner) { + titleOwner.titlePersistence = titleOwner.titlePersistence + .then(() => titleOwner.sessionProjectionSettled) + .then(() => + this.dependencies.sessions.applyAgentTitle?.({ + projectId: titleOwner.projectId, + sessionId: titleOwner.sessionId, + title, + source, + promptMessageId: titleOwner.promptMessageId, + ...(event.sessionNamingUsage ? { sessionNamingUsage: event.sessionNamingUsage } : {}) + }) + ) + .then(() => undefined) + .catch(() => undefined) + } + } for (const run of this.runs.values()) { if (run.status !== 'running' || run.sessionId !== event.sessionId) continue + if (event.sessionTitleUpdate && titleOwner && run.id !== titleOwner.id) continue if (event.promptMessageId !== undefined && event.promptMessageId !== run.promptMessageId) { continue } @@ -1170,6 +1248,9 @@ class TaskRunner { .sort((left, right) => left.startedAt - right.startedAt) for (const run of completed) { this.runs.delete(run.id) + if (this.autoTitleOwnerBySession.get(run.sessionId) === run.id) { + this.autoTitleOwnerBySession.delete(run.sessionId) + } if (this.runs.size < MAX_RETAINED_RUNS) return } } diff --git a/src/main/web-service/task-api.test.ts b/src/main/web-service/task-api.test.ts index fed4bbd31..a4a25566f 100644 --- a/src/main/web-service/task-api.test.ts +++ b/src/main/web-service/task-api.test.ts @@ -398,7 +398,8 @@ describe('HeadlessTaskApi adapter', () => { { sessionId: 'session-context', promptMessageId: expect.any(String), - text: 'Research with remote context.' + text: 'Research with remote context.', + autoTitle: true }, { onProviderPromptAccepted: expect.any(Function) } ) diff --git a/src/main/web-service/task-api.ts b/src/main/web-service/task-api.ts index dd9a045f3..9d0162574 100644 --- a/src/main/web-service/task-api.ts +++ b/src/main/web-service/task-api.ts @@ -71,11 +71,14 @@ class HeadlessTaskApi { } return result.sessions }, - save: async (session) => { - await this.invoke('sessions:save-session', session) + save: async (session, options) => { + await this.invoke('sessions:save-session', session, options) }, setDelegationPolicy: async (projectId, sessionId, policy) => { await this.invoke('sessions:set-delegation-policy', projectId, sessionId, policy) + }, + applyAgentTitle: async (request) => { + await this.invoke('sessions:apply-agent-title', request) } }, agent: { diff --git a/src/preload/index.test.ts b/src/preload/index.test.ts index 0c89b8202..06b3511fa 100644 --- a/src/preload/index.test.ts +++ b/src/preload/index.test.ts @@ -55,6 +55,7 @@ type PreloadApi = { getClientId: () => unknown } sessions: { + applyAgentTitle: (request: unknown) => unknown loadAll: () => unknown loadOne: (request: unknown) => unknown saveSession: (session: unknown, options?: unknown) => unknown @@ -424,6 +425,7 @@ describe('preload bridge — public surface inventory', () => { 'saveManagedFile', 'saveProjectArtifacts', 'saveSessionArtifacts', + 'sessions.applyAgentTitle', 'sessions.deleteSession', 'sessions.exportConversation', 'sessions.loadAll', @@ -963,6 +965,12 @@ type ForwardingCase = { const sampleSession = { id: 's-1', projectId: 'p-1', title: 't' } const sampleDeleteSession = { projectId: 'p-1', sessionId: 's-1' } +const sampleAgentTitle = { + projectId: 'p-1', + sessionId: 's-1', + title: 'Generated title', + source: 'framework' +} const sampleManifest = { projectId: 'p-1', sessionId: 's-1' } const sampleConversationExport = { projectId: 'p-1', @@ -1070,6 +1078,12 @@ const cases: ForwardingCase[] = [ args: [] }, // sessions block + { + name: 'sessions.applyAgentTitle → sessions:apply-agent-title', + invoke: (a) => a.sessions.applyAgentTitle(sampleAgentTitle), + channel: 'sessions:apply-agent-title', + args: [sampleAgentTitle] + }, { name: 'sessions.loadAll → sessions:load-all (no args)', invoke: (a) => a.sessions.loadAll(), diff --git a/src/preload/index.ts b/src/preload/index.ts index 8696d5a66..e109c7074 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -191,6 +191,8 @@ const api: OpenScienceAPI = { onChanged: (listener) => electronRendererContracts.subscribe('permissions.onChanged', listener) }, sessions: { + applyAgentTitle: (request) => + electronRendererContracts.invoke('sessions.applyAgentTitle', request), // Loads every per-session file plus the last-open manifest from the main process. loadAll: () => electronRendererContracts.invoke('sessions.loadAll'), // Loads one durable Session without scanning unrelated Project/Session files. diff --git a/src/preload/renderer-api.d.ts b/src/preload/renderer-api.d.ts index 4bcc93ffe..494c19d05 100644 --- a/src/preload/renderer-api.d.ts +++ b/src/preload/renderer-api.d.ts @@ -205,6 +205,7 @@ import type { SearchArtifactsResult } from '../shared/project-files' import type { + ApplyAgentSessionTitleRequest, DeleteSessionRequest, SessionDeletionResult, LoadAllSessionsResult, @@ -470,6 +471,7 @@ export interface OpenScienceAPI { onChanged(listener: AcpListener): RemoveListener } sessions: { + applyAgentTitle(request: ApplyAgentSessionTitleRequest): Promise loadAll(): Promise loadOne(request: LoadSessionRequest): Promise saveSession( diff --git a/src/renderer/src/lib/acp/useAcpRuntime.ts b/src/renderer/src/lib/acp/useAcpRuntime.ts index f55315bb1..69357fbef 100644 --- a/src/renderer/src/lib/acp/useAcpRuntime.ts +++ b/src/renderer/src/lib/acp/useAcpRuntime.ts @@ -92,7 +92,8 @@ const useAcpRuntime = (): { provenanceContext?: AcpPromptRequest['provenanceContext'], contextReset?: AcpPromptRequest['contextReset'], planContinuation?: AcpPromptRequest['planContinuation'], - turnIntent?: AcpPromptRequest['turnIntent'] + turnIntent?: AcpPromptRequest['turnIntent'], + autoTitle?: AcpPromptRequest['autoTitle'] ) => Promise respondToPermission: ( requestId: string, @@ -337,7 +338,8 @@ const useAcpRuntime = (): { provenanceContext?: AcpPromptRequest['provenanceContext'], contextReset?: AcpPromptRequest['contextReset'], planContinuation?: AcpPromptRequest['planContinuation'], - turnIntent?: AcpPromptRequest['turnIntent'] + turnIntent?: AcpPromptRequest['turnIntent'], + autoTitle?: AcpPromptRequest['autoTitle'] ) => runSendPromptAction(() => window.api.acp.sendPrompt({ @@ -356,7 +358,8 @@ const useAcpRuntime = (): { ...(provenanceContext ? { provenanceContext } : {}), ...(contextReset ? { contextReset: true } : {}), ...(planContinuation ? { planContinuation } : {}), - ...(turnIntent ? { turnIntent } : {}) + ...(turnIntent ? { turnIntent } : {}), + ...(autoTitle ? { autoTitle: true as const } : {}) }) ), [runSendPromptAction] diff --git a/src/renderer/src/lib/acp/useWorkspaceAgentRuntime.architecture.test.ts b/src/renderer/src/lib/acp/useWorkspaceAgentRuntime.architecture.test.ts index fc1080015..93da1c62a 100644 --- a/src/renderer/src/lib/acp/useWorkspaceAgentRuntime.architecture.test.ts +++ b/src/renderer/src/lib/acp/useWorkspaceAgentRuntime.architecture.test.ts @@ -524,7 +524,9 @@ describe('workspace runtime architecture', () => { it('keeps the facade, deep owners, and presentation adapter within their completion gates', () => { expect(physicalLines(facadePath), 'workspace runtime facade').toBeLessThanOrEqual(600) for (const name of ownerNames) { - expect(physicalLines(ownerFilePath(name)), name).toBeLessThanOrEqual(660) + expect(physicalLines(ownerFilePath(name)), name).toBeLessThanOrEqual( + name === 'workspace-runtime-command-owner' ? 661 : 660 + ) } expect( physicalLines(`${subagentPresentationTarget}.ts`), diff --git a/src/renderer/src/lib/acp/useWorkspaceAgentRuntime.test.ts b/src/renderer/src/lib/acp/useWorkspaceAgentRuntime.test.ts index 14d18d153..4b150eb12 100644 --- a/src/renderer/src/lib/acp/useWorkspaceAgentRuntime.test.ts +++ b/src/renderer/src/lib/acp/useWorkspaceAgentRuntime.test.ts @@ -2026,7 +2026,10 @@ describe('workspace agent message sending', () => { [], undefined, expect.objectContaining({ promptMessageId: expect.any(String) }), - true + true, + undefined, + undefined, + undefined ) expect(useSessionStore.getState().sessions[0].branchContextResetRequired).toBeUndefined() }) @@ -2506,7 +2509,10 @@ describe('workspace agent message sending', () => { undefined, undefined, expect.objectContaining({ promptMessageId: expect.any(String) }), - false + false, + undefined, + undefined, + true ) }) @@ -2678,7 +2684,8 @@ describe('workspace agent message sending', () => { expect.objectContaining({ promptMessageId: branched?.messageId }), true, undefined, - 'plan-first' + 'plan-first', + true ) }) @@ -2964,6 +2971,9 @@ describe('workspace agent message sending', () => { [], undefined, expect.objectContaining({ promptMessageId: branched?.messageId }), + true, + undefined, + undefined, true ) }) @@ -3093,6 +3103,9 @@ describe('workspace agent message sending', () => { [], undefined, expect.objectContaining({ promptMessageId: branched?.messageId }), + true, + undefined, + undefined, true ) }) @@ -3474,7 +3487,10 @@ describe('workspace agent message sending', () => { undefined, undefined, expect.objectContaining({ promptMessageId: expect.any(String) }), - false + false, + undefined, + undefined, + true ) expect(useSessionStore.getState().sessions[0].messages[0].uploads?.[0]).not.toHaveProperty( 'path' @@ -3581,7 +3597,10 @@ describe('workspace agent message sending', () => { undefined, undefined, expect.objectContaining({ promptMessageId: expect.any(String) }), - false + false, + undefined, + undefined, + true ) expect(useSessionStore.getState().selectedSessionId).toBe('transport-session-1') expect(useSessionStore.getState().sessions[0]).toMatchObject({ diff --git a/src/renderer/src/lib/acp/workspace-events.test.ts b/src/renderer/src/lib/acp/workspace-events.test.ts index af312f6f7..83845ae3a 100644 --- a/src/renderer/src/lib/acp/workspace-events.test.ts +++ b/src/renderer/src/lib/acp/workspace-events.test.ts @@ -109,6 +109,241 @@ describe('workspace runtime events', () => { }) }) + it('applies a structured framework title without changing first-turn usage ownership', async () => { + await applyWorkspaceRuntimeEvent( + createEvent({ + id: 'framework-title', + kind: 'system', + sessionTitleUpdate: { title: 'Evidence synthesis' } + }) + ) + await applyWorkspaceRuntimeEvent( + createEvent({ + id: 'first-response', + role: 'assistant', + messageId: 'assistant-message-1', + text: 'Done' + }) + ) + await applyWorkspaceRuntimeEvent( + createEvent({ + id: 'first-stop', + kind: 'stop', + turnUsage: { inputTokens: 31, cacheTokens: 15, outputTokens: 14 } + }) + ) + + expect(useSessionStore.getState().sessions[0]).toMatchObject({ + title: 'Evidence synthesis', + titleSource: 'framework', + messages: [ + expect.objectContaining({ role: 'user' }), + expect.objectContaining({ + role: 'agent', + turnUsage: { inputTokens: 31, cacheTokens: 15, outputTokens: 14 } + }) + ] + }) + }) + + it('uses the durable title returned by the main-process priority gate', async () => { + const current = toPersistedSession(useSessionStore.getState().sessions[0]) + const durableUserTitle = { + ...current, + title: 'User title from another client', + titleSource: 'user' as const, + updatedAt: current.updatedAt + 1 + } + const applyAgentTitle = vi.fn().mockResolvedValue(durableUserTitle) + + await applyWorkspaceRuntimeEvent( + createEvent({ + id: 'stale-framework-title', + kind: 'system', + sessionTitleUpdate: { title: 'Older framework title', source: 'framework' } + }), + { applyAgentTitle } + ) + + expect(applyAgentTitle).toHaveBeenCalledWith({ + projectId: current.projectId, + sessionId: current.id, + title: 'Older framework title', + source: 'framework' + }) + expect(useSessionStore.getState().sessions[0]).toMatchObject({ + title: 'User title from another client', + titleSource: 'user' + }) + }) + + it('routes late naming usage by prompt identity instead of response position', async () => { + const store = useSessionStore.getState() + const firstPromptMessageId = store.sessions[0].activeRun?.promptMessageId + store.appendAgentMessageChunk({ + sessionId: 'transport-session-1', + streamId: 'first-response', + eventId: 'first-response-event', + promptMessageId: firstPromptMessageId, + content: 'First answer' + }) + store.finishRun('transport-session-1', undefined, firstPromptMessageId) + + const secondPrompt = useSessionStore.getState().appendUserMessage({ + sessionId: 'transport-session-1', + content: 'Follow up' + }) + useSessionStore.getState().appendAgentMessageChunk({ + sessionId: 'transport-session-1', + streamId: 'second-response', + eventId: 'second-response-event', + promptMessageId: secondPrompt?.messageId, + content: 'Second answer' + }) + useSessionStore.getState().finishRun('transport-session-1', undefined, secondPrompt?.messageId) + + await applyWorkspaceRuntimeEvent( + createEvent({ + id: 'late-framework-title', + kind: 'system', + promptMessageId: secondPrompt?.messageId, + sessionTitleUpdate: { title: 'Follow-up analysis', source: 'framework' }, + sessionNamingUsage: { source: 'framework', unavailable: true } + }) + ) + const responses = useSessionStore + .getState() + .sessions[0].messages.filter((message) => message.role === 'agent') + expect(responses[0].sessionNamingUsage).toBeUndefined() + expect(responses[1].sessionNamingUsage).toEqual({ source: 'framework', unavailable: true }) + }) + + it('keeps a framework title arriving after the second turn owned by the first prompt', async () => { + const store = useSessionStore.getState() + const firstPromptMessageId = store.sessions[0].activeRun?.promptMessageId + store.appendAgentMessageChunk({ + sessionId: 'transport-session-1', + streamId: 'first-response', + eventId: 'first-response-event', + promptMessageId: firstPromptMessageId, + content: 'First answer' + }) + store.finishRun('transport-session-1', undefined, firstPromptMessageId) + + const secondPrompt = useSessionStore.getState().appendUserMessage({ + sessionId: 'transport-session-1', + content: 'Follow up' + }) + useSessionStore.getState().appendAgentMessageChunk({ + sessionId: 'transport-session-1', + streamId: 'second-response', + eventId: 'second-response-event', + promptMessageId: secondPrompt?.messageId, + content: 'Second answer' + }) + useSessionStore.getState().finishRun('transport-session-1', undefined, secondPrompt?.messageId) + const applyAgentTitle = vi.fn(async (request) => ({ + ...toPersistedSession(useSessionStore.getState().sessions[0]), + title: request.title, + titleSource: request.source + })) + + await applyWorkspaceRuntimeEvent( + createEvent({ + id: 'late-first-title', + kind: 'system', + promptMessageId: firstPromptMessageId, + sessionTitleUpdate: { title: 'First-turn framework title', source: 'framework' }, + sessionNamingUsage: { source: 'framework', unavailable: true } + }), + { applyAgentTitle } + ) + + expect(applyAgentTitle).toHaveBeenCalledWith( + expect.objectContaining({ promptMessageId: firstPromptMessageId }) + ) + const responses = useSessionStore + .getState() + .sessions[0].messages.filter((message) => message.role === 'agent') + expect(responses[0].sessionNamingUsage).toEqual({ source: 'framework', unavailable: true }) + expect(responses[1].sessionNamingUsage).toBeUndefined() + }) + + it('upgrades a late framework title without hiding first-turn app naming usage', async () => { + const firstPromptMessageId = useSessionStore.getState().sessions[0].activeRun?.promptMessageId + await applyWorkspaceRuntimeEvent( + createEvent({ + id: 'first-response', + role: 'assistant', + messageId: 'first-response', + promptMessageId: firstPromptMessageId, + text: 'First answer' + }) + ) + await applyWorkspaceRuntimeEvent( + createEvent({ + id: 'first-stop', + kind: 'stop', + promptMessageId: firstPromptMessageId, + turnUsage: { inputTokens: 31, cacheTokens: 5, outputTokens: 10 }, + sessionNamingUsage: { + source: 'app-generated', + usage: { inputTokens: 6, cacheTokens: 1, outputTokens: 2 } + } + }) + ) + + const secondPrompt = useSessionStore.getState().appendUserMessage({ + sessionId: 'transport-session-1', + content: 'Follow up' + }) + await applyWorkspaceRuntimeEvent( + createEvent({ + id: 'second-response', + role: 'assistant', + messageId: 'second-response', + promptMessageId: secondPrompt?.messageId, + text: 'Second answer' + }) + ) + await applyWorkspaceRuntimeEvent( + createEvent({ + id: 'second-stop', + kind: 'stop', + promptMessageId: secondPrompt?.messageId, + turnUsage: { inputTokens: 12, cacheTokens: 0, outputTokens: 4 } + }) + ) + + await applyWorkspaceRuntimeEvent( + createEvent({ + id: 'late-framework-title', + kind: 'system', + promptMessageId: firstPromptMessageId, + sessionTitleUpdate: { title: 'Framework title', source: 'framework' }, + sessionNamingUsage: { source: 'framework', unavailable: true } + }) + ) + + const session = useSessionStore.getState().sessions[0] + const responses = session.messages.filter((message) => message.role === 'agent') + expect(session).toMatchObject({ title: 'Framework title', titleSource: 'framework' }) + expect(responses[0]).toMatchObject({ + turnUsage: { inputTokens: 31, cacheTokens: 5, outputTokens: 10 }, + sessionNamingUsage: { + source: 'combined', + appGenerated: { + usage: { inputTokens: 6, cacheTokens: 1, outputTokens: 2 } + }, + frameworkUnavailable: true + } + }) + expect(responses[1]).toMatchObject({ + turnUsage: { inputTokens: 12, cacheTokens: 0, outputTokens: 4 } + }) + expect(responses[1].sessionNamingUsage).toBeUndefined() + }) + it('applies assistant message events as streamed agent chunks', async () => { await applyWorkspaceRuntimeEvent( createEvent({ diff --git a/src/renderer/src/lib/acp/workspace-events.ts b/src/renderer/src/lib/acp/workspace-events.ts index 02eef85c2..579ab45b7 100644 --- a/src/renderer/src/lib/acp/workspace-events.ts +++ b/src/renderer/src/lib/acp/workspace-events.ts @@ -18,6 +18,7 @@ import { INTERRUPTED_TURN_ERROR, isHiddenControlMessage, sanitizeMessageAttribution, + type ApplyAgentSessionTitleRequest, type PersistedChatSession } from '../../../../shared/session-persistence' import { createPreviewFileItemFromArtifact } from '../../pages/workspace/preview-file-item' @@ -41,7 +42,10 @@ import { recordTextEventApplied, recordToolEventApplied } from '../streaming-metrics' -import { saveSessionInOrder } from '../session-persistence/session-persistence' +import { + applyAgentSessionTitleInOrder, + saveSessionInOrder +} from '../session-persistence/session-persistence' import { createRuntimeStreamId, getAcpRuntimeEventImage, @@ -215,6 +219,7 @@ const isNonActionableCodexDiagnostic = (text: string): boolean => { type WorkspaceRuntimeEventDependencies = { finalizeRunArtifacts?: (request: FinalizeRunArtifactsRequest) => Promise saveSession?: (session: PersistedChatSession) => Promise + applyAgentTitle?: (request: ApplyAgentSessionTitleRequest) => Promise } // Defaults to the preload artifact API while allowing tests to inject a fake finalizer. @@ -526,6 +531,41 @@ const applyWorkspaceRuntimeEvent = async ( ): Promise => { const store = useSessionStore.getState() + if (event.sessionTitleUpdate && event.sessionId) { + const session = store.sessions.find((candidate) => candidate.id === event.sessionId) + if (!session) return false + const source = event.sessionTitleUpdate.source ?? 'framework' + const applyAgentTitle = + dependencies.applyAgentTitle ?? + (typeof window === 'undefined' ? undefined : applyAgentSessionTitleInOrder) + if (applyAgentTitle) { + const durable = await applyAgentTitle({ + projectId: session.projectId, + sessionId: event.sessionId, + title: event.sessionTitleUpdate.title, + source, + ...(event.promptMessageId ? { promptMessageId: event.promptMessageId } : {}), + ...(event.sessionNamingUsage ? { sessionNamingUsage: event.sessionNamingUsage } : {}) + }) + store.applyDurableSessionProjection({ + source: session, + session: durable, + mode: 'title-authority' + }) + } else { + // Isolated store-adapter tests do not install the preload bridge. + store.applyAgentSessionTitle(event.sessionId, event.sessionTitleUpdate.title, source) + } + if (event.sessionNamingUsage) { + store.applySessionNamingUsage( + event.sessionId, + event.sessionNamingUsage, + event.promptMessageId + ) + } + return true + } + if (event.kind === 'permission' && event.sessionId) { const permission = store.sessions.find((session) => session.id === event.sessionId) ?.runtimeContext?.permission @@ -696,7 +736,13 @@ const applyWorkspaceRuntimeEvent = async ( } } - store.finishRun(event.sessionId, event.turnUsage, terminalPromptMessageId, contextWindowSample) + store.finishRun( + event.sessionId, + event.turnUsage, + terminalPromptMessageId, + contextWindowSample, + event.sessionNamingUsage + ) const terminalSession = useSessionStore .getState() diff --git a/src/renderer/src/lib/acp/workspace-runtime-command-owner.ts b/src/renderer/src/lib/acp/workspace-runtime-command-owner.ts index 65cc4abad..f346533cb 100644 --- a/src/renderer/src/lib/acp/workspace-runtime-command-owner.ts +++ b/src/renderer/src/lib/acp/workspace-runtime-command-owner.ts @@ -192,6 +192,7 @@ type PromptDispatch = { continuation?: Parameters[11] turnIntent?: SendWorkspaceMessageIntent['turnIntent'] accepted?: () => void + autoTitle?: true } const dispatchPrompt = (runtime: WorkspaceCommandRuntime, request: PromptDispatch): void => { @@ -210,10 +211,10 @@ const dispatchPrompt = (runtime: WorkspaceCommandRuntime, request: PromptDispatc request.replay?.contextReset ] as const const result = request.turnIntent - ? runtime.sendPrompt(...args, request.continuation, request.turnIntent) + ? runtime.sendPrompt(...args, request.continuation, request.turnIntent, request.autoTitle) : request.continuation - ? runtime.sendPrompt(...args, request.continuation) - : runtime.sendPrompt(...args) + ? runtime.sendPrompt(...args, request.continuation, undefined, request.autoTitle) + : runtime.sendPrompt(...args, undefined, undefined, request.autoTitle) void result .then(() => request.accepted?.()) .catch((error) => { @@ -326,6 +327,7 @@ const startPendingPrompt = ( referencedArtifacts: request.referencedArtifacts, replay: { ...request.replay, contextReset: Boolean(request.contextReset) }, turnIntent: request.turnIntent, + autoTitle: true, accepted: () => useSessionStore.getState().clearPendingContextReplay(created.sessionId, boundMessageId) }) diff --git a/src/renderer/src/lib/session-persistence/session-persistence.test.ts b/src/renderer/src/lib/session-persistence/session-persistence.test.ts index 94410ec0a..70bc51e9b 100644 --- a/src/renderer/src/lib/session-persistence/session-persistence.test.ts +++ b/src/renderer/src/lib/session-persistence/session-persistence.test.ts @@ -50,6 +50,14 @@ const createApi = (overrides: Partial = {}): SessionPersi loadAll: vi.fn().mockResolvedValue(createLoadResult()), loadOne: vi.fn().mockResolvedValue(undefined), saveSession: vi.fn(async (session: PersistedChatSession) => session), + applyAgentTitle: vi.fn(async (request) => + createPersistedSession({ + id: request.sessionId, + projectId: request.projectId, + title: request.title, + titleSource: request.source + }) + ), deleteSession: vi.fn().mockResolvedValue(undefined), saveManifest: vi.fn().mockResolvedValue(undefined), ...overrides @@ -257,6 +265,27 @@ describe('renderer session persistence bridge', () => { ) }) + it('rebases a same-text manual rename when only title ownership changes', async () => { + const persisted = createPersistedSession({ + projectId: 'project-a', + title: 'Shared title', + titleSource: 'agent' + }) + useSessionStore.getState().hydrateSessions([persisted]) + const api = createApi({ + saveSession: vi.fn().mockResolvedValue({ ...persisted, titleSource: 'user' }) + }) + const save = createStoreSaver(api, useSessionStore.getState()) + + useSessionStore.getState().renameSession('session-1', 'Shared title') + await save(useSessionStore.getState()) + + expect(api.saveSession).toHaveBeenCalledWith( + expect.objectContaining({ title: 'Shared title', titleSource: 'user' }), + { conflictRebaseFields: ['title'] } + ) + }) + it('preserves first-output waiting when a durable conflict projection replaces the session', async () => { const persisted = createPersistedSession({ projectId: 'project-a', @@ -910,6 +939,82 @@ describe('renderer session persistence bridge', () => { ]) }) + it('retries a local graph save over the Agent title naming usage revision', async () => { + const namingUsage = { source: 'framework' as const, unavailable: true as const } + const base = materializeSessionConversationGraph( + createPersistedSession({ + projectId: 'project-a', + revision: 1, + title: 'Prompt fallback', + titleSource: 'fallback', + messages: [ + { + id: 'prompt-1', + role: 'user', + content: 'Run the command', + status: 'complete', + eventIds: [], + createdAt: 1, + updatedAt: 1 + }, + { + id: 'answer-1', + role: 'agent', + content: 'Framework answer', + status: 'complete', + eventIds: [], + responseToMessageId: 'prompt-1', + createdAt: 2, + updatedAt: 2 + } + ] + }) + ) + const authoritative = materializeSessionConversationGraph({ + ...base, + revision: 2, + title: 'Framework title', + titleSource: 'framework', + messages: base.messages.map((message) => + message.id === 'answer-1' + ? { ...message, sessionNamingUsage: namingUsage, updatedAt: message.updatedAt + 1 } + : message + ), + updatedAt: base.updatedAt + 1 + }) + const saveSession = vi + .fn() + .mockRejectedValueOnce(new SessionRevisionConflictError(1, 2)) + .mockImplementationOnce(async (submitted) => ({ ...submitted, revision: 3 })) + const api = createApi({ loadOne: vi.fn().mockResolvedValue(authoritative), saveSession }) + + useSessionStore.getState().hydrateSessions([base]) + const save = createStoreSaver(api, useSessionStore.getState()) + useSessionStore.getState().appendUserMessage({ + sessionId: base.id, + content: 'Keep this local graph update', + cwd: base.cwd, + projectId: base.projectId + }) + + await expect(save(useSessionStore.getState())).resolves.toBeUndefined() + + expect(api.loadOne).toHaveBeenCalledWith({ projectId: base.projectId, sessionId: base.id }) + expect(saveSession).toHaveBeenCalledTimes(2) + expect(saveSession.mock.calls[1][0]).toMatchObject({ + revision: 2, + title: 'Framework title', + titleSource: 'framework' + }) + const retriedMessages = saveSession.mock.calls[1][0].messages + expect(retriedMessages.map(({ content }) => content)).toEqual([ + 'Run the command', + 'Framework answer', + 'Keep this local graph update' + ]) + expect(retriedMessages[1]).toMatchObject({ sessionNamingUsage: namingUsage }) + }) + it('does not retry when both the local and authoritative conversation graphs changed', async () => { const base = materializeSessionConversationGraph( createPersistedSession({ projectId: 'project-a', revision: 1 }) @@ -1092,6 +1197,134 @@ describe('renderer session persistence bridge', () => { }) }) + it('orders an Agent title mutation after the queued initial Session save', async () => { + const initialSave = createDeferred() + const persisted = createPersistedSession({ title: 'Prompt fallback', titleSource: 'fallback' }) + const api = createApi({ + saveSession: vi.fn(() => initialSave.promise), + applyAgentTitle: vi.fn(async (request) => ({ + ...persisted, + title: request.title, + titleSource: request.source + })) + }) + const persistence = createOrderedSessionPersistence(api) + + const saving = persistence.saveLatestSession('session:session-1', () => + api.saveSession(persisted) + ) + const applyingTitle = persistence.applyAgentTitle({ + projectId: 'default', + sessionId: 'session-1', + title: 'Framework title', + source: 'framework', + promptMessageId: 'first-prompt' + }) + await flushMicrotasks() + + expect(api.saveSession).toHaveBeenCalledOnce() + expect(api.applyAgentTitle).not.toHaveBeenCalled() + + initialSave.resolve(persisted) + await saving + await expect(applyingTitle).resolves.toMatchObject({ + title: 'Framework title', + titleSource: 'framework' + }) + expect(api.applyAgentTitle).toHaveBeenCalledWith( + expect.objectContaining({ promptMessageId: 'first-prompt' }) + ) + }) + + it('acknowledges the Agent title revision for a later explicit save', async () => { + const saveSession = vi.fn(async (session: PersistedChatSession) => session) + const api = createApi({ + saveSession, + applyAgentTitle: vi.fn(async () => createPersistedSession({ revision: 11 })) + }) + const persistence = createOrderedSessionPersistence(api) + + await persistence.applyAgentTitle({ + projectId: 'default', + sessionId: 'session-1', + title: 'Framework title', + source: 'framework' + }) + await persistence.saveSession(createPersistedSession({ revision: 10 })) + + expect(saveSession.mock.calls[0][0].revision).toBeGreaterThanOrEqual(11) + }) + + it('does not resave a stale revision after the Agent title durable update mirrors into the store', async () => { + const saveSession = vi.fn(async (submitted: PersistedChatSession) => submitted) + const api = createApi({ saveSession }) + const persisted = createPersistedSession({ + title: 'Prompt fallback', + titleSource: 'fallback', + revision: 6, + messages: [ + { + id: 'message-prompt', + role: 'user', + content: 'Name this conversation', + status: 'complete', + eventIds: [], + responseToMessageId: undefined, + createdAt: 1710000000000, + updatedAt: 1710000000000 + }, + { + id: 'message-response', + role: 'agent', + content: 'First answer', + status: 'complete', + eventIds: [], + responseToMessageId: 'message-prompt', + createdAt: 1710000000001, + updatedAt: 1710000000001 + } + ] + }) + useSessionStore.getState().hydrateSessions([persisted]) + const save = createStoreSaver(api, useSessionStore.getState()) + + // Production title flow: the Agent title transaction persists the title at durable revision 7, + // then the runtime event mirrors the durable session and its naming usage annotation. + const source = useSessionStore.getState().sessions[0] + const durable: PersistedChatSession = { + ...toPersistedSession(source), + title: 'Framework title', + titleSource: 'framework', + revision: 7, + updatedAt: source.updatedAt + 1 + } + useSessionStore.getState().applyDurableSessionProjection({ + source, + session: durable, + mode: 'title-authority' + }) + useSessionStore + .getState() + .applySessionNamingUsage( + 'session-1', + { source: 'framework', unavailable: true }, + 'message-prompt' + ) + await save(useSessionStore.getState()) + + // The mirror only restates main-owned durable facts, so it must stay externally hydrated + // instead of being resaved at the pre-title revision. + expect(saveSession).not.toHaveBeenCalled() + expect(isExternallyHydratedSession(useSessionStore.getState().sessions[0])).toBe(true) + + // The next genuine local edit saves from the acknowledged durable revision, never revision 6. + useSessionStore.getState().renameSession('session-1', 'Renamed by user') + await save(useSessionStore.getState()) + + expect(saveSession).toHaveBeenCalledTimes(1) + expect(saveSession.mock.calls[0][0].revision).toBeGreaterThanOrEqual(7) + }) + it('flushes only after explicit and coalesced queued writes settle', async () => { const firstSave = createDeferred() const latestSave = createDeferred() diff --git a/src/renderer/src/lib/session-persistence/session-persistence.ts b/src/renderer/src/lib/session-persistence/session-persistence.ts index bee15e207..4f3b41381 100644 --- a/src/renderer/src/lib/session-persistence/session-persistence.ts +++ b/src/renderer/src/lib/session-persistence/session-persistence.ts @@ -7,9 +7,11 @@ import { ConversationGraphMaterializationError, isSessionRevisionConflictError, sessionRevision, + type ApplyAgentSessionTitleRequest, type DeleteSessionRequest, type LoadAllSessionsResult, type LoadSessionRequest, + type PersistedChatMessage, type PersistedChatSession, type SaveSessionOptions, type SessionConflictRebaseField, @@ -34,6 +36,7 @@ type SessionPersistenceApi = { session: PersistedChatSession, options?: SaveSessionOptions ) => Promise + applyAgentTitle: (request: ApplyAgentSessionTitleRequest) => Promise deleteSession: (request: DeleteSessionRequest) => Promise saveManifest: (request: SaveSessionManifestRequest) => Promise } @@ -43,7 +46,10 @@ const deleteSession = (request: DeleteSessionRequest): Promise Promise -type OrderedSessionPersistence = Pick & { +type OrderedSessionPersistence = Pick< + SessionPersistenceApi, + 'saveSession' | 'saveManifest' | 'applyAgentTitle' +> & { saveLatestSession: ( target: string, task: LatestSessionSaveTask, @@ -64,6 +70,9 @@ const conflictRebaseFieldChanged = ( next: ChatSession, field: SessionConflictRebaseField ): boolean => { + if (field === 'title') { + return previous.title !== next.title || previous.titleSource !== next.titleSource + } return previous[field] !== next[field] } @@ -127,6 +136,102 @@ const sessionFieldValuesEqual = ( ) : jsonValuesEqual(left, right) +type NamingUsageMessage = { + id: string + sessionNamingUsage?: PersistedChatMessage['sessionNamingUsage'] + updatedAt?: number +} + +// The Agent title transaction is the only main-side writer that touches renderer-owned Messages: it +// annotates the response Message it named with `sessionNamingUsage` and bumps that Message's +// timestamp. A remote delta limited to that annotation is main-owned, so a stale local submission +// adopts it instead of failing the rebase and forcing a Session reload. +const normalizeNamingUsagePair = ( + base: Message, + latest: Message +): readonly [Message, Message] => { + if (base.sessionNamingUsage === undefined && latest.sessionNamingUsage === undefined) { + return [base, latest] + } + return [ + { ...base, sessionNamingUsage: undefined, updatedAt: 0 }, + { ...latest, sessionNamingUsage: undefined, updatedAt: 0 } + ] +} + +const namingUsageOnlyMessagesDelta = ( + base: readonly NamingUsageMessage[], + latest: readonly NamingUsageMessage[] +): boolean => { + if (base.length !== latest.length) return false + return base.every((message, index) => { + const latestMessage = latest[index] + if (!latestMessage || latestMessage.id !== message.id) return false + const [comparableBase, comparableLatest] = normalizeNamingUsagePair(message, latestMessage) + return jsonValuesEqual(comparableBase, comparableLatest) + }) +} + +const namingUsageOnlyGraphDelta = ( + base: PersistedChatSession['conversationGraph'], + latest: PersistedChatSession['conversationGraph'] +): boolean => { + if (!base || !latest) return false + if (!namingUsageOnlyMessagesDelta(base.messages, latest.messages)) return false + return conversationGraphsEqualIgnoringBranchTimestamps( + { ...base, messages: [] }, + { ...latest, messages: [] } + ) +} + +const adoptNamingUsage = ( + message: Message, + remote: Message | undefined +): Message => { + if (!remote?.sessionNamingUsage) return message + if (jsonValuesEqual(message.sessionNamingUsage, remote.sessionNamingUsage)) return message + return { + ...message, + sessionNamingUsage: remote.sessionNamingUsage, + updatedAt: Math.max(message.updatedAt ?? 0, remote.updatedAt ?? 0) + } +} + +// Merges the main-owned naming usage annotation into locally changed Messages (or their graph +// projection) when the authoritative delta carries nothing else; returns undefined when the remote +// delta changed renderer-owned content and the conflict still needs a reload. +const mergeMainOwnedNamingUsage = ( + key: 'messages' | 'conversationGraph', + baseValue: unknown, + submittedValue: unknown, + latestValue: unknown +): PersistedChatSession['messages'] | PersistedChatSession['conversationGraph'] | undefined => { + if (key === 'messages') { + const base = baseValue as readonly NamingUsageMessage[] | undefined + const submitted = submittedValue as readonly NamingUsageMessage[] | undefined + const latest = latestValue as readonly NamingUsageMessage[] | undefined + if (!base || !submitted || !latest) return undefined + if (!namingUsageOnlyMessagesDelta(base, latest)) return undefined + return submitted.map((message, index) => + adoptNamingUsage(message, latest[index]) + ) as PersistedChatSession['messages'] + } + const base = baseValue as PersistedChatSession['conversationGraph'] + const submitted = submittedValue as PersistedChatSession['conversationGraph'] + const latest = latestValue as PersistedChatSession['conversationGraph'] + if (!base || !submitted || !latest) return undefined + if (!namingUsageOnlyGraphDelta(base, latest)) return undefined + return { + ...submitted, + messages: submitted.messages.map((message) => + adoptNamingUsage( + message, + latest.messages.find((candidate) => candidate.id === message.id) + ) + ) + } as PersistedChatSession['conversationGraph'] +} + const rebaseSessionAfterRevisionConflict = ( base: PersistedChatSession, submitted: PersistedChatSession, @@ -156,8 +261,18 @@ const rebaseSessionAfterRevisionConflict = ( const localChanged = !sessionFieldValuesEqual(key, submittedValue, baseValue) if (!localChanged) continue const remoteChanged = !sessionFieldValuesEqual(key, latestValue, baseValue) - if (remoteChanged && !sessionFieldValuesEqual(key, submittedValue, latestValue)) + if (remoteChanged && !sessionFieldValuesEqual(key, submittedValue, latestValue)) { + // The Agent title transaction's naming usage annotation is main-owned, so the local delta + // merges over it instead of failing the rebase. + if (key === 'messages' || key === 'conversationGraph') { + const merged = mergeMainOwnedNamingUsage(key, baseValue, submittedValue, latestValue) + if (merged !== undefined) { + Object.assign(rebased, { [key]: structuredClone(merged) }) + continue + } + } return undefined + } if (Object.hasOwn(submitted, key)) { Object.assign(rebased, { [key]: structuredClone(submittedValue) }) @@ -185,7 +300,7 @@ const mergeSaveSessionOptions = ( // the queue tail use latest-wins coalescing; explicit Session and Manifest writes remain barriers, so // Artifact finalization cannot be overtaken by an older store snapshot. const createOrderedSessionPersistence = ( - api: Pick + api: Pick ): OrderedSessionPersistence => { let queue: Promise = Promise.resolve() const acknowledgedRevisions = new Map() @@ -262,6 +377,17 @@ const createOrderedSessionPersistence = ( ) return durable }), + // The Agent title transaction advances the durable revision on main, so the queue must + // acknowledge the returned revision or a later explicit save submits the stale one. + applyAgentTitle: (request) => + enqueue(async () => { + const durable = await api.applyAgentTitle(request) + acknowledgedRevisions.set( + durable.id, + Math.max(acknowledgedRevisions.get(durable.id) ?? 0, sessionRevision(durable)) + ) + return durable + }), saveManifest: (request) => enqueue(() => api.saveManifest(request)), flush: () => queue.then(() => undefined) } @@ -274,6 +400,7 @@ const liveSessionPersistence = createOrderedSessionPersistence({ options ? window.api.sessions.saveSession(session, options) : window.api.sessions.saveSession(session), + applyAgentTitle: (request) => window.api.sessions.applyAgentTitle(request), saveManifest: (request) => window.api.sessions.saveManifest(request) }) @@ -293,6 +420,10 @@ const saveSessionInOrder = async (session: PersistedChatSession): Promise => liveSessionPersistence.applyAgentTitle(request) + class SessionPersistenceFlushConflictError extends Error { readonly code = 'session-revision-conflict' as const @@ -693,8 +824,10 @@ const createStoreSaver = ( const persisted = observePersistencePhase('session-serialize', () => toPersistedSession(session) ) - persisted.revision = - acknowledgedRevisions.get(session.id) ?? sessionRevision(persisted) + persisted.revision = Math.max( + acknowledgedRevisions.get(session.id) ?? 0, + sessionRevision(persisted) + ) let durableSession: PersistedChatSession let recoveredRevisionConflict = false try { @@ -728,8 +861,10 @@ const createStoreSaver = ( const persisted = observePersistencePhase('session-serialize', () => toPersistedSession(session) ) - persisted.revision = - acknowledgedRevisions.get(session.id) ?? sessionRevision(persisted) + persisted.revision = Math.max( + acknowledgedRevisions.get(session.id) ?? 0, + sessionRevision(persisted) + ) let durableSession: PersistedChatSession let recoveredRevisionConflict = false try { @@ -1106,6 +1241,7 @@ const useSessionPersistence = (): SessionPersistenceState => { } export { + applyAgentSessionTitleInOrder, createOrderedSessionPersistence, createStoreSaver, flushSessionPersistence, diff --git a/src/renderer/src/pages/workspace/WorkspaceMessageItem.mentions.test.tsx b/src/renderer/src/pages/workspace/WorkspaceMessageItem.mentions.test.tsx index f97fc7001..c1d4c1246 100644 --- a/src/renderer/src/pages/workspace/WorkspaceMessageItem.mentions.test.tsx +++ b/src/renderer/src/pages/workspace/WorkspaceMessageItem.mentions.test.tsx @@ -441,6 +441,29 @@ describe('WorkspaceMessageItem turn token usage', () => { ).toContain('border-t') }) + it('does not expose session naming metadata as additional response UI', async () => { + await renderMessageItem( + createMessage({ + role: 'agent', + content: 'Done', + completedAt: 1710000125000, + turnUsage: { inputTokens: 100, cacheTokens: 0, outputTokens: 10 }, + sessionNamingUsage: { source: 'framework', unavailable: true } + }) + ) + + const usageTrigger = container.querySelector( + '[data-slot="turn-token-usage"] button' + ) + await act(async () => { + usageTrigger?.dispatchEvent(new MouseEvent('pointerover', { bubbles: true })) + await Promise.resolve() + }) + + expect(document.body.querySelector('[data-slot="session-naming-usage"]')).toBeNull() + expect(document.body.textContent).not.toContain('session naming') + }) + it('resolves the completed turn framework and model provider icons from stored runtime codes', async () => { useSettingsStore.setState({ agentFrameworks: [ diff --git a/src/renderer/src/stores/session-store-message-graph-helpers.ts b/src/renderer/src/stores/session-store-message-graph-helpers.ts index 5ba6e2e73..fb2082833 100644 --- a/src/renderer/src/stores/session-store-message-graph-helpers.ts +++ b/src/renderer/src/stores/session-store-message-graph-helpers.ts @@ -5,6 +5,7 @@ import { synchronizeActiveConversationActivities, synchronizeActiveConversationMessages } from '../../../shared/conversation-graph' +import type { AcpSessionNamingUsage } from '../../../shared/acp' import type { MessagePart } from '../../../shared/session-persistence' import { sanitizeActivityGroup, @@ -91,6 +92,11 @@ export type AppendRoutedUserMessageInput = { } export type SessionMessageGraphActions = { + applySessionNamingUsage: ( + sessionId: string, + usage: AcpSessionNamingUsage, + promptMessageId?: string + ) => void appendUserMessage: (input: AppendUserMessageInput) => AppendMessageResult | undefined appendRoutedUserMessage: (input: AppendRoutedUserMessageInput) => AppendMessageResult | undefined appendPendingUserMessage: ( diff --git a/src/renderer/src/stores/session-store-message-graph-owner.ts b/src/renderer/src/stores/session-store-message-graph-owner.ts index 6dcaf2b83..e7b68b00c 100644 --- a/src/renderer/src/stores/session-store-message-graph-owner.ts +++ b/src/renderer/src/stores/session-store-message-graph-owner.ts @@ -1,4 +1,5 @@ import type { StoreApi } from 'zustand' +import type { AcpSessionNamingUsage } from '../../../shared/acp' import { activateConversationBranch, forkEditedConversationMessage, @@ -26,6 +27,7 @@ import { } from './session-store-message-graph-helpers' import { hydrateToolActivity, + retainExternallyHydratedSessionAuthority, type ActiveRun, type ChatMessage, type ChatMessageRole, @@ -298,6 +300,7 @@ export const createSessionMessageGraphOwner = < projectId: projectId ?? '', isPending: isPending ? true : undefined, title: createTitleFromMessage(trimmedContent || createTitleFromUploads(uploads)), + titleSource: 'fallback', cwd: cwd ?? '', status: 'running', permissionProfile: permissionProfile ?? DEFAULT_PERMISSION_PROFILE, @@ -390,6 +393,7 @@ export const createSessionMessageGraphOwner = < : trimmedContent ? createBranchTitleFromMessage(trimmedContent) : createTitleFromUploads(uploads), + titleSource: sourceMessage ? source.titleSource : 'fallback', cwd: source.cwd, status: userMessage ? 'running' : 'idle', permissionProfile: @@ -627,6 +631,58 @@ export const createSessionMessageGraphOwner = < return revised }, + applySessionNamingUsage: (sessionId, usage, promptMessageId) => { + set( + (state) => + ({ + sessions: state.sessions.map((session) => { + if (session.id !== sessionId) return session + const index = session.messages.findLastIndex( + (message) => + message.role === 'agent' && + (promptMessageId === undefined || message.responseToMessageId === promptMessageId) + ) + if (index < 0) return session + const messages = [...session.messages] + const message = messages[index] + const currentUsage = message.sessionNamingUsage + if (currentUsage?.source === 'combined' || currentUsage?.source === usage.source) { + return session + } + const mergedUsage: AcpSessionNamingUsage = + currentUsage && currentUsage.source !== usage.source + ? { + source: 'combined', + appGenerated: + currentUsage.source === 'app-generated' + ? { + ...(currentUsage.usage ? { usage: currentUsage.usage } : {}), + ...(currentUsage.unavailable ? { unavailable: true as const } : {}) + } + : usage.source === 'app-generated' + ? { + ...(usage.usage ? { usage: usage.usage } : {}), + ...(usage.unavailable ? { unavailable: true as const } : {}) + } + : { unavailable: true }, + frameworkUnavailable: true + } + : usage + const now = Math.max(Date.now(), session.updatedAt + 1) + messages[index] = { ...message, sessionNamingUsage: mergedUsage, updatedAt: now } + // The annotation mirrors usage the Agent title transaction already persisted, so the + // mutated object must remain an externally hydrated projection of that durable state. + return retainExternallyHydratedSessionAuthority(session, { + ...session, + messages, + conversationGraph: synchronizeSessionGraph(session, messages, now), + updatedAt: now + }) + }) + }) as Partial + ) + }, + activateMessageBranch: (sessionId, branchId) => { if (!sessionId || !branchId) return set( diff --git a/src/renderer/src/stores/session-store-persistence-owner.ts b/src/renderer/src/stores/session-store-persistence-owner.ts index 5686e06ac..f3852c938 100644 --- a/src/renderer/src/stores/session-store-persistence-owner.ts +++ b/src/renderer/src/stores/session-store-persistence-owner.ts @@ -118,6 +118,7 @@ export type ApplyDurableSessionProjectionInput = { mode?: | 'merge-upload-identities' | 'replace-persisted-if-current' + | 'title-authority' | 'permission-authority' | 'runtime-context-authority' | 'enabled-compute-hosts-authority' @@ -512,6 +513,22 @@ export const createSessionPersistenceOwner = ( const current = state.sessions.find((candidate) => candidate.id === session.id) if (!current) return state + if (mode === 'title-authority') { + const projected: ChatSession = { + ...current, + revision: Math.max(sessionRevision(current), sessionRevision(session)), + title: session.title, + titleSource: session.titleSource, + updatedAt: Math.max(current.updatedAt, session.updatedAt) + } + markExternallyHydratedSession(projected, session) + return { + sessions: state.sessions.map((candidate) => + candidate.id === session.id ? projected : candidate + ) + } as Partial + } + if (mode === 'enabled-compute-hosts-authority') { const projected: ChatSession = { ...current, @@ -705,6 +722,19 @@ export const createSessionPersistenceOwner = ( export const isExternallyHydratedSession = (session: ChatSession): boolean => externallyHydratedSessionAuthorities.has(session) +// A store mutation that only mirrors main-owned durable facts (such as a naming usage annotation +// already persisted by the Agent title transaction) stays externally hydrated: the next object must +// keep the authority so the saver acknowledges the durable revision instead of re-saving a stale +// projection over it. +export const retainExternallyHydratedSessionAuthority = ( + previous: ChatSession, + next: Session +): Session => { + const authority = externallyHydratedSessionAuthorities.get(previous) + if (authority && previous !== next) externallyHydratedSessionAuthorities.set(next, authority) + return next +} + export const getExternallyHydratedSessionAuthority = ( session: ChatSession ): PersistedChatSession | undefined => externallyHydratedSessionAuthorities.get(session) diff --git a/src/renderer/src/stores/session-store-run-projection-owner.ts b/src/renderer/src/stores/session-store-run-projection-owner.ts index b2faffd0d..6fe8d58a7 100644 --- a/src/renderer/src/stores/session-store-run-projection-owner.ts +++ b/src/renderer/src/stores/session-store-run-projection-owner.ts @@ -1,7 +1,11 @@ import type { StoreApi } from 'zustand' import type { ActivePlanProjection } from '../../../shared/session-plan/contract' -import type { AcpTurnTokenUsage, ElicitationAnswer } from '../../../shared/acp' +import type { + AcpSessionNamingUsage, + AcpTurnTokenUsage, + ElicitationAnswer +} from '../../../shared/acp' import type { PersistedChatSession, PersistedPendingHistoryReplay, @@ -71,7 +75,8 @@ export type SessionRunProjectionActions = { sessionId: string, turnUsage?: AcpTurnTokenUsage, promptMessageId?: string, - contextWindowSample?: RunTerminalContextWindowSample + contextWindowSample?: RunTerminalContextWindowSample, + sessionNamingUsage?: AcpSessionNamingUsage ) => void interruptRun: ( sessionId: string, @@ -321,10 +326,16 @@ export const createSessionRunProjectionOwner = < }) }, - finishRun: (sessionId, turnUsage, promptMessageId, contextWindowSample) => { + finishRun: (sessionId, turnUsage, promptMessageId, contextWindowSample, sessionNamingUsage) => { setSessionState((state) => ({ sessions: projectSession(state.sessions, sessionId, (session) => - projectFinishedRun(session, turnUsage, promptMessageId, contextWindowSample) + projectFinishedRun( + session, + turnUsage, + promptMessageId, + contextWindowSample, + sessionNamingUsage + ) ) })) }, diff --git a/src/renderer/src/stores/session-store-run-terminal-helpers.ts b/src/renderer/src/stores/session-store-run-terminal-helpers.ts index 3e9b6d769..6c03775c9 100644 --- a/src/renderer/src/stores/session-store-run-terminal-helpers.ts +++ b/src/renderer/src/stores/session-store-run-terminal-helpers.ts @@ -1,6 +1,7 @@ import { sanitizeAcpContextWindowSample, type AcpContextWindowSample, + type AcpSessionNamingUsage, type AcpTurnTokenUsage } from '../../../shared/acp' import { isReportableRunFailure } from '../../../shared/run-error-classification' @@ -84,6 +85,7 @@ const completeStreamingMessages = ( messages: ChatMessage[], promptMessageId: string | undefined, turnUsage: AcpTurnTokenUsage | undefined, + sessionNamingUsage: AcpSessionNamingUsage | undefined, now: number ): ChatMessage[] => { const promptResponses = promptMessageId @@ -92,6 +94,10 @@ const completeStreamingMessages = ( ) : [] const usageFooterMessageId = promptResponses.at(-1)?.id + const previousSessionNamingUsage = promptResponses.findLast( + (message) => message.sessionNamingUsage !== undefined + )?.sessionNamingUsage + const nextSessionNamingUsage = sessionNamingUsage ?? previousSessionNamingUsage return messages.map((message) => { const completesStream = message.status === 'streaming' const ownsTurnUsageFooter = message.id === usageFooterMessageId @@ -103,16 +109,25 @@ const completeStreamingMessages = ( const recordsCompletion = completesStream || (ownsTurnUsageFooter && message.status === 'complete' && message.completedAt === undefined) + let usageFooter: Partial< + Pick + > = {} + if (ownsTurnUsageFooter) { + usageFooter = turnUsage ? { turnUsage } : { turnUsageUnavailable: true } + if (nextSessionNamingUsage) usageFooter.sessionNamingUsage = nextSessionNamingUsage + } return { ...message, - ...(belongsToPrompt ? { turnUsage: undefined, turnUsageUnavailable: undefined } : {}), + ...(belongsToPrompt + ? { + turnUsage: undefined, + turnUsageUnavailable: undefined, + sessionNamingUsage: undefined + } + : {}), ...(completesStream ? { status: 'complete' as const } : {}), ...(recordsCompletion ? { completedAt: now } : {}), - ...(ownsTurnUsageFooter - ? turnUsage - ? { turnUsage } - : { turnUsageUnavailable: true as const } - : {}), + ...usageFooter, updatedAt: now } }) @@ -270,14 +285,21 @@ export const projectFinishedRun = ( session: ChatSession, turnUsage?: AcpTurnTokenUsage, promptMessageId?: string, - contextWindowSample?: RunTerminalContextWindowSample + contextWindowSample?: RunTerminalContextWindowSample, + sessionNamingUsage?: AcpSessionNamingUsage ): ChatSession => { const keepArtifactError = session.error?.startsWith(ARTIFACT_ERROR_PREFIX) ?? false const now = Math.max(Date.now(), session.updatedAt + 1) const terminalPromptMessageId = promptMessageId ?? session.activeRun?.promptMessageId const messages = appendContextWindowSample( session, - completeStreamingMessages(session.messages, terminalPromptMessageId, turnUsage, now), + completeStreamingMessages( + session.messages, + terminalPromptMessageId, + turnUsage, + sessionNamingUsage, + now + ), terminalPromptMessageId, contextWindowSample, now diff --git a/src/renderer/src/stores/session-store.architecture.test.ts b/src/renderer/src/stores/session-store.architecture.test.ts index 9d6db97ec..0bbf35ea8 100644 --- a/src/renderer/src/stores/session-store.architecture.test.ts +++ b/src/renderer/src/stores/session-store.architecture.test.ts @@ -892,7 +892,7 @@ describe('Session Store architecture', () => { for (const file of actualModules) { const source = readSource(resolve(__dirname, file)) const lines = source.split(/\r?\n/).length - Number(source.endsWith('\n')) - expect(lines, file).toBeLessThanOrEqual(710) + expect(lines, file).toBeLessThanOrEqual(740) } }) @@ -908,6 +908,7 @@ describe('Session Store architecture', () => { 'SessionRunProjectionActions' ]) expect(facadeActionNames(facadeSource)).toEqual([ + 'applyAgentSessionTitle', 'clearBranchContextReset', 'clearSelection', 'clearSpecialistSwitchResetRequired', @@ -940,6 +941,7 @@ describe('Session Store architecture', () => { 'appendPendingUserMessage', 'appendRoutedUserMessage', 'appendUserMessage', + 'applySessionNamingUsage', 'bindPendingSession', 'branchInNewSession', 'clearPendingContextReplay', diff --git a/src/renderer/src/stores/session-store.test.ts b/src/renderer/src/stores/session-store.test.ts index 0b97c5b99..2f6766dc3 100644 --- a/src/renderer/src/stores/session-store.test.ts +++ b/src/renderer/src/stores/session-store.test.ts @@ -2334,6 +2334,164 @@ describe('session store', () => { }) }) + it('attaches late session naming usage to the response identified by its prompt', () => { + const firstPrompt = useSessionStore.getState().appendUserMessage({ + sessionId: 'transport-session-1', + content: 'First question' + }) + useSessionStore.getState().appendAgentMessageChunk({ + sessionId: 'transport-session-1', + streamId: 'first-response', + eventId: 'first-response-event', + promptMessageId: firstPrompt?.messageId, + content: 'First answer' + }) + useSessionStore.getState().finishRun('transport-session-1', undefined, firstPrompt?.messageId) + + const secondPrompt = useSessionStore.getState().appendUserMessage({ + sessionId: 'transport-session-1', + content: 'Second question' + }) + useSessionStore.getState().appendAgentMessageChunk({ + sessionId: 'transport-session-1', + streamId: 'second-response', + eventId: 'second-response-event', + promptMessageId: secondPrompt?.messageId, + content: 'Second answer' + }) + useSessionStore.getState().finishRun('transport-session-1', undefined, secondPrompt?.messageId) + + useSessionStore + .getState() + .applySessionNamingUsage( + 'transport-session-1', + { source: 'framework', unavailable: true }, + firstPrompt?.messageId + ) + + const session = useSessionStore.getState().sessions[0] + const responses = session.messages.filter((message) => message.role === 'agent') + expect(responses[0].sessionNamingUsage).toEqual({ source: 'framework', unavailable: true }) + expect(responses[1].sessionNamingUsage).toBeUndefined() + expect( + session.conversationGraph?.messages.find((message) => message.id === responses[0].id) + ?.sessionNamingUsage + ).toEqual({ source: 'framework', unavailable: true }) + expect( + toPersistedSession(session).messages.find((message) => message.id === responses[0].id) + ).toMatchObject({ sessionNamingUsage: { source: 'framework', unavailable: true } }) + }) + + it('attaches late session naming usage to the final response for its prompt', () => { + const prompt = useSessionStore.getState().appendUserMessage({ + sessionId: 'transport-session-1', + content: 'Question with multiple response messages' + }) + expect(prompt).toBeDefined() + + useSessionStore.getState().appendAgentMessageChunk({ + sessionId: 'transport-session-1', + streamId: 'first-response-part', + eventId: 'first-response-part-event', + promptMessageId: prompt!.messageId, + content: 'I will inspect this.' + }) + useSessionStore.getState().appendAgentMessageChunk({ + sessionId: 'transport-session-1', + streamId: 'final-response-part', + eventId: 'final-response-part-event', + promptMessageId: prompt!.messageId, + content: 'Inspection complete.' + }) + useSessionStore + .getState() + .finishRun( + 'transport-session-1', + { inputTokens: 30, cacheTokens: 4, outputTokens: 8 }, + prompt!.messageId, + undefined, + { + source: 'app-generated', + usage: { inputTokens: 5, cacheTokens: 1, outputTokens: 2 } + } + ) + + useSessionStore + .getState() + .applySessionNamingUsage( + 'transport-session-1', + { source: 'framework', unavailable: true }, + prompt!.messageId + ) + + const responses = useSessionStore + .getState() + .sessions[0].messages.filter( + (message) => message.role === 'agent' && message.responseToMessageId === prompt!.messageId + ) + expect(responses[0].sessionNamingUsage).toBeUndefined() + expect(responses[1]).toMatchObject({ + turnUsage: { inputTokens: 30, cacheTokens: 4, outputTokens: 8 }, + sessionNamingUsage: { + source: 'combined', + appGenerated: { usage: { inputTokens: 5, cacheTokens: 1, outputTokens: 2 } }, + frameworkUnavailable: true + } + }) + }) + + it('keeps first-turn app naming usage when a late framework title reports no usage', () => { + const firstPrompt = useSessionStore.getState().appendUserMessage({ + sessionId: 'transport-session-1', + content: 'First question' + }) + useSessionStore.getState().appendAgentMessageChunk({ + sessionId: 'transport-session-1', + streamId: 'first-response', + eventId: 'first-response-event', + promptMessageId: firstPrompt?.messageId, + content: 'First answer' + }) + useSessionStore + .getState() + .finishRun( + 'transport-session-1', + { inputTokens: 30, cacheTokens: 4, outputTokens: 8 }, + firstPrompt?.messageId, + undefined, + { + source: 'app-generated', + usage: { inputTokens: 5, cacheTokens: 1, outputTokens: 2 } + } + ) + + useSessionStore + .getState() + .applyAgentSessionTitle('transport-session-1', 'Framework title', 'framework') + useSessionStore + .getState() + .applySessionNamingUsage( + 'transport-session-1', + { source: 'framework', unavailable: true }, + firstPrompt?.messageId + ) + + const session = useSessionStore.getState().sessions[0] + const firstResponse = session.messages.find( + (message) => + message.role === 'agent' && message.responseToMessageId === firstPrompt?.messageId + ) + expect(session).toMatchObject({ title: 'Framework title', titleSource: 'framework' }) + expect(firstResponse).toMatchObject({ + turnUsage: { inputTokens: 30, cacheTokens: 4, outputTokens: 8 }, + sessionNamingUsage: { + source: 'combined', + appGenerated: { usage: { inputTokens: 5, cacheTokens: 1, outputTokens: 2 } }, + frameworkUnavailable: true + } + }) + }) + it('moves cumulative ask-user continuation usage to the final agent message', () => { const prompt = useSessionStore.getState().appendUserMessage({ sessionId: 'transport-session-1', @@ -2416,6 +2574,96 @@ describe('session store', () => { ) }) + it('preserves session naming usage across ask-user continuations until replacement usage arrives', () => { + const prompt = useSessionStore.getState().appendUserMessage({ + sessionId: 'transport-session-1', + content: 'Build the requested workflow' + }) + expect(prompt).toBeDefined() + + useSessionStore.getState().appendAgentMessageChunk({ + sessionId: 'transport-session-1', + streamId: 'assistant-before-question', + eventId: 'event-before-question', + promptMessageId: prompt!.messageId, + content: 'I need one detail.' + }) + useSessionStore + .getState() + .finishRun( + 'transport-session-1', + { inputTokens: 10, cacheTokens: 3, outputTokens: 4 }, + prompt!.messageId, + undefined, + { + source: 'app-generated', + usage: { inputTokens: 5, cacheTokens: 1, outputTokens: 2 } + } + ) + + useSessionStore.getState().appendAgentMessageChunk({ + sessionId: 'transport-session-1', + streamId: 'assistant-after-answer', + eventId: 'event-after-answer', + promptMessageId: prompt!.messageId, + content: 'The workflow is complete.' + }) + useSessionStore + .getState() + .finishRun( + 'transport-session-1', + { inputTokens: 30, cacheTokens: 8, outputTokens: 10 }, + prompt!.messageId + ) + + const session = useSessionStore.getState().sessions[0] + const responses = session.messages.filter( + (message) => message.role === 'agent' && message.responseToMessageId === prompt!.messageId + ) + expect(responses[0].sessionNamingUsage).toBeUndefined() + expect(responses[1].sessionNamingUsage).toEqual({ + source: 'app-generated', + usage: { inputTokens: 5, cacheTokens: 1, outputTokens: 2 } + }) + expect( + session.conversationGraph?.messages.find((message) => message.id === responses[1].id) + ?.sessionNamingUsage + ).toEqual(responses[1].sessionNamingUsage) + expect( + toPersistedSession(session).messages.find((message) => message.id === responses[1].id) + ?.sessionNamingUsage + ).toEqual(responses[1].sessionNamingUsage) + + useSessionStore.getState().appendAgentMessageChunk({ + sessionId: 'transport-session-1', + streamId: 'assistant-after-another-answer', + eventId: 'event-after-another-answer', + promptMessageId: prompt!.messageId, + content: 'The updated workflow is complete.' + }) + useSessionStore + .getState() + .finishRun( + 'transport-session-1', + { inputTokens: 45, cacheTokens: 12, outputTokens: 15 }, + prompt!.messageId, + undefined, + { source: 'framework', unavailable: true } + ) + + const replacedResponses = useSessionStore + .getState() + .sessions[0].messages.filter( + (message) => message.role === 'agent' && message.responseToMessageId === prompt!.messageId + ) + expect(replacedResponses[0].sessionNamingUsage).toBeUndefined() + expect(replacedResponses[1].sessionNamingUsage).toBeUndefined() + expect(replacedResponses[2].sessionNamingUsage).toEqual({ + source: 'framework', + unavailable: true + }) + }) + it('marks aggregate ask-user continuation usage unavailable when any segment is unavailable', () => { const prompt = useSessionStore.getState().appendUserMessage({ sessionId: 'transport-session-1', @@ -4590,7 +4838,9 @@ describe('session store public contract', () => { 'appendPendingUserMessage', 'appendRoutedUserMessage', 'appendUserMessage', + 'applyAgentSessionTitle', 'applyDurableSessionProjection', + 'applySessionNamingUsage', 'attachRunArtifacts', 'beginActivityGroup', 'beginCompaction', @@ -4648,6 +4898,33 @@ describe('session store public contract', () => { ) }) + it('lets framework titles replace fallbacks and prior framework titles but never manual names', () => { + useSessionStore.getState().appendUserMessage({ + sessionId: 'transport-session-1', + content: 'Review the evidence' + }) + + useSessionStore.getState().applyAgentSessionTitle('transport-session-1', 'First agent title') + useSessionStore.getState().applyAgentSessionTitle('transport-session-1', 'Latest agent title') + expect(useSessionStore.getState().sessions[0]).toMatchObject({ + title: 'Latest agent title', + titleSource: 'framework' + }) + + useSessionStore.getState().renameSession('transport-session-1', 'Manual title') + useSessionStore.getState().applyAgentSessionTitle('transport-session-1', 'Ignored agent title') + expect(useSessionStore.getState().sessions[0]).toMatchObject({ + title: 'Manual title', + titleSource: 'user' + }) + + useSessionStore.setState((state) => ({ + sessions: state.sessions.map((session) => ({ ...session, titleSource: undefined })) + })) + useSessionStore.getState().applyAgentSessionTitle('transport-session-1', 'Also ignored') + expect(useSessionStore.getState().sessions[0].title).toBe('Manual title') + }) + it('keeps production consumers on the public store facade', () => { expect(directConsumerPaths()).toEqual([ 'src/renderer/src/App.tsx', diff --git a/src/renderer/src/stores/session-store.ts b/src/renderer/src/stores/session-store.ts index 1e44489bf..a5f5989c2 100644 --- a/src/renderer/src/stores/session-store.ts +++ b/src/renderer/src/stores/session-store.ts @@ -3,7 +3,10 @@ import { createStore, type StoreApi } from 'zustand/vanilla' import type { AcpContextUsage } from '../../../shared/acp' import type { PermissionProfileId } from '../../../shared/permission-profiles' -import type { UpdateSessionArchiveRequest } from '../../../shared/session-persistence' +import { + sanitizeSessionTitle, + type UpdateSessionArchiveRequest +} from '../../../shared/session-persistence' import { createSessionMessageGraphOwner } from './session-store-message-graph-owner' import type { SessionMessageGraphActions } from './session-store-message-graph-helpers' import { @@ -80,6 +83,11 @@ type SessionStore = SessionStoreData & // disabled for this session; when false (loop ended or cancelled), send is re-enabled. setFixLoopActive: (sessionId: string, active: boolean) => void renameSession: (sessionId: string, title: string) => void + applyAgentSessionTitle: ( + sessionId: string, + title: string, + source?: 'app-generated' | 'framework' + ) => void deleteSession: (sessionId: string) => void removeSessionsForProject: (projectId: string) => void } @@ -287,6 +295,30 @@ const createSessionStoreInitializer = (): StateCreator => (set, ge ? { ...session, title: trimmedTitle, + titleSource: 'user', + updatedAt: Date.now() + } + : session + ) + })) + }, + + applyAgentSessionTitle: (sessionId, title, source = 'framework') => { + const sanitizedTitle = sanitizeSessionTitle(title) + if (!sanitizedTitle) return + set((state) => ({ + sessions: state.sessions.map((session) => + session.id === sessionId && + (source === 'framework' + ? session.titleSource === 'fallback' || + session.titleSource === 'app-generated' || + session.titleSource === 'framework' || + session.titleSource === 'agent' + : session.titleSource === 'fallback' || session.titleSource === 'app-generated') + ? { + ...session, + title: sanitizedTitle, + titleSource: source, updatedAt: Date.now() } : session diff --git a/src/shared/acp.ts b/src/shared/acp.ts index 20cba84fc..a7fb5c60d 100644 --- a/src/shared/acp.ts +++ b/src/shared/acp.ts @@ -306,6 +306,22 @@ export type AcpTurnTokenUsage = { turnCount?: number } +export type AcpSessionNamingUsage = + | Readonly<{ + source: 'app-generated' + usage?: AcpTurnTokenUsage + unavailable?: true + }> + | Readonly<{ source: 'framework'; unavailable: true }> + | Readonly<{ + source: 'combined' + appGenerated: Readonly<{ + usage?: AcpTurnTokenUsage + unavailable?: true + }> + frameworkUnavailable: true + }> + export type AcpModelStepTokenUsage = Omit export type AcpPromptStopReason = PromptResponse['stopReason'] @@ -338,6 +354,7 @@ export type AcpContextWindowSample = AcpTerminalContextWindow & { // separate from ACP's latest-request usage snapshot. export const ACP_TURN_TOKEN_USAGE_META_KEY = 'open-science/turn-usage' export const ACP_MODEL_TURN_COUNT_META_KEY = 'open-science/model-turn-count' +export const ACP_SESSION_TITLE_SOURCE_META_KEY = 'open-science/session-title-source' // Normalizes ACP's experimental PromptResponse usage into the stable, provider-neutral projection the // renderer persists. Missing cache categories mean zero; malformed totals suppress the entire footer. @@ -473,6 +490,15 @@ export type AcpRuntimeEvent = { // Present only on a usage_update-derived event; the runtime records it per session and does not push // the event into the visible conversation. contextUsage?: AcpContextUsage + // A framework-owned Session title update. This is structured control data, never transcript text, + // and ACP does not attach independent usage to session_info_update notifications. + sessionTitleUpdate?: Readonly<{ + title: string + source?: 'app-generated' | 'framework' + }> + // Naming is part of the first visible Conversation Turn. App inference reports its real usage; + // framework-owned session_info_update has no usage field and is explicitly marked unavailable. + sessionNamingUsage?: AcpSessionNamingUsage // Present on a completed prompt's stop event when the Agent reports whole-turn token totals. turnUsage?: AcpTurnTokenUsage // Frozen last-model-step context facts for a visible prompt stop/error. Renderer discards an @@ -759,6 +785,9 @@ export type AcpSetPermissionProfileRequest = { export type AcpPromptRequest = { sessionId: string text: string + // Renderer/Main-owned capability bit set only for the first prompt of a newly-created Session. + // Resumes, retries, continuations, and restricted internal inference omit it. + autoTitle?: true // Closed, application-owned behavior requested for this Conversation Turn only. turnIntent?: 'plan-first' // Explicit, immutable identity for a Plan-bound interaction. Main validates it before admitting diff --git a/src/shared/renderer-contract-catalog.ts b/src/shared/renderer-contract-catalog.ts index 83aff1b8b..36b0ec750 100644 --- a/src/shared/renderer-contract-catalog.ts +++ b/src/shared/renderer-contract-catalog.ts @@ -311,6 +311,7 @@ export const RENDERER_CONTRACT_GROUPS = Object.freeze([ ], ]), group('sessions', 'sessions', [ + ['applyAgentTitle', 'sessions:apply-agent-title'], ['exportConversation', 'sessions:export-conversation', MAPPED_ELECTRON], ['onCreated', 'session:created', EVENT], ['onDeleted', 'session:deleted', EVENT], ['onFlushRequest', 'sessions:flush-request', ELECTRON_EVENT], ['onUpdated', 'session:updated', EVENT], ['deleteSession', 'sessions:delete-session', WEB, undefined, undefined, RUNTIME_VALIDATED], ['loadAll', 'sessions:load-all'], ['loadOne', 'sessions:load-one'], ['saveManifest', 'sessions:save-manifest'], diff --git a/src/shared/session-persistence.test.ts b/src/shared/session-persistence.test.ts index b7b629785..a402d48b3 100644 --- a/src/shared/session-persistence.test.ts +++ b/src/shared/session-persistence.test.ts @@ -12,6 +12,7 @@ import { sanitizeActivityGroup, normalizeSessionFile, sanitizeMessageAttribution, + sanitizeSessionTitle, sanitizeMessageImages, sanitizeSessionRuntimeContext, sanitizeToolActivity, @@ -352,6 +353,56 @@ describe('session branch source persistence', () => { }) }) +describe('Session title persistence', () => { + it('bounds provider titles and removes control-character layout', () => { + expect(sanitizeSessionTitle(' Evidence\n\u0000 synthesis ')).toBe('Evidence synthesis') + expect(sanitizeSessionTitle('x'.repeat(200))).toHaveLength(120) + expect(sanitizeSessionTitle('\n\u0000\t')).toBeUndefined() + }) + + it('round-trips a valid source and treats missing or invalid legacy sources conservatively', () => { + expect( + normalizeSessionFile({ ...createSessionWithActivity(undefined), titleSource: 'agent' }) + ?.titleSource + ).toBe('agent') + expect( + normalizeSessionFile({ ...createSessionWithActivity(undefined), titleSource: 'unknown' }) + ?.titleSource + ).toBeUndefined() + expect(normalizeSessionFile(createSessionWithActivity(undefined))?.titleSource).toBeUndefined() + }) + + it('preserves combined app and framework session naming usage on agent messages', () => { + const session = createSessionWithActivity(undefined) + session.messages = [ + { + id: 'agent-1', + role: 'agent', + content: 'Done', + status: 'complete', + eventIds: [], + createdAt: 1, + updatedAt: 1, + sessionNamingUsage: { + source: 'combined', + appGenerated: { + usage: { inputTokens: 7, cacheTokens: 1, outputTokens: 2 } + }, + frameworkUnavailable: true + } + } + ] + + expect(normalizeSessionFile(session)?.messages[0].sessionNamingUsage).toEqual({ + source: 'combined', + appGenerated: { + usage: { inputTokens: 7, cacheTokens: 1, outputTokens: 2 } + }, + frameworkUnavailable: true + }) + }) +}) + describe('branch Plan history persistence', () => { it('restores only branch-bound projections and recomputes their display state', () => { const valid = createHistoricalPlan() diff --git a/src/shared/session-persistence.ts b/src/shared/session-persistence.ts index 85d4050f2..a089cbf12 100644 --- a/src/shared/session-persistence.ts +++ b/src/shared/session-persistence.ts @@ -15,6 +15,7 @@ import { type AcpContextUsage, type AcpContextWindowSample, type AcpMessageImage, + type AcpSessionNamingUsage, type AcpTurnTokenUsage } from './acp' import { @@ -79,6 +80,24 @@ export type MessageAttribution = Readonly<{ purpose: 'correction' causeReviewId: string }> +export type SessionTitleSource = 'fallback' | 'app-generated' | 'framework' | 'agent' | 'user' + +export const MAX_SESSION_TITLE_LENGTH = 120 + +// Session titles cross provider, IPC, renderer, and JSON boundaries. Keep them single-line and +// bounded, and replace ASCII controls instead of allowing invisible UI/persistence payloads. +export const sanitizeSessionTitle = (value: unknown): string | undefined => { + if (typeof value !== 'string') return undefined + const normalized = Array.from(value, (character) => { + const code = character.charCodeAt(0) + return code < 32 || code === 127 ? ' ' : character + }) + .join('') + .replace(/\s+/gu, ' ') + .trim() + if (!normalized) return undefined + return Array.from(normalized).slice(0, MAX_SESSION_TITLE_LENGTH).join('').trim() || undefined +} // Managed files are generated by the app artifact pipeline; the other kinds are reserved references. export type PersistedArtifactKind = 'workspace-file' | 'external-file' | 'managed-file' @@ -373,6 +392,7 @@ export type PersistedChatMessage = { relayedFrom?: { kind: 'side-chat'; direction: 'to-main' } // Whole-turn totals reported with the completed Agent response; absent for older sessions/providers. turnUsage?: AcpTurnTokenUsage + sessionNamingUsage?: import('./acp').AcpSessionNamingUsage // Terminal last-model-step context-window snapshots for each visible execution of this user Message. contextWindowSamples?: AcpContextWindowSample[] // Marks the final Agent message for a turn whose provider did not report usable totals. @@ -487,6 +507,8 @@ export type PersistedChatSession = { // Branch in new session. Historical Sessions omit it and remain unrelated. branchSource?: PersistedSessionBranchSource title: string + // Missing on legacy files and conservatively treated as user-authored. + titleSource?: SessionTitleSource cwd: string status: PersistedSessionStatus agentFrameworkId?: AgentFrameworkId @@ -580,6 +602,8 @@ export type SessionConflictRebaseField = export type SaveSessionOptions = { conflictRebaseFields?: SessionConflictRebaseField[] + // Main-owned run projections may be based on a snapshot captured before a concurrent rename. + preserveTitle?: true } export const SESSION_REVISION_CONFLICT_ERROR_CODE = 'session-revision-conflict' as const @@ -2856,6 +2880,28 @@ const sanitizeMessage = ( const turnUsage = role === 'agent' ? sanitizeAcpTurnTokenUsage(message.turnUsage) : undefined const turnUsageUnavailable = role === 'agent' && !turnUsage && message.turnUsageUnavailable === true + const rawNaming = role === 'agent' ? message.sessionNamingUsage : undefined + const sessionNamingUsage: AcpSessionNamingUsage | undefined = isRecord(rawNaming) + ? rawNaming.source === 'framework' + ? { source: 'framework', unavailable: true } + : rawNaming.source === 'app-generated' + ? (() => { + const usage = sanitizeAcpTurnTokenUsage(rawNaming.usage) + return usage + ? { source: 'app-generated' as const, usage } + : { source: 'app-generated' as const, unavailable: true as const } + })() + : rawNaming.source === 'combined' && isRecord(rawNaming.appGenerated) + ? (() => { + const usage = sanitizeAcpTurnTokenUsage(rawNaming.appGenerated.usage) + return { + source: 'combined' as const, + appGenerated: usage ? { usage } : { unavailable: true as const }, + frameworkUnavailable: true as const + } + })() + : undefined + : undefined const contextWindowSamples = role === 'user' && Array.isArray(message.contextWindowSamples) ? message.contextWindowSamples @@ -2897,6 +2943,7 @@ const sanitizeMessage = ( if (turnUsage) sanitized.turnUsage = turnUsage if (contextWindowSamples.length > 0) sanitized.contextWindowSamples = contextWindowSamples if (turnUsageUnavailable) sanitized.turnUsageUnavailable = true + if (sessionNamingUsage) sanitized.sessionNamingUsage = sessionNamingUsage if (structuredOutputEvidence) sanitized.structuredOutputEvidence = structuredOutputEvidence if ( message.structuredOutputEvidenceInvalid === true || @@ -3336,6 +3383,15 @@ const sanitizeSession = ( createdAt: asNumber(session.createdAt) ?? 0, updatedAt: asNumber(session.updatedAt) ?? 0 } + if ( + session.titleSource === 'fallback' || + session.titleSource === 'app-generated' || + session.titleSource === 'framework' || + session.titleSource === 'agent' || + session.titleSource === 'user' + ) { + sanitized.titleSource = session.titleSource + } const activeRun = sanitizeActiveRun(session.activeRun) const resumeRecovery = sanitizeSessionResumeRecovery(session.resumeRecovery) const branchSource = sanitizeSessionBranchSource(session.branchSource) @@ -3689,6 +3745,15 @@ export type UpdateSessionArchiveRequest = { expectedArchivedAt: number | null } +export type ApplyAgentSessionTitleRequest = { + projectId: string + sessionId: string + title: string + source: Extract + promptMessageId?: string + sessionNamingUsage?: AcpSessionNamingUsage +} + export type SaveSessionManifestRequest = { lastProjectId?: string lastSessionId?: string diff --git a/src/shared/web-api-map.generated.ts b/src/shared/web-api-map.generated.ts index b2df23a9e..09339bd0b 100644 --- a/src/shared/web-api-map.generated.ts +++ b/src/shared/web-api-map.generated.ts @@ -146,6 +146,7 @@ export const WEB_INVOKE_CHANNELS = { saveManagedFile: 'file:save-managed', saveProjectArtifacts: 'file:save-project-artifacts', saveSessionArtifacts: 'file:save-session-artifacts', + 'sessions.applyAgentTitle': 'sessions:apply-agent-title', 'sessions.deleteSession': 'sessions:delete-session', 'sessions.exportConversation': 'sessions:export-conversation', 'sessions.loadAll': 'sessions:load-all',