From f27344a5220c6d3e92d84eca8a60984a984653ec Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 01:58:26 -0700 Subject: [PATCH 01/62] Add standalone ACP protocol client and session runtime --- config/scripts/acp/generate-protocol.mjs | 298 +++++++++++++ src/main/acp/acp-errors.ts | 31 ++ src/main/acp/acp-incoming-requests.ts | 91 ++++ src/main/acp/acp-json-rpc-peer.test.ts | 253 +++++++++++ src/main/acp/acp-json-rpc-peer.ts | 258 +++++++++++ src/main/acp/acp-permission-requests.ts | 88 ++++ .../acp/acp-scripted-agent.test-support.ts | 85 ++++ src/main/acp/acp-session-runtime.test.ts | 413 ++++++++++++++++++ src/main/acp/acp-session-runtime.ts | 303 +++++++++++++ src/main/acp/acp-session-setup.ts | 75 ++++ src/main/acp/acp-write-queue.ts | 119 +++++ .../acp/generated/client-capabilities.gen.ts | 113 +++++ src/main/acp/generated/diff.gen.ts | 126 ++++++ src/main/acp/generated/license.gen.ts | 197 +++++++++ src/main/acp/generated/mcp-server-acp.gen.ts | 116 +++++ .../generated/nes-context-capabilities.gen.ts | 124 ++++++ src/main/acp/generated/plan-update.gen.ts | 103 +++++ .../acp/generated/protocol-version.gen.ts | 93 ++++ src/main/acp/generated/protocol.gen.ts | 14 + .../generated/providers-capabilities.gen.ts | 87 ++++ .../generated/session-config-option.gen.ts | 113 +++++ src/main/acp/generated/session-update.gen.ts | 132 ++++++ .../set-session-model-request.gen.ts | 121 +++++ .../generated/text-resource-contents.gen.ts | 129 ++++++ .../wait-for-terminal-exit-request.gen.ts | 30 ++ 25 files changed, 3512 insertions(+) create mode 100644 config/scripts/acp/generate-protocol.mjs create mode 100644 src/main/acp/acp-errors.ts create mode 100644 src/main/acp/acp-incoming-requests.ts create mode 100644 src/main/acp/acp-json-rpc-peer.test.ts create mode 100644 src/main/acp/acp-json-rpc-peer.ts create mode 100644 src/main/acp/acp-permission-requests.ts create mode 100644 src/main/acp/acp-scripted-agent.test-support.ts create mode 100644 src/main/acp/acp-session-runtime.test.ts create mode 100644 src/main/acp/acp-session-runtime.ts create mode 100644 src/main/acp/acp-session-setup.ts create mode 100644 src/main/acp/acp-write-queue.ts create mode 100644 src/main/acp/generated/client-capabilities.gen.ts create mode 100644 src/main/acp/generated/diff.gen.ts create mode 100644 src/main/acp/generated/license.gen.ts create mode 100644 src/main/acp/generated/mcp-server-acp.gen.ts create mode 100644 src/main/acp/generated/nes-context-capabilities.gen.ts create mode 100644 src/main/acp/generated/plan-update.gen.ts create mode 100644 src/main/acp/generated/protocol-version.gen.ts create mode 100644 src/main/acp/generated/protocol.gen.ts create mode 100644 src/main/acp/generated/providers-capabilities.gen.ts create mode 100644 src/main/acp/generated/session-config-option.gen.ts create mode 100644 src/main/acp/generated/session-update.gen.ts create mode 100644 src/main/acp/generated/set-session-model-request.gen.ts create mode 100644 src/main/acp/generated/text-resource-contents.gen.ts create mode 100644 src/main/acp/generated/wait-for-terminal-exit-request.gen.ts diff --git a/config/scripts/acp/generate-protocol.mjs b/config/scripts/acp/generate-protocol.mjs new file mode 100644 index 000000000000..db3de9cf5e31 --- /dev/null +++ b/config/scripts/acp/generate-protocol.mjs @@ -0,0 +1,298 @@ +import { createHash } from 'node:crypto' +import { mkdir, readdir, readFile, unlink, writeFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import { resolve } from 'node:path' + +const release = 'schema-v1.21.0' +const legacyRelease = 'v0.11.6' +const repository = 'https://github.com/agentclientprotocol/agent-client-protocol' +const output = fileURLToPath(new URL('../../../src/main/acp/generated/', import.meta.url)) +const check = process.argv.includes('--check') +const definitions = {} +const header = `// Generated by config/scripts/acp/generate-protocol.mjs; do not edit.\n// ACP ${release}, legacy model API ${legacyRelease}; SPDX-License-Identifier: Apache-2.0.\n` + +async function download(url, digest) { + const response = await fetch(url) + if (!response.ok) { + throw new Error(`Download failed: ${url} (${response.status})`) + } + const text = await response.text() + if (createHash('sha256').update(text).digest('hex') !== digest) { + throw new Error(`Upstream content changed: ${url}`) + } + return text +} + +const [schema, legacy, license] = await Promise.all([ + download( + `${repository}/releases/download/${release}/schema.unstable.json`, + '7f77702b34e0a0558e77220e9007bf8ee161a976bb8ac5021aba1b7e7b2c5708' + ), + download( + `${repository}/releases/download/${legacyRelease}/schema.unstable.json`, + 'b3cf8687d979c98c009f0fbcf8f0c237645b82ac2d2f0a3ebca683f963c3d581' + ), + download( + `https://raw.githubusercontent.com/agentclientprotocol/agent-client-protocol/${release}/LICENSE`, + 'f250d08cee4549b22b3b4aaaf3a743473336fd280316df5d0340717e5127a221' + ) +]) +Object.assign(definitions, JSON.parse(legacy).$defs, JSON.parse(schema).$defs) + +const roots = [ + 'InitializeRequest', + 'InitializeResponse', + 'AuthenticateRequest', + 'AuthenticateResponse', + 'NewSessionRequest', + 'NewSessionResponse', + 'LoadSessionRequest', + 'LoadSessionResponse', + 'ResumeSessionRequest', + 'ResumeSessionResponse', + 'PromptRequest', + 'PromptResponse', + 'CancelNotification', + 'SessionNotification', + 'RequestPermissionRequest', + 'RequestPermissionResponse', + 'SetSessionModeRequest', + 'SetSessionModeResponse', + 'SetSessionModelRequest', + 'SetSessionModelResponse', + 'SetSessionConfigOptionRequest', + 'SetSessionConfigOptionResponse', + 'ReadTextFileRequest', + 'ReadTextFileResponse', + 'WriteTextFileRequest', + 'WriteTextFileResponse', + 'CreateTerminalRequest', + 'CreateTerminalResponse', + 'TerminalOutputRequest', + 'TerminalOutputResponse', + 'ReleaseTerminalRequest', + 'ReleaseTerminalResponse', + 'WaitForTerminalExitRequest', + 'WaitForTerminalExitResponse', + 'KillTerminalRequest', + 'KillTerminalResponse' +] + +function references(value) { + if (!value || typeof value !== 'object') { + return [] + } + if (Array.isArray(value)) { + return value.flatMap(references) + } + return [ + ...(value.$ref ? [value.$ref.split('/').at(-1)] : []), + ...Object.values(value).flatMap(references) + ] +} + +const ordered = [] +const visiting = new Set() +const visited = new Set() +function visit(name) { + if (visited.has(name)) { + return + } + if (visiting.has(name)) { + throw new Error(`Recursive schema needs an explicit type: ${name}`) + } + if (!definitions[name]) { + throw new Error(`Missing definition: ${name}`) + } + visiting.add(name) + for (const dependency of references(definitions[name])) { + visit(dependency) + } + visiting.delete(name) + visited.add(name) + ordered.push(name) +} +roots.forEach(visit) + +function expression(value) { + if (value === true) { + return 'z.unknown()' + } + if (value === false) { + return 'z.never()' + } + if (value.$ref) { + return `${value.$ref.split('/').at(-1)}Schema` + } + if ('const' in value) { + return `z.literal(${JSON.stringify(value.const)})` + } + const alternatives = value.oneOf ?? value.anyOf + if (alternatives || value.allOf) { + const combined = alternatives + ? `z.union([${alternatives.map(expression).join(',')}])` + : value.allOf.map(expression).reduce((left, right) => `z.intersection(${left},${right})`) + const siblings = { ...value } + delete siblings.oneOf + delete siblings.anyOf + delete siblings.allOf + return siblings.type || siblings.properties + ? `z.intersection(${expression(siblings)},${combined})` + : combined + } + if (Array.isArray(value.type)) { + return `z.union([${value.type.map((type) => expression({ ...value, type })).join(',')}])` + } + let result + switch (value.type) { + case 'string': + result = 'z.string()' + break + case 'integer': + result = 'z.number().int()' + break + case 'number': + result = 'z.number()' + break + case 'boolean': + result = 'z.boolean()' + break + case 'null': + result = 'z.null()' + break + case 'array': + result = `z.array(${expression(value.items ?? true)})` + break + case 'object': { + const properties = Object.entries(value.properties ?? {}).map( + ([key, property]) => + `${JSON.stringify(key)}:${expression(property)}${value.required?.includes(key) ? '' : '.optional()'}` + ) + result = `z.${value.additionalProperties === false ? 'strictObject' : 'looseObject'}({${properties.join(',')}})` + if (typeof value.additionalProperties === 'object') { + result += `.catchall(${expression(value.additionalProperties)})` + } + break + } + default: + if ( + Object.keys(value).some( + (key) => !key.startsWith('x-') && !['description', 'title', 'default'].includes(key) + ) + ) { + throw new Error(`Unsupported schema: ${JSON.stringify(value)}`) + } + result = 'z.unknown()' + } + if (['integer', 'number'].includes(value.type) && typeof value.minimum === 'number') { + result += `.min(${value.minimum})` + } + if (['integer', 'number'].includes(value.type) && typeof value.maximum === 'number') { + result += `.max(${value.maximum})` + } + if (value.not) { + result += `.refine(value=>!${expression(value.not)}.safeParse(value).success)` + } + return result +} + +// Pack in dependency order so generated imports stay acyclic and each module stays small. +const groups = [] +let group = [] +let length = 0 +for (const name of ordered) { + const body = `export const ${name}Schema = ${expression(definitions[name])}\nexport type ${name} = z.infer\n` + if (length + body.length > 4300 && group.length) { + groups.push(group) + group = [] + length = 0 + } + group.push({ name, body }) + length += body.length +} +if (group.length) { + groups.push(group) +} +const filenames = new Map() +for (const entries of groups) { + const filename = entries[0].name.replace( + /[A-Z]/g, + (c, offset) => `${offset ? '-' : ''}${c.toLowerCase()}` + ) + for (const { name } of entries) { + filenames.set(name, `${filename}.gen.ts`) + } +} + +const generated = new Map() +for (const entries of groups) { + const file = filenames.get(entries[0].name) + const imports = new Map() + for (const { body } of entries) { + for (const dependency of [...body.matchAll(/\b(\w+)Schema\b/g)].map((match) => match[1])) { + const source = filenames.get(dependency) + if (source === file) { + continue + } + if (!imports.has(source)) { + imports.set(source, new Set()) + } + imports.get(source).add(`${dependency}Schema`) + } + } + generated.set( + file, + `${header}import { z } from 'zod'\n${[...imports] + .map( + ([source, names]) => `import { ${[...names].join(',')} } from './${source.slice(0, -3)}'\n` + ) + .join('')}${entries.map((entry) => entry.body).join('\n')}` + ) +} +generated.set( + 'protocol.gen.ts', + `${ + header + + [...new Set(filenames.values())] + .map((file) => `export * from './${file.slice(0, -3)}'`) + .join('\n') + }\n` +) +generated.set( + 'license.gen.ts', + `${header}/*\n${license.trim()}\n*/\nexport const ACP_SCHEMA_RELEASE = '${release}'\nexport const ACP_LEGACY_MODEL_SCHEMA_RELEASE = '${legacyRelease}'\nexport const ACP_PROTOCOL_VERSION = 1\n` +) + +// Use the repository formatter without spawning a platform-dependent executable shim. +const { format } = await import('oxfmt') +await mkdir(output, { recursive: true }) +for (const [file, source] of generated) { + const formatted = await format(file, source, { + singleQuote: true, + semi: false, + printWidth: 100, + trailingComma: 'none' + }) + if (formatted.errors.length) { + throw new Error(`Formatting failed for ${file}`) + } + const destination = resolve(output, file) + if (check) { + if ((await readFile(destination, 'utf8')) !== formatted.code) { + throw new Error(`Stale generated file: ${file}`) + } + } else { + await writeFile(destination, formatted.code) + } +} +for (const file of await readdir(output)) { + if (file.endsWith('.gen.ts') && !generated.has(file)) { + if (check || !(await readFile(resolve(output, file), 'utf8')).startsWith(header)) { + throw new Error(`Unexpected generated file: ${file}`) + } + await unlink(resolve(output, file)) + } +} +console.log( + `${check ? 'Checked' : 'Generated'} ${ordered.length} ACP definitions in ${generated.size} files` +) diff --git a/src/main/acp/acp-errors.ts b/src/main/acp/acp-errors.ts new file mode 100644 index 000000000000..732db5c26cfc --- /dev/null +++ b/src/main/acp/acp-errors.ts @@ -0,0 +1,31 @@ +export class AcpRpcError extends Error { + constructor( + readonly code: number, + message: string, + readonly data?: unknown + ) { + super(message) + this.name = 'AcpRpcError' + } +} + +export class AcpAuthRequiredError extends AcpRpcError { + constructor(message: string, data?: unknown) { + super(-32000, message, data) + this.name = 'AcpAuthRequiredError' + } +} + +export class AcpConnectionClosedError extends Error { + constructor(message = 'ACP connection closed') { + super(message) + this.name = 'AcpConnectionClosedError' + } +} + +export class AcpRequestTimeoutError extends Error { + constructor(readonly method: string) { + super(`ACP request timed out: ${method}`) + this.name = 'AcpRequestTimeoutError' + } +} diff --git a/src/main/acp/acp-incoming-requests.ts b/src/main/acp/acp-incoming-requests.ts new file mode 100644 index 000000000000..5b55fb8d5835 --- /dev/null +++ b/src/main/acp/acp-incoming-requests.ts @@ -0,0 +1,91 @@ +import { AcpRpcError } from './acp-errors' +import type { AcpPeerHandlers } from './acp-json-rpc-peer' + +type OpenRequest = { controller: AbortController; abandon: () => void } + +export class AcpIncomingRequests { + private readonly open = new Map() + + constructor( + private readonly handler: AcpPeerHandlers['onRequest'], + private readonly send: (message: object) => Promise, + private readonly onFailure: (error: Error) => void, + private readonly capacity: number, + private readonly timeoutMs: number + ) {} + + close(error: Error): void { + for (const request of this.open.values()) { + request.controller.abort(error) + request.abandon() + } + this.open.clear() + } + + handle(id: string | number | null, method: string, params: unknown): void { + if (this.open.has(id) || this.open.size >= this.capacity) { + void this.sendError( + id, + new AcpRpcError(-32600, 'Duplicate request id or incoming request capacity exceeded') + ) + return + } + const controller = new AbortController() + let timer: ReturnType | undefined + let abandon = (): void => {} + const deadline = new Promise((_resolve, reject) => { + abandon = () => reject(controller.signal.reason) + timer = setTimeout( + () => reject(new AcpRpcError(-32001, `ACP client request timed out: ${method}`)), + this.timeoutMs + ) + }) + this.open.set(id, { controller, abandon }) + void Promise.race([ + deadline, + Promise.resolve().then(() => { + if (controller.signal.aborted) { + return undefined + } + return this.handler?.(method, params, { id, signal: controller.signal }) + }) + ]) + .then(async (result) => { + if (controller.signal.aborted) { + return + } + if (result === undefined) { + throw new AcpRpcError(-32601, `Unknown ACP client method: ${method}`) + } + await this.send({ jsonrpc: '2.0', id, result }) + }) + .catch(async (error) => { + if (controller.signal.aborted) { + return + } + await this.sendError( + id, + error instanceof AcpRpcError + ? error + : new AcpRpcError(-32603, error instanceof Error ? error.message : String(error)) + ) + }) + .finally(() => { + clearTimeout(timer) + this.open.delete(id) + controller.abort() + }) + } + + private async sendError(id: string | number | null, error: AcpRpcError): Promise { + try { + await this.send({ + jsonrpc: '2.0', + id, + error: { code: error.code, message: error.message, data: error.data } + }) + } catch (failure) { + this.onFailure(failure instanceof Error ? failure : new Error(String(failure))) + } + } +} diff --git a/src/main/acp/acp-json-rpc-peer.test.ts b/src/main/acp/acp-json-rpc-peer.test.ts new file mode 100644 index 000000000000..3aba7848293a --- /dev/null +++ b/src/main/acp/acp-json-rpc-peer.test.ts @@ -0,0 +1,253 @@ +import { PassThrough, Writable } from 'node:stream' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { AcpJsonRpcPeer, type AcpPeerHandlers, type AcpPeerOptions } from './acp-json-rpc-peer' +import { AcpConnectionClosedError, AcpRequestTimeoutError, AcpRpcError } from './acp-errors' +import { AcpScriptedAgent, deferred, tick } from './acp-scripted-agent.test-support' + +const peers: AcpJsonRpcPeer[] = [] +const agents: AcpScriptedAgent[] = [] +function fixture(handlers: AcpPeerHandlers = {}, options: AcpPeerOptions = {}) { + const agent = new AcpScriptedAgent() + const peer = new AcpJsonRpcPeer(agent.stdout, agent.stdin, handlers, options) + agents.push(agent) + peers.push(peer) + return { peer, agent } +} +afterEach(() => { + peers.splice(0).forEach((peer) => peer.close()) + agents.splice(0).forEach((agent) => agent.close()) + vi.useRealTimers() +}) + +describe('ACP JSON-RPC peer', () => { + it('routes interleaved requests in both directions without conflating id types', async () => { + const { peer, agent } = fixture({ onRequest: (method, params) => ({ method, params }) }) + const first = peer.request('first', {}) + const second = peer.request('second', {}) + await tick() + expect(agent.frames.map((frame) => frame.id)).toEqual([1, 2]) + agent.send({ jsonrpc: '2.0', id: '1', result: 'not the numeric id' }) + const fromAgent = agent.request(1, '_question', { text: 'hello' }) + agent.reply(agent.frames[1], 'second result') + agent.reply(agent.frames[0], 'first result') + expect(await second).toBe('second result') + expect(await first).toBe('first result') + expect(await fromAgent).toMatchObject({ + id: 1, + result: { method: '_question', params: { text: 'hello' } } + }) + expect(await agent.request(null, '_null_id', {})).toMatchObject({ + id: null, + result: { method: '_null_id' } + }) + }) + + it('returns method-not-found and preserves explicit handler error objects', async () => { + const { agent } = fixture({ + onRequest: (method) => { + if (method === '_denied') { + throw new AcpRpcError(-32005, 'Denied', { reason: 'policy' }) + } + if (method === '_crash') { + throw new Error('Handler failed') + } + return undefined + } + }) + expect(await agent.request('missing', '_missing', {})).toMatchObject({ + error: { code: -32601 } + }) + expect(await agent.request('denied', '_denied', {})).toMatchObject({ + error: { code: -32005, message: 'Denied', data: { reason: 'policy' } } + }) + expect(await agent.request('crash', '_crash', {})).toMatchObject({ + error: { code: -32603, message: 'Handler failed' } + }) + }) + + it('ignores malformed and oversized lines then resumes at the next newline', async () => { + const diagnostics: string[] = [] + const notified: unknown[] = [] + const { peer, agent } = fixture( + { + onDiagnostic: (message) => diagnostics.push(message), + onNotification: (_method, params) => notified.push(params) + }, + { maxLineBytes: 100 } + ) + agent.stdout.write('not json\n[]\n{"jsonrpc":"1.0","method":"bad"}\n') + for (let i = 0; i < 20; i++) { + agent.stdout.write('x'.repeat(40)) + } + agent.stdout.write('\n') + const data = Buffer.from('{"jsonrpc":"2.0","method":"notice","params":"✓"}\r\n') + const split = data.indexOf(Buffer.from('✓')) + 1 + agent.stdout.write(data.subarray(0, split)) + agent.stdout.write(data.subarray(split)) + agent.on('valid', (frame) => agent.reply(frame, 'ok')) + expect(await peer.request('valid', {})).toBe('ok') + expect(notified).toEqual(['✓']) + expect(diagnostics).toContain('Ignored ACP line: invalid-json') + expect(diagnostics).toContain('Ignored ACP line: line-too-long') + expect(diagnostics).toContain('Ignored invalid ACP JSON-RPC envelope') + }) + + it('ignores malformed response envelopes without settling pending requests', async () => { + const { peer, agent } = fixture() + const pending = peer.request('wait', {}) + agent.stdout.write('{"jsonrpc":"2.0","id":1}\n') + agent.stdout.write('{"jsonrpc":"2.0","id":1,"result":"x","error":{"code":1,"message":"y"}}\n') + agent.stdout.write('{"jsonrpc":"2.0","id":1,"result":"ok"}\n') + expect(await pending).toBe('ok') + }) + + it('rejects all pending calls on exit and stops accepting requests', async () => { + const onClose = vi.fn() + const { peer, agent } = fixture({ onClose }) + const first = expect(peer.request('first', {})).rejects.toBeInstanceOf(AcpConnectionClosedError) + const second = expect(peer.request('second', {})).rejects.toBeInstanceOf( + AcpConnectionClosedError + ) + agent.stdout.end() + await Promise.all([first, second]) + peer.close() + expect(onClose).toHaveBeenCalledTimes(1) + expect(agent.stdout.listenerCount('data')).toBe(0) + await expect(peer.request('late', {})).rejects.toBeInstanceOf(AcpConnectionClosedError) + }) + + it('bounds pending calls and frees capacity when a request times out', async () => { + vi.useFakeTimers() + const { peer, agent } = fixture({}, { maxPendingRequests: 1, requestTimeoutMs: 20 }) + const timedOut = expect(peer.request('wait', {})).rejects.toBeInstanceOf(AcpRequestTimeoutError) + await expect(peer.request('overflow', {})).rejects.toThrow('capacity exceeded') + await vi.advanceTimersByTimeAsync(20) + await timedOut + agent.on('next', (frame) => agent.reply(frame, 'ok')) + expect(await peer.request('next', {})).toBe('ok') + expect(agent.frames.map((frame) => frame.method)).toEqual(['wait', 'next']) + }) + + it('expires unanswered incoming requests and aborts their hooks', async () => { + vi.useFakeTimers() + const signals: AbortSignal[] = [] + const { agent } = fixture( + { + onRequest: (_method, _params, context) => { + signals.push(context.signal) + return new Promise(() => {}) + } + }, + { maxIncomingRequests: 1, incomingRequestTimeoutMs: 20 } + ) + const first = agent.request('first', '_question', {}) + await vi.advanceTimersByTimeAsync(0) + expect(await agent.request('overflow', '_question', {})).toMatchObject({ + error: { code: -32600 } + }) + await vi.advanceTimersByTimeAsync(20) + expect(await first).toMatchObject({ error: { code: -32001 } }) + expect(signals[0].aborted).toBe(true) + const next = agent.request('next', '_question', {}) + await vi.advanceTimersByTimeAsync(20) + expect(await next).toMatchObject({ error: { code: -32001 } }) + expect(signals).toHaveLength(2) + }) + + it('aborts an in-flight incoming hook on close and never writes its late response', async () => { + const entered = deferred() + const result = deferred() + const { peer, agent } = fixture({ + onRequest: (_method, _params, context) => { + entered.resolve(context.signal) + return result.promise + } + }) + void agent.request('open', '_question', {}) + const signal = await entered.promise + peer.close() + expect(signal.aborted).toBe(true) + result.resolve({ late: true }) + await tick() + expect(agent.frames).toEqual([]) + }) + + it('serializes writes through backpressure and bounds queued output', async () => { + const writes: string[] = [] + const callbacks: ((error?: Error | null) => void)[] = [] + const output = new Writable({ + highWaterMark: 1, + write(chunk, _encoding, callback) { + writes.push(chunk.toString()) + callbacks.push(callback) + } + }) + const input = new PassThrough() + const peer = new AcpJsonRpcPeer(input, output, {}, { maxQueuedWriteBytes: 170 }) + peers.push(peer) + const first = peer.notify('one', {}) + const second = peer.notify('two', {}) + const third = peer.notify('three', {}) + await expect(peer.notify('overflow', {})).rejects.toThrow('capacity exceeded') + expect(writes).toHaveLength(1) + callbacks[0]() + await first + expect(writes).toHaveLength(2) + callbacks[1]() + await second + callbacks[2]() + await third + expect(writes.map((line) => JSON.parse(line).method)).toEqual(['one', 'two', 'three']) + peer.close() + input.destroy() + output.destroy() + }) + + it('rejects active and queued writes when output closes', async () => { + const input = new PassThrough() + const output = new Writable({ write(_chunk, _encoding, _callback) {} }) + const peer = new AcpJsonRpcPeer(input, output) + peers.push(peer) + const first = expect(peer.notify('one', {})).rejects.toBeInstanceOf(AcpConnectionClosedError) + const second = expect(peer.request('two', {})).rejects.toBeInstanceOf(AcpConnectionClosedError) + output.destroy() + await Promise.all([first, second]) + input.destroy() + }) + + it('removes a timed-out queued request before it can reach the agent', async () => { + vi.useFakeTimers() + const writes: string[] = [] + const callbacks: ((error?: Error | null) => void)[] = [] + const input = new PassThrough() + const output = new Writable({ + highWaterMark: 1, + write(chunk, _encoding, callback) { + writes.push(chunk.toString()) + callbacks.push(callback) + } + }) + const peer = new AcpJsonRpcPeer(input, output, {}, { requestTimeoutMs: 20 }) + peers.push(peer) + const first = peer.notify('hold', {}) + const timedOut = expect(peer.request('must-not-arrive', {})).rejects.toBeInstanceOf( + AcpRequestTimeoutError + ) + await vi.advanceTimersByTimeAsync(20) + await timedOut + callbacks[0]() + await first + expect(writes.map((line) => JSON.parse(line).method)).toEqual(['hold']) + peer.close() + input.destroy() + output.destroy() + }) + + it('fails pending requests on an output stream error', async () => { + const { peer, agent } = fixture() + const rejected = expect(peer.request('wait', {})).rejects.toThrow('Broken pipe') + agent.stdin.emit('error', new Error('Broken pipe')) + await rejected + expect(peer.closed).toBe(true) + }) +}) diff --git a/src/main/acp/acp-json-rpc-peer.ts b/src/main/acp/acp-json-rpc-peer.ts new file mode 100644 index 000000000000..0924a1f797fa --- /dev/null +++ b/src/main/acp/acp-json-rpc-peer.ts @@ -0,0 +1,258 @@ +import type { Readable, Writable } from 'node:stream' +import { z } from 'zod' +import { + createIncrementalNdjsonFramer, + encodeNdjson +} from '../../shared/main-process-ndjson-framer' +import { + AcpAuthRequiredError, + AcpConnectionClosedError, + AcpRequestTimeoutError, + AcpRpcError +} from './acp-errors' +import { AcpIncomingRequests } from './acp-incoming-requests' +import { AcpWriteQueue } from './acp-write-queue' + +const idSchema = z.union([z.string(), z.number(), z.null()]) +const envelopeSchema = z.looseObject({ + jsonrpc: z.literal('2.0'), + id: idSchema.optional(), + method: z.string().optional(), + params: z.unknown().optional(), + result: z.unknown().optional(), + error: z + .object({ code: z.number().int(), message: z.string(), data: z.unknown().optional() }) + .optional() +}) +export type AcpRequestContext = { id: string | number | null; signal: AbortSignal } +export type AcpPeerHandlers = { + onRequest?: ( + method: string, + params: unknown, + context: AcpRequestContext + ) => unknown | Promise + onNotification?: (method: string, params: unknown) => void + onDiagnostic?: (message: string) => void + onClose?: (error: Error) => void +} +export type AcpPeerOptions = { + maxLineBytes?: number + maxQueuedWriteBytes?: number + maxPendingRequests?: number + maxIncomingRequests?: number + requestTimeoutMs?: number + incomingRequestTimeoutMs?: number +} +type Pending = { + resolve: (value: unknown) => void + reject: (error: Error) => void + timer: ReturnType +} + +export class AcpJsonRpcPeer { + private readonly pending = new Map() + private readonly incoming: AcpIncomingRequests + private readonly writer: AcpWriteQueue + private readonly framer: ReturnType + private nextId = 1 + private terminalError?: Error + private readonly maxLineBytes: number + private readonly maxPending: number + private readonly maxIncoming: number + private readonly timeoutMs: number + + constructor( + private readonly input: Readable, + private readonly output: Writable, + private readonly handlers: AcpPeerHandlers = {}, + options: AcpPeerOptions = {} + ) { + this.maxLineBytes = bounded(options.maxLineBytes, 16 * 1024 * 1024) + this.maxPending = bounded(options.maxPendingRequests, 128) + this.maxIncoming = bounded(options.maxIncomingRequests, 128) + this.timeoutMs = bounded(options.requestTimeoutMs, 120_000) + this.writer = new AcpWriteQueue( + output, + bounded(options.maxQueuedWriteBytes, 32 * 1024 * 1024), + (error) => this.close(error) + ) + this.incoming = new AcpIncomingRequests( + handlers.onRequest, + (message) => this.send(message), + (error) => this.close(error), + this.maxIncoming, + bounded(options.incomingRequestTimeoutMs, this.timeoutMs) + ) + this.framer = createIncrementalNdjsonFramer( + (record) => this.dispatch(record), + (rejected) => this.diagnose(`Ignored ACP line: ${rejected.kind}`), + { maxLineBytes: this.maxLineBytes } + ) + input.setEncoding('utf8') + input.on('data', this.onData) + input.on('end', this.onEnd) + input.on('close', this.onEnd) + input.on('error', this.onError) + output.on('close', this.onEnd) + output.on('finish', this.onEnd) + output.on('error', this.onError) + if (input.destroyed || input.readableEnded || output.destroyed || !output.writable) { + this.onEnd() + } + } + + get closed(): boolean { + return this.terminalError !== undefined + } + + request(method: string, params: unknown, options: { timeoutMs?: number } = {}): Promise { + if (this.terminalError) { + return Promise.reject(this.terminalError) + } + if (this.pending.size >= this.maxPending) { + return Promise.reject(new Error('ACP pending request capacity exceeded')) + } + const id = this.nextId++ + const controller = new AbortController() + return new Promise((resolve, reject) => { + const timer = setTimeout( + () => { + this.pending.delete(id) + const error = new AcpRequestTimeoutError(method) + controller.abort(error) + reject(error) + }, + bounded(options.timeoutMs, this.timeoutMs) + ) + this.pending.set(id, { resolve, reject, timer }) + void this.send({ jsonrpc: '2.0', id, method, params }, controller.signal).catch((error) => { + const pending = this.pending.get(id) + if (!pending) { + return + } + this.pending.delete(id) + clearTimeout(timer) + pending.reject(error instanceof Error ? error : new Error(String(error))) + }) + }) + } + + notify(method: string, params: unknown): Promise { + return this.send({ jsonrpc: '2.0', method, params }) + } + + close(error: Error = new AcpConnectionClosedError()): void { + if (this.terminalError) { + return + } + this.terminalError = error + this.input.removeListener('data', this.onData) + this.input.removeListener('end', this.onEnd) + this.input.removeListener('close', this.onEnd) + this.input.removeListener('error', this.onError) + this.output.removeListener('close', this.onEnd) + this.output.removeListener('finish', this.onEnd) + this.output.removeListener('error', this.onError) + this.framer.reset() + this.writer.close(error) + for (const pending of this.pending.values()) { + clearTimeout(pending.timer) + pending.reject(error) + } + this.pending.clear() + this.incoming.close(error) + try { + this.handlers.onClose?.(error) + } catch (failure) { + this.diagnose(String(failure)) + } + } + + private readonly onData = (chunk: string): void => { + try { + this.framer.feed(chunk) + } catch (error) { + this.close(error instanceof Error ? error : new Error(String(error))) + } + } + private readonly onEnd = (): void => this.close() + private readonly onError = (error: Error): void => this.close(error) + private diagnose(message: string): void { + try { + this.handlers.onDiagnostic?.(message) + } catch { + /* Diagnostics cannot break the transport. */ + } + } + + private send(message: object, signal?: AbortSignal): Promise { + if (this.terminalError) { + return Promise.reject(this.terminalError) + } + try { + return this.writer.write(encodeNdjson(message, this.maxLineBytes), signal) + } catch (error) { + return Promise.reject(error) + } + } + + private dispatch(record: unknown): void { + if (this.closed) { + return + } + const parsed = envelopeSchema.safeParse(record) + if (!parsed.success) { + this.diagnose('Ignored invalid ACP JSON-RPC envelope') + return + } + const frame = parsed.data + if (frame.method !== undefined) { + if ('result' in frame || 'error' in frame) { + this.diagnose('Ignored invalid ACP request') + return + } + if (frame.id !== undefined) { + this.incoming.handle(frame.id, frame.method, frame.params) + return + } + try { + this.handlers.onNotification?.(frame.method, frame.params) + } catch (error) { + this.diagnose(`ACP notification handler failed: ${String(error)}`) + } + return + } + if ('result' in frame === 'error' in frame) { + this.diagnose('Ignored invalid ACP response') + return + } + if (typeof frame.id !== 'number') { + return + } + const pending = this.pending.get(frame.id) + if (!pending) { + return + } + this.pending.delete(frame.id) + clearTimeout(pending.timer) + if (frame.error) { + pending.reject( + frame.error.code === -32000 + ? new AcpAuthRequiredError(frame.error.message, frame.error.data) + : new AcpRpcError(frame.error.code, frame.error.message, frame.error.data) + ) + } else { + pending.resolve(frame.result) + } + } +} + +function bounded(value: number | undefined, fallback: number): number { + if (value === undefined) { + return fallback + } + if (!Number.isSafeInteger(value) || value <= 0) { + throw new Error('ACP limits must be positive finite integers') + } + return value +} diff --git a/src/main/acp/acp-permission-requests.ts b/src/main/acp/acp-permission-requests.ts new file mode 100644 index 000000000000..120f896be2b6 --- /dev/null +++ b/src/main/acp/acp-permission-requests.ts @@ -0,0 +1,88 @@ +import { AcpRpcError } from './acp-errors' +import type { AcpRequestContext } from './acp-json-rpc-peer' +import { + RequestPermissionResponseSchema, + type RequestPermissionRequest, + type RequestPermissionResponse +} from './generated/protocol.gen' + +export type AcpPermissionHandler = ( + request: RequestPermissionRequest, + context: AcpRequestContext +) => RequestPermissionResponse | Promise + +const cancelled: RequestPermissionResponse = { outcome: { outcome: 'cancelled' } } +type OpenPermission = { sessionId: string; cancel: () => void } + +export class AcpPermissionRequests { + private readonly open = new Set() + + cancel(sessionId: string): void { + for (const permission of this.open) { + if (permission.sessionId === sessionId) { + permission.cancel() + } + } + } + + handle( + request: RequestPermissionRequest, + context: AcpRequestContext, + handler: AcpPermissionHandler | undefined + ): Promise { + if (!handler || context.signal.aborted) { + return Promise.resolve(cancelled) + } + return new Promise((resolve, reject) => { + const controller = new AbortController() + let settled = false + const finish = (response: RequestPermissionResponse | Error): void => { + if (settled) { + return + } + settled = true + this.open.delete(permission) + context.signal.removeEventListener('abort', permission.cancel) + controller.abort() + if (response instanceof Error) { + reject(response) + } else { + resolve(response) + } + } + const permission: OpenPermission = { + sessionId: request.sessionId, + cancel: () => finish(cancelled) + } + this.open.add(permission) + context.signal.addEventListener('abort', permission.cancel, { once: true }) + void Promise.resolve() + .then(() => { + if (controller.signal.aborted) { + return cancelled + } + return handler(request, { id: context.id, signal: controller.signal }) + }) + .then((response) => { + if (settled) { + return + } + const parsed = RequestPermissionResponseSchema.safeParse(response) + if (!parsed.success) { + finish(new AcpRpcError(-32603, 'Invalid permission handler response')) + return + } + const outcome = parsed.data.outcome + if ( + outcome.outcome === 'selected' && + !request.options.some((option) => option.optionId === outcome.optionId) + ) { + finish(new AcpRpcError(-32603, 'Permission handler selected an unavailable option')) + return + } + finish(parsed.data) + }) + .catch((error) => finish(error instanceof Error ? error : new Error(String(error)))) + }) + } +} diff --git a/src/main/acp/acp-scripted-agent.test-support.ts b/src/main/acp/acp-scripted-agent.test-support.ts new file mode 100644 index 000000000000..7de3fe93d2e6 --- /dev/null +++ b/src/main/acp/acp-scripted-agent.test-support.ts @@ -0,0 +1,85 @@ +import { PassThrough } from 'node:stream' +import { z } from 'zod' + +const frameSchema = z.object({ + jsonrpc: z.literal('2.0'), + id: z.union([z.string(), z.number(), z.null()]).optional(), + method: z.string().optional(), + params: z.unknown().optional(), + result: z.unknown().optional(), + error: z + .object({ code: z.number(), message: z.string(), data: z.unknown().optional() }) + .optional() +}) +export type FakeFrame = z.infer +type Handler = (frame: FakeFrame) => void + +export class AcpScriptedAgent { + readonly stdout = new PassThrough() + readonly stdin = new PassThrough() + readonly frames: FakeFrame[] = [] + private readonly methods = new Map() + private readonly requests = new Map void>() + private suffix = '' + + constructor() { + this.stdin.setEncoding('utf8').on('data', (chunk: string) => { + this.suffix += chunk + let newline: number + while ((newline = this.suffix.indexOf('\n')) !== -1) { + const frame = frameSchema.parse(JSON.parse(this.suffix.slice(0, newline))) + this.suffix = this.suffix.slice(newline + 1) + this.frames.push(frame) + if (frame.method !== undefined) { + this.methods.get(frame.method)?.(frame) + } else if (frame.id !== undefined) { + this.requests.get(frame.id)?.(frame) + this.requests.delete(frame.id) + } + } + }) + } + + on(method: string, handler: Handler): void { + this.methods.set(method, handler) + } + reply(frame: FakeFrame, result: unknown): void { + this.send({ jsonrpc: '2.0', id: frame.id, result }) + } + fail(frame: FakeFrame, code: number, message: string, data?: unknown): void { + this.send({ jsonrpc: '2.0', id: frame.id, error: { code, message, data } }) + } + notify(method: string, params: unknown): void { + this.send({ jsonrpc: '2.0', method, params }) + } + request(id: string | number | null, method: string, params: unknown): Promise { + return new Promise((resolve) => { + this.requests.set(id, resolve) + this.send({ jsonrpc: '2.0', id, method, params }) + }) + } + send(frame: FakeFrame): void { + this.stdout.write(`${JSON.stringify(frame)}\n`) + } + close(): void { + this.stdout.end() + this.stdin.end() + } +} + +export function deferred(): { promise: Promise; resolve: (value: T) => void } { + let fulfill: ((value: T) => void) | undefined + const promise = new Promise((resolve) => { + fulfill = resolve + }) + return { + promise, + resolve: (value) => { + fulfill?.(value) + } + } +} + +export function tick(): Promise { + return new Promise((resolve) => setImmediate(resolve)) +} diff --git a/src/main/acp/acp-session-runtime.test.ts b/src/main/acp/acp-session-runtime.test.ts new file mode 100644 index 000000000000..bdc1e0cd8256 --- /dev/null +++ b/src/main/acp/acp-session-runtime.test.ts @@ -0,0 +1,413 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + AcpAuthRequiredError, + AcpConnectionClosedError, + AcpRequestTimeoutError +} from './acp-errors' +import { AcpSessionRuntime, type AcpSessionRuntimeOptions } from './acp-session-runtime' +import { AcpScriptedAgent, deferred, tick } from './acp-scripted-agent.test-support' +import type { + AgentCapabilities, + RequestPermissionResponse, + SessionNotification +} from './generated/protocol.gen' +import { SetSessionConfigOptionRequestSchema } from './generated/protocol.gen' + +const opened: { runtime: AcpSessionRuntime; agent: AcpScriptedAgent }[] = [] +const startOptions = { cwd: '/runtime/project', mcpServers: [] } +const textPrompt = [{ type: 'text', text: 'hello' }] as const +const permission = { + sessionId: 'session-1', + toolCall: { toolCallId: 'tool-1', title: 'Edit file' }, + options: [{ optionId: 'allow', name: 'Allow once', kind: 'allow_once' }] +} +function fixture(capabilities: AgentCapabilities = {}, options: AcpSessionRuntimeOptions = {}) { + const agent = new AcpScriptedAgent() + agent.on('initialize', (frame) => + agent.reply(frame, { protocolVersion: 1, agentCapabilities: capabilities }) + ) + agent.on('session/new', (frame) => agent.reply(frame, { sessionId: 'session-1' })) + agent.on('session/load', (frame) => agent.reply(frame, {})) + agent.on('session/resume', (frame) => agent.reply(frame, {})) + agent.on('session/prompt', (frame) => agent.reply(frame, { stopReason: 'end_turn' })) + const runtime = new AcpSessionRuntime(agent.stdout, agent.stdin, options) + opened.push({ runtime, agent }) + return { runtime, agent } +} +afterEach(() => { + for (const { runtime, agent } of opened.splice(0)) { + runtime.close() + agent.close() + } + vi.useRealTimers() +}) + +describe('ACP session runtime', () => { + it('initializes once, starts a session, streams typed updates, and completes the turn', async () => { + const { runtime, agent } = fixture() + const events: SessionNotification[] = [] + const unsubscribe = runtime.subscribe((event) => events.push(event)) + const updates = [ + { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'Hello' } }, + { sessionUpdate: 'agent_thought_chunk', content: { type: 'text', text: 'Thinking' } }, + { sessionUpdate: 'user_message_chunk', content: { type: 'text', text: 'hello' } }, + { sessionUpdate: 'tool_call', toolCallId: 'tool-1', title: 'Read file' }, + { sessionUpdate: 'tool_call_update', toolCallId: 'tool-1', status: 'completed' }, + { + sessionUpdate: 'plan', + entries: [{ content: 'Read', priority: 'medium', status: 'completed' }] + }, + { sessionUpdate: 'usage_update', used: 12, size: 100 }, + { sessionUpdate: 'available_commands_update', availableCommands: [] }, + { sessionUpdate: 'current_mode_update', currentModeId: 'plan' }, + { sessionUpdate: 'config_option_update', configOptions: [] }, + { sessionUpdate: 'session_info_update', title: 'Test session' } + ] + agent.on('session/prompt', (frame) => { + for (const update of updates) { + agent.notify('session/update', { sessionId: 'session-1', update }) + } + agent.reply(frame, { stopReason: 'end_turn' }) + }) + await Promise.all([runtime.initialize(), runtime.initialize()]) + expect(await runtime.start(startOptions)).toMatchObject({ kind: 'new', sessionId: 'session-1' }) + expect(await runtime.prompt([...textPrompt])).toEqual({ stopReason: 'end_turn' }) + expect(events.map((event) => event.update)).toEqual(updates) + expect(agent.frames.filter((frame) => frame.method === 'initialize')).toHaveLength(1) + expect(agent.frames[0].params).toEqual({ + protocolVersion: 1, + clientCapabilities: { fs: { readTextFile: false, writeTextFile: false }, terminal: false } + }) + expect(agent.frames.find((frame) => frame.method === 'session/new')?.params).toEqual( + startOptions + ) + unsubscribe() + agent.notify('session/update', { sessionId: 'session-1', update: updates[0] }) + expect(events).toHaveLength(updates.length) + }) + + it.each([ + [{ loadSession: true }, undefined, 'load'], + [{ sessionCapabilities: { resume: {} } }, undefined, 'resume'], + [{ loadSession: true, sessionCapabilities: { resume: {} } }, undefined, 'load'], + [{ loadSession: true, sessionCapabilities: { resume: {} } }, 'resume', 'resume'], + [{ loadSession: true, sessionCapabilities: { resume: null } }, 'resume', 'load'] + ] as const)( + 'selects supported activation (%j, %s)', + async (capabilities, preference, expected) => { + const { runtime, agent } = fixture(capabilities) + const result = await runtime.start({ + ...startOptions, + sessionId: 'old', + resumePreference: preference + }) + expect(result).toMatchObject({ kind: expected, sessionId: 'old' }) + expect(agent.frames.at(-1)).toMatchObject({ + method: `session/${expected}`, + params: { ...startOptions, sessionId: 'old' } + }) + } + ) + + it('refuses unsupported restoration without silently creating a different session', async () => { + const { runtime, agent } = fixture({ sessionCapabilities: { resume: null } }) + await expect(runtime.start({ ...startOptions, sessionId: 'old' })).rejects.toMatchObject({ + code: -32601 + }) + expect(agent.frames.map((frame) => frame.method)).toEqual(['initialize']) + }) + + it('round-trips permission and vendor requests while prompt is pending', async () => { + const permitted = deferred() + const { runtime, agent } = fixture( + {}, + { + onPermission: (request) => { + expect(request.toolCall.toolCallId).toBe('tool-1') + return { outcome: { outcome: 'selected', optionId: 'allow' } } + }, + onRequest: (method, params) => + method === '_vendor/question' ? { answer: params } : undefined + } + ) + agent.on('session/prompt', (frame) => { + void agent.request('agent-id', 'session/request_permission', permission).then((response) => { + permitted.resolve(response.result) + agent.reply(frame, { stopReason: 'end_turn' }) + }) + }) + await runtime.start(startOptions) + const prompt = runtime.prompt([...textPrompt]) + expect(await permitted.promise).toEqual({ outcome: { outcome: 'selected', optionId: 'allow' } }) + await prompt + expect(await agent.request(1, '_vendor/question', 'yes')).toMatchObject({ + id: 1, + result: { answer: 'yes' } + }) + expect(await agent.request('unknown', '_vendor/unknown', {})).toMatchObject({ + error: { code: -32601 } + }) + expect(await agent.request('fs', 'fs/read_text_file', {})).toMatchObject({ + error: { code: -32601 } + }) + }) + + it('cancels open and late permissions and waits for the agent cancellation response', async () => { + const requested = deferred() + const decision = deferred() + const responses: unknown[] = [] + const { runtime, agent } = fixture( + {}, + { + onPermission: (_request, context) => { + requested.resolve(context.signal) + return decision.promise + } + } + ) + agent.on('session/prompt', (frame) => { + void agent + .request('permission', 'session/request_permission', permission) + .then((response) => responses.push(response.result)) + agent.on('session/cancel', (cancel) => { + expect(cancel.id).toBeUndefined() + void agent + .request('late-permission', 'session/request_permission', permission) + .then((response) => { + responses.push(response.result) + agent.reply(frame, { stopReason: 'cancelled' }) + }) + }) + }) + await runtime.start(startOptions) + const prompt = runtime.prompt([...textPrompt]) + const signal = await requested.promise + const cancel = runtime.cancel() + expect(runtime.cancel()).toBe(cancel) + expect(await prompt).toEqual({ stopReason: 'cancelled' }) + await cancel + expect(signal.aborted).toBe(true) + expect(responses).toEqual([ + { outcome: { outcome: 'cancelled' } }, + { outcome: { outcome: 'cancelled' } } + ]) + decision.resolve({ outcome: { outcome: 'selected', optionId: 'allow' } }) + await tick() + expect(agent.frames.filter((frame) => frame.id === 'permission')).toHaveLength(1) + }) + + it('surfaces authentication-required errors with code and data', async () => { + const { runtime, agent } = fixture() + agent.on('session/new', (frame) => + agent.fail(frame, -32000, 'Login required', { detail: 'Sign in' }) + ) + await expect(runtime.start(startOptions)).rejects.toBeInstanceOf(AcpAuthRequiredError) + await expect(runtime.start(startOptions)).rejects.toMatchObject({ + code: -32000, + data: { detail: 'Sign in' } + }) + }) + + it('authenticates once with a configured agent method and retries session setup', async () => { + const { runtime, agent } = fixture() + let authenticated = false + agent.on('initialize', (frame) => + agent.reply(frame, { protocolVersion: 1, authMethods: [{ id: 'login', name: 'Login' }] }) + ) + agent.on('session/new', (frame) => + authenticated + ? agent.reply(frame, { sessionId: 'session-1' }) + : agent.fail(frame, -32000, 'Login required') + ) + agent.on('authenticate', (frame) => { + authenticated = true + agent.reply(frame, {}) + }) + await runtime.start({ ...startOptions, authMethodId: 'login' }) + expect(agent.frames.map((frame) => frame.method)).toEqual([ + 'initialize', + 'session/new', + 'authenticate', + 'session/new' + ]) + expect(agent.frames[2].params).toEqual({ methodId: 'login' }) + }) + + it('chooses an advertised agent authentication method when login is required', async () => { + const { runtime, agent } = fixture() + let authenticated = false + agent.on('initialize', (frame) => + agent.reply(frame, { + protocolVersion: 1, + authMethods: [ + { id: 'terminal', name: 'Interactive login', type: 'terminal' }, + { id: 'agent', name: 'Agent login' } + ] + }) + ) + agent.on('session/new', (frame) => + authenticated + ? agent.reply(frame, { sessionId: 'session-1' }) + : agent.fail(frame, -32000, 'Login required') + ) + agent.on('authenticate', (frame) => { + expect(frame.params).toEqual({ methodId: 'agent' }) + authenticated = true + agent.reply(frame, {}) + }) + await runtime.start(startOptions) + expect(authenticated).toBe(true) + }) + + it('does not retry authentication indefinitely or start an interactive login', async () => { + const { runtime, agent } = fixture() + agent.on('initialize', (frame) => + agent.reply(frame, { + protocolVersion: 1, + authMethods: [{ id: 'terminal', name: 'Interactive login', type: 'terminal' }] + }) + ) + agent.on('session/new', (frame) => agent.fail(frame, -32000, 'Login required')) + await expect(runtime.start(startOptions)).rejects.toBeInstanceOf(AcpAuthRequiredError) + expect(agent.frames.map((frame) => frame.method)).toEqual(['initialize', 'session/new']) + const retry = fixture() + retry.agent.on('initialize', (frame) => + retry.agent.reply(frame, { + protocolVersion: 1, + authMethods: [{ id: 'agent', name: 'Agent login' }] + }) + ) + retry.agent.on('authenticate', (frame) => retry.agent.reply(frame, {})) + retry.agent.on('session/new', (frame) => + retry.agent.fail(frame, -32000, 'Still requires login') + ) + await expect(retry.runtime.start(startOptions)).rejects.toBeInstanceOf(AcpAuthRequiredError) + expect(retry.agent.frames.map((frame) => frame.method)).toEqual([ + 'initialize', + 'session/new', + 'authenticate', + 'session/new' + ]) + }) + + it('sends mode, model, and config changes to the active session', async () => { + const { runtime, agent } = fixture() + agent.on('session/set_mode', (frame) => agent.reply(frame, {})) + agent.on('session/set_model', (frame) => agent.reply(frame, {})) + agent.on('session/set_config_option', (frame) => { + SetSessionConfigOptionRequestSchema.parse(frame.params) + agent.reply(frame, { configOptions: [] }) + }) + await runtime.start(startOptions) + await runtime.setMode('plan') + await runtime.setModel('model-1') + await runtime.setConfigOption('thinking', 'high') + expect(agent.frames.slice(-3).map((frame) => frame.params)).toEqual([ + { sessionId: 'session-1', modeId: 'plan' }, + { sessionId: 'session-1', modelId: 'model-1' }, + { sessionId: 'session-1', configId: 'thinking', value: 'high' } + ]) + await runtime.setConfigOption('enabled', true) + expect(agent.frames.at(-1)?.params).toEqual({ + sessionId: 'session-1', + configId: 'enabled', + value: true, + type: 'boolean' + }) + }) + + it('rejects an unsupported protocol and malformed session responses', async () => { + const { runtime, agent } = fixture() + agent.on('initialize', (frame) => agent.reply(frame, { protocolVersion: 2 })) + await expect(runtime.start(startOptions)).rejects.toMatchObject({ code: -32602 }) + expect(agent.frames.map((frame) => frame.method)).toEqual(['initialize']) + const malformed = fixture() + malformed.agent.on('session/new', (frame) => malformed.agent.reply(frame, {})) + await expect(malformed.runtime.start(startOptions)).rejects.toMatchObject({ code: -32603 }) + }) + + it('rejects invalid permission requests and fails closed on an unavailable selected option', async () => { + const { runtime, agent } = fixture( + {}, + { + onPermission: () => ({ outcome: { outcome: 'selected', optionId: 'not-offered' } }) + } + ) + agent.on('session/prompt', () => {}) + await runtime.start(startOptions) + const prompt = runtime.prompt([...textPrompt]) + const rejected = expect(prompt).rejects.toBeInstanceOf(AcpConnectionClosedError) + expect(await agent.request('invalid', 'session/request_permission', {})).toMatchObject({ + error: { code: -32602 } + }) + expect( + await agent.request('bad-selection', 'session/request_permission', permission) + ).toMatchObject({ error: { code: -32603 } }) + await expect(runtime.prompt([...textPrompt])).rejects.toThrow('already in progress') + runtime.close() + await rejected + }) + + it('ignores invalid updates and isolates event listener failures', async () => { + const diagnostics: string[] = [] + const { runtime, agent } = fixture({}, { onDiagnostic: (message) => diagnostics.push(message) }) + const updates: SessionNotification[] = [] + runtime.subscribe(() => { + throw new Error('Consumer failed') + }) + runtime.subscribe((event) => updates.push(event)) + await runtime.start(startOptions) + agent.notify('session/update', { + sessionId: 'session-1', + update: { sessionUpdate: 'agent_message_chunk', content: {} } + }) + agent.notify('session/update', { + sessionId: 'session-1', + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'valid', _meta: { vendor: true } } + } + }) + expect(updates).toHaveLength(1) + expect(updates[0].update).toMatchObject({ content: { _meta: { vendor: true } } }) + expect(diagnostics).toContain('Ignored invalid ACP session update') + expect(diagnostics.some((message) => message.includes('Consumer failed'))).toBe(true) + }) + + it('rejects pending calls and aborts permission hooks when the agent exits', async () => { + const requested = deferred() + const { runtime, agent } = fixture( + {}, + { + onPermission: (_request, context) => { + requested.resolve(context.signal) + return new Promise(() => {}) + } + } + ) + agent.on('session/prompt', () => { + void agent.request('permission', 'session/request_permission', permission) + }) + await runtime.start(startOptions) + const pending = runtime.prompt([...textPrompt]) + const rejected = expect(pending).rejects.toBeInstanceOf(AcpConnectionClosedError) + const signal = await requested.promise + agent.stdout.end() + await rejected + expect(signal.aborted).toBe(true) + await expect(runtime.setMode('plan')).rejects.toBeInstanceOf(AcpConnectionClosedError) + }) + + it('closes an unconfirmed cancellation rather than allowing another prompt', async () => { + vi.useFakeTimers() + const { runtime, agent } = fixture({}, { cancelTimeoutMs: 100 }) + agent.on('session/prompt', () => {}) + await runtime.start(startOptions) + const prompt = runtime.prompt([...textPrompt]) + const rejectedPrompt = expect(prompt).rejects.toBeInstanceOf(AcpRequestTimeoutError) + const cancelled = expect(runtime.cancel()).rejects.toBeInstanceOf(AcpRequestTimeoutError) + await vi.advanceTimersByTimeAsync(100) + await Promise.all([rejectedPrompt, cancelled]) + await expect(runtime.prompt([...textPrompt])).rejects.toBeInstanceOf(AcpRequestTimeoutError) + }) +}) diff --git a/src/main/acp/acp-session-runtime.ts b/src/main/acp/acp-session-runtime.ts new file mode 100644 index 000000000000..f9296d6c82da --- /dev/null +++ b/src/main/acp/acp-session-runtime.ts @@ -0,0 +1,303 @@ +import type { Readable, Writable } from 'node:stream' +import type { z } from 'zod' +import { AcpRequestTimeoutError, AcpRpcError } from './acp-errors' +import { AcpJsonRpcPeer, type AcpPeerOptions, type AcpRequestContext } from './acp-json-rpc-peer' +import { AcpPermissionRequests, type AcpPermissionHandler } from './acp-permission-requests' +import { + setupAcpSession, + type AcpSessionStarted, + type AcpSessionStartOptions +} from './acp-session-setup' +export type { AcpSessionStarted, AcpSessionStartOptions } from './acp-session-setup' +import { ACP_PROTOCOL_VERSION } from './generated/license.gen' +import { + InitializeResponseSchema, + AuthenticateResponseSchema, + PromptResponseSchema, + RequestPermissionRequestSchema, + SessionNotificationSchema, + SetSessionModeResponseSchema, + SetSessionModelResponseSchema, + SetSessionConfigOptionResponseSchema, + type InitializeRequest, + type InitializeResponse, + type AuthenticateResponse, + type PromptRequest, + type PromptResponse, + type SessionNotification, + type SetSessionConfigOptionRequest, + type SetSessionConfigOptionResponse, + type SetSessionModeResponse, + type SetSessionModelResponse +} from './generated/protocol.gen' + +export type AcpSessionRuntimeOptions = { + clientInfo?: InitializeRequest['clientInfo'] + peer?: AcpPeerOptions + promptTimeoutMs?: number + cancelTimeoutMs?: number + onPermission?: AcpPermissionHandler + onRequest?: ( + method: string, + params: unknown, + context: AcpRequestContext + ) => unknown | Promise + onDiagnostic?: (message: string) => void + onClose?: (error: Error) => void +} +type ActivePrompt = { + response: Promise + cancelling: boolean + cancelPromise?: Promise +} + +export class AcpSessionRuntime { + private readonly peer: AcpJsonRpcPeer + private readonly permissions = new AcpPermissionRequests() + private readonly listeners = new Set<(event: SessionNotification) => void>() + private initialized?: Promise + private starting?: Promise + private started?: AcpSessionStarted + private activePrompt?: ActivePrompt + + constructor( + input: Readable, + output: Writable, + private readonly options: AcpSessionRuntimeOptions = {} + ) { + for (const timeout of [options.promptTimeoutMs, options.cancelTimeoutMs]) { + if ( + timeout !== undefined && + (!Number.isSafeInteger(timeout) || timeout <= 0 || timeout > 2_147_483_647) + ) { + throw new Error('ACP timeouts must be positive finite timer durations') + } + } + this.peer = new AcpJsonRpcPeer( + input, + output, + { + onRequest: (method, params, context) => this.handleRequest(method, params, context), + onNotification: (method, params) => this.handleNotification(method, params), + onDiagnostic: options.onDiagnostic, + onClose: options.onClose + }, + options.peer + ) + } + + subscribe(listener: (event: SessionNotification) => void): () => void { + this.listeners.add(listener) + return () => { + this.listeners.delete(listener) + } + } + + initialize(): Promise { + this.initialized ??= this.call( + 'initialize', + { + protocolVersion: ACP_PROTOCOL_VERSION, + clientCapabilities: { fs: { readTextFile: false, writeTextFile: false }, terminal: false }, + ...(this.options.clientInfo === undefined ? {} : { clientInfo: this.options.clientInfo }) + } satisfies InitializeRequest, + InitializeResponseSchema + ) + .then((response) => { + if (response.protocolVersion !== ACP_PROTOCOL_VERSION) { + const error = new AcpRpcError( + -32602, + `Unsupported ACP protocol version: ${response.protocolVersion}` + ) + this.peer.close(error) + throw error + } + return response + }) + .catch((error) => { + this.initialized = undefined + throw error + }) + return this.initialized + } + + async authenticate(methodId: string): Promise { + await this.initialize() + return this.call('authenticate', { methodId }, AuthenticateResponseSchema) + } + + start(options: AcpSessionStartOptions): Promise { + if (this.started) { + return Promise.resolve(this.started) + } + this.starting ??= this.initialize() + .then((initialized) => + setupAcpSession(initialized, options, (method, params, schema) => + this.call(method, params, schema) + ) + ) + .then((started) => { + this.started = started + return started + }) + .catch((error) => { + this.starting = undefined + throw error + }) + return this.starting + } + + prompt(prompt: PromptRequest['prompt']): Promise { + if (this.activePrompt) { + return Promise.reject(new Error('ACP prompt already in progress')) + } + const sessionId = this.sessionId() + const active: ActivePrompt = { + cancelling: false, + response: this.call('session/prompt', { sessionId, prompt }, PromptResponseSchema, { + timeoutMs: this.options.promptTimeoutMs ?? 30 * 60_000 + }) + } + this.activePrompt = active + active.response = active.response.finally(() => { + this.permissions.cancel(sessionId) + if (this.activePrompt === active) { + this.activePrompt = undefined + } + }) + return active.response + } + + cancel(): Promise { + const sessionId = this.sessionId() + const active = this.activePrompt + if (!active) { + return Promise.resolve() + } + active.cancelling = true + this.permissions.cancel(sessionId) + active.cancelPromise ??= this.cancelActive(sessionId, active) + return active.cancelPromise + } + + private async cancelActive(sessionId: string, active: ActivePrompt): Promise { + const timeoutMs = this.options.cancelTimeoutMs ?? 10_000 + let timer: ReturnType | undefined + try { + await Promise.race([ + this.peer.notify('session/cancel', { sessionId }).then(() => active.response), + new Promise((_resolve, reject) => { + timer = setTimeout(() => { + const error = new AcpRequestTimeoutError('session/cancel') + this.peer.close(error) + reject(error) + }, timeoutMs) + }) + ]) + } finally { + clearTimeout(timer) + } + } + + setMode(modeId: string): Promise { + return this.call( + 'session/set_mode', + { sessionId: this.sessionId(), modeId }, + SetSessionModeResponseSchema + ) + } + setModel(modelId: string): Promise { + return this.call( + 'session/set_model', + { sessionId: this.sessionId(), modelId }, + SetSessionModelResponseSchema + ) + } + setConfigOption( + configId: SetSessionConfigOptionRequest['configId'], + value: SetSessionConfigOptionRequest['value'] + ): Promise { + return this.call( + 'session/set_config_option', + { + configId, + value, + sessionId: this.sessionId(), + ...(typeof value === 'boolean' ? { type: 'boolean' as const } : {}) + } satisfies SetSessionConfigOptionRequest, + SetSessionConfigOptionResponseSchema + ) + } + + close(error?: Error): void { + this.peer.close(error) + this.listeners.clear() + } + + private sessionId(): string { + if (!this.started) { + throw new Error('ACP session has not started') + } + return this.started.sessionId + } + + private async call( + method: string, + params: unknown, + schema: z.ZodType, + options?: { timeoutMs?: number } + ): Promise { + const result = await this.peer.request(method, params, options).catch((error) => { + // A timed-out mutation may still be executing; this runtime cannot safely reuse the session. + if (error instanceof AcpRequestTimeoutError) { + this.peer.close(error) + } + throw error + }) + const parsed = schema.safeParse(result) + if (!parsed.success) { + throw new AcpRpcError(-32603, `Invalid ACP response: ${method}`) + } + return parsed.data + } + + private handleRequest( + method: string, + params: unknown, + context: AcpRequestContext + ): unknown | Promise { + if (method !== 'session/request_permission') { + return this.options.onRequest?.(method, params, context) + } + const parsed = RequestPermissionRequestSchema.safeParse(params) + if (!parsed.success) { + throw new AcpRpcError(-32602, 'Invalid ACP permission request') + } + if ( + !this.activePrompt || + this.activePrompt.cancelling || + parsed.data.sessionId !== this.started?.sessionId + ) { + return { outcome: { outcome: 'cancelled' } } + } + return this.permissions.handle(parsed.data, context, this.options.onPermission) + } + + private handleNotification(method: string, params: unknown): void { + if (method !== 'session/update') { + return + } + const parsed = SessionNotificationSchema.safeParse(params) + if (!parsed.success) { + this.options.onDiagnostic?.('Ignored invalid ACP session update') + return + } + for (const listener of this.listeners) { + try { + listener(parsed.data) + } catch (error) { + this.options.onDiagnostic?.(`ACP event listener failed: ${String(error)}`) + } + } + } +} diff --git a/src/main/acp/acp-session-setup.ts b/src/main/acp/acp-session-setup.ts new file mode 100644 index 000000000000..6c81f0fc78e5 --- /dev/null +++ b/src/main/acp/acp-session-setup.ts @@ -0,0 +1,75 @@ +import type { z } from 'zod' +import { AcpAuthRequiredError, AcpRpcError } from './acp-errors' +import { + NewSessionResponseSchema, + LoadSessionResponseSchema, + ResumeSessionResponseSchema, + AuthenticateResponseSchema, + type InitializeResponse, + type NewSessionRequest, + type NewSessionResponse, + type LoadSessionResponse, + type ResumeSessionResponse +} from './generated/protocol.gen' + +export type AcpSessionStarted = + | { kind: 'new'; sessionId: string; response: NewSessionResponse } + | { kind: 'load'; sessionId: string; response: LoadSessionResponse } + | { kind: 'resume'; sessionId: string; response: ResumeSessionResponse } +export type AcpSessionStartOptions = NewSessionRequest & { + sessionId?: string + resumePreference?: 'load' | 'resume' + authMethodId?: string +} +type Request = (method: string, params: unknown, schema: z.ZodType) => Promise + +export async function setupAcpSession( + initialized: InitializeResponse, + options: AcpSessionStartOptions, + request: Request +): Promise { + const setup = async (): Promise => { + const { sessionId, resumePreference, authMethodId: _auth, ...params } = options + if (sessionId === undefined) { + const response = await request('session/new', params, NewSessionResponseSchema) + return { kind: 'new', sessionId: response.sessionId, response } + } + const capabilities = initialized.agentCapabilities + const load = capabilities?.loadSession === true + const resume = capabilities?.sessionCapabilities?.resume != null + if (load && (resumePreference !== 'resume' || !resume)) { + return { + kind: 'load', + sessionId, + response: await request('session/load', { ...params, sessionId }, LoadSessionResponseSchema) + } + } + if (resume) { + return { + kind: 'resume', + sessionId, + response: await request( + 'session/resume', + { ...params, sessionId }, + ResumeSessionResponseSchema + ) + } + } + throw new AcpRpcError(-32601, 'ACP agent cannot load or resume this session') + } + try { + return await setup() + } catch (error) { + if (!(error instanceof AcpAuthRequiredError)) { + throw error + } + const method = initialized.authMethods?.find((method) => + options.authMethodId ? method.id === options.authMethodId : method.type !== 'terminal' + ) + if (!method || method.type === 'terminal') { + throw error + } + await request('authenticate', { methodId: method.id }, AuthenticateResponseSchema) + return setup() + } +} diff --git a/src/main/acp/acp-write-queue.ts b/src/main/acp/acp-write-queue.ts new file mode 100644 index 000000000000..7538fc04aa66 --- /dev/null +++ b/src/main/acp/acp-write-queue.ts @@ -0,0 +1,119 @@ +import type { Writable } from 'node:stream' +import { AcpConnectionClosedError } from './acp-errors' + +type Write = { + line: string + resolve: () => void + reject: (error: Error) => void + detachAbort?: () => void +} + +export class AcpWriteQueue { + private readonly queue: Write[] = [] + private bytes = 0 + private active?: Write + private terminalError?: Error + private detachDrain?: () => void + + constructor( + private readonly output: Writable, + private readonly maxBytes: number, + private readonly onFailure: (error: Error) => void + ) {} + + write(line: string, signal?: AbortSignal): Promise { + if (this.terminalError) { + return Promise.reject(this.terminalError) + } + if (signal?.aborted) { + return Promise.reject(signal.reason) + } + const bytes = Buffer.byteLength(line) + if (this.bytes + bytes > this.maxBytes) { + return Promise.reject(new Error('ACP write queue capacity exceeded')) + } + this.bytes += bytes + return new Promise((resolve, reject) => { + const write: Write = { line, resolve, reject } + const abort = (): void => { + const index = this.queue.indexOf(write) + if (index === -1) { + return + } + this.queue.splice(index, 1) + this.bytes -= bytes + write.detachAbort?.() + reject(signal?.reason) + } + signal?.addEventListener('abort', abort, { once: true }) + write.detachAbort = () => signal?.removeEventListener('abort', abort) + this.queue.push(write) + this.flush() + }) + } + + close(error: Error): void { + if (this.terminalError) { + return + } + this.terminalError = error + this.detachDrain?.() + this.active?.reject(error) + this.active = undefined + for (const write of this.queue.splice(0)) { + write.detachAbort?.() + write.reject(error) + } + this.bytes = 0 + } + + private flush(): void { + if (this.active || this.terminalError) { + return + } + const write = this.queue.shift() + if (!write) { + return + } + write.detachAbort?.() + this.active = write + if (this.output.destroyed || !this.output.writable) { + this.onFailure(new AcpConnectionClosedError('ACP output is not writable')) + return + } + let completed = false + let drained = false + let returned = false + const finish = (): void => { + if (!returned || !completed || !drained || this.terminalError) { + return + } + this.detachDrain?.() + this.active = undefined + this.bytes -= Buffer.byteLength(write.line) + write.resolve() + this.flush() + } + const onDrain = (): void => { + drained = true + finish() + } + this.output.once('drain', onDrain) + this.detachDrain = () => this.output.removeListener('drain', onDrain) + try { + const accepted = this.output.write(write.line, (error) => { + if (error) { + this.onFailure(error) + return + } + completed = true + finish() + }) + drained ||= accepted + returned = true + finish() + } catch (error) { + this.onFailure(error instanceof Error ? error : new Error(String(error))) + } + } +} diff --git a/src/main/acp/generated/client-capabilities.gen.ts b/src/main/acp/generated/client-capabilities.gen.ts new file mode 100644 index 000000000000..723ef2fddb15 --- /dev/null +++ b/src/main/acp/generated/client-capabilities.gen.ts @@ -0,0 +1,113 @@ +// Generated by config/scripts/acp/generate-protocol.mjs; do not edit. +// ACP schema-v1.21.0, legacy model API v0.11.6; SPDX-License-Identifier: Apache-2.0. +import { z } from 'zod' +import { + FileSystemCapabilitiesSchema, + ClientSessionCapabilitiesSchema, + PlanCapabilitiesSchema, + AuthCapabilitiesSchema, + ElicitationCapabilitiesSchema, + ClientNesCapabilitiesSchema, + PositionEncodingKindSchema, + ProtocolVersionSchema +} from './protocol-version.gen' +export const ClientCapabilitiesSchema = z.looseObject({ + fs: FileSystemCapabilitiesSchema.optional(), + terminal: z.boolean().optional(), + session: z.union([ClientSessionCapabilitiesSchema, z.null()]).optional(), + plan: z.union([PlanCapabilitiesSchema, z.null()]).optional(), + auth: AuthCapabilitiesSchema.optional(), + elicitation: z.union([ElicitationCapabilitiesSchema, z.null()]).optional(), + nes: z.union([ClientNesCapabilitiesSchema, z.null()]).optional(), + positionEncodings: z.array(PositionEncodingKindSchema).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type ClientCapabilities = z.infer + +export const ImplementationSchema = z.looseObject({ + name: z.string(), + title: z.union([z.string(), z.null()]).optional(), + version: z.string(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type Implementation = z.infer + +export const InitializeRequestSchema = z.looseObject({ + protocolVersion: ProtocolVersionSchema, + clientCapabilities: ClientCapabilitiesSchema.optional(), + clientInfo: z.union([ImplementationSchema, z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type InitializeRequest = z.infer + +export const PromptCapabilitiesSchema = z.looseObject({ + image: z.boolean().optional(), + audio: z.boolean().optional(), + embeddedContext: z.boolean().optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type PromptCapabilities = z.infer + +export const McpCapabilitiesSchema = z.looseObject({ + http: z.boolean().optional(), + sse: z.boolean().optional(), + acp: z.boolean().optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type McpCapabilities = z.infer + +export const SessionListCapabilitiesSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type SessionListCapabilities = z.infer + +export const SessionDeleteCapabilitiesSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type SessionDeleteCapabilities = z.infer + +export const SessionAdditionalDirectoriesCapabilitiesSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type SessionAdditionalDirectoriesCapabilities = z.infer< + typeof SessionAdditionalDirectoriesCapabilitiesSchema +> + +export const SessionForkCapabilitiesSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type SessionForkCapabilities = z.infer + +export const SessionResumeCapabilitiesSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type SessionResumeCapabilities = z.infer + +export const SessionCloseCapabilitiesSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type SessionCloseCapabilities = z.infer + +export const SessionCapabilitiesSchema = z.looseObject({ + list: z.union([SessionListCapabilitiesSchema, z.null()]).optional(), + delete: z.union([SessionDeleteCapabilitiesSchema, z.null()]).optional(), + additionalDirectories: z + .union([SessionAdditionalDirectoriesCapabilitiesSchema, z.null()]) + .optional(), + fork: z.union([SessionForkCapabilitiesSchema, z.null()]).optional(), + resume: z.union([SessionResumeCapabilitiesSchema, z.null()]).optional(), + close: z.union([SessionCloseCapabilitiesSchema, z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type SessionCapabilities = z.infer + +export const LogoutCapabilitiesSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type LogoutCapabilities = z.infer + +export const AgentAuthCapabilitiesSchema = z.looseObject({ + logout: z.union([LogoutCapabilitiesSchema, z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type AgentAuthCapabilities = z.infer diff --git a/src/main/acp/generated/diff.gen.ts b/src/main/acp/generated/diff.gen.ts new file mode 100644 index 000000000000..21ec2dbe11ba --- /dev/null +++ b/src/main/acp/generated/diff.gen.ts @@ -0,0 +1,126 @@ +// Generated by config/scripts/acp/generate-protocol.mjs; do not edit. +// ACP schema-v1.21.0, legacy model API v0.11.6; SPDX-License-Identifier: Apache-2.0. +import { z } from 'zod' +import { + ContentSchema, + ToolCallIdSchema, + ToolKindSchema, + ToolCallStatusSchema +} from './text-resource-contents.gen' +export const DiffSchema = z.looseObject({ + path: z.string(), + oldText: z.union([z.string(), z.null()]).optional(), + newText: z.string(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type Diff = z.infer + +export const TerminalIdSchema = z.string() +export type TerminalId = z.infer + +export const TerminalSchema = z.looseObject({ + terminalId: TerminalIdSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type Terminal = z.infer + +export const ToolCallContentSchema = z.union([ + z.intersection(z.looseObject({ type: z.literal('content') }), ContentSchema), + z.intersection(z.looseObject({ type: z.literal('diff') }), DiffSchema), + z.intersection(z.looseObject({ type: z.literal('terminal') }), TerminalSchema) +]) +export type ToolCallContent = z.infer + +export const ToolCallLocationSchema = z.looseObject({ + path: z.string(), + line: z.union([z.number().int().min(0), z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type ToolCallLocation = z.infer + +export const ToolCallSchema = z.looseObject({ + toolCallId: ToolCallIdSchema, + title: z.string(), + name: z.union([z.string(), z.null()]).optional(), + kind: ToolKindSchema.optional(), + status: ToolCallStatusSchema.optional(), + content: z.array(ToolCallContentSchema).optional(), + locations: z.array(ToolCallLocationSchema).optional(), + rawInput: z.unknown().optional(), + rawOutput: z.unknown().optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type ToolCall = z.infer + +export const ToolCallUpdateSchema = z.looseObject({ + toolCallId: ToolCallIdSchema, + kind: z.union([ToolKindSchema, z.null()]).optional(), + status: z.union([ToolCallStatusSchema, z.null()]).optional(), + title: z.union([z.string(), z.null()]).optional(), + name: z.union([z.string(), z.null()]).optional(), + content: z.union([z.array(ToolCallContentSchema), z.null()]).optional(), + locations: z.union([z.array(ToolCallLocationSchema), z.null()]).optional(), + rawInput: z.unknown().optional(), + rawOutput: z.unknown().optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type ToolCallUpdate = z.infer + +export const PlanEntryPrioritySchema = z.union([ + z.literal('high'), + z.literal('medium'), + z.literal('low') +]) +export type PlanEntryPriority = z.infer + +export const PlanEntryStatusSchema = z.union([ + z.literal('pending'), + z.literal('in_progress'), + z.literal('completed') +]) +export type PlanEntryStatus = z.infer + +export const PlanEntrySchema = z.looseObject({ + content: z.string(), + priority: PlanEntryPrioritySchema, + status: PlanEntryStatusSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type PlanEntry = z.infer + +export const PlanSchema = z.looseObject({ + entries: z.array(PlanEntrySchema), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type Plan = z.infer + +export const PlanIdSchema = z.string() +export type PlanId = z.infer + +export const PlanItemsSchema = z.looseObject({ + planId: PlanIdSchema, + entries: z.array(PlanEntrySchema), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type PlanItems = z.infer + +export const PlanFileSchema = z.looseObject({ + planId: PlanIdSchema, + uri: z.string(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type PlanFile = z.infer + +export const PlanMarkdownSchema = z.looseObject({ + planId: PlanIdSchema, + content: z.string(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type PlanMarkdown = z.infer + +export const PlanUpdateContentSchema = z.union([ + z.intersection(z.looseObject({ type: z.literal('items') }), PlanItemsSchema), + z.intersection(z.looseObject({ type: z.literal('file') }), PlanFileSchema), + z.intersection(z.looseObject({ type: z.literal('markdown') }), PlanMarkdownSchema) +]) +export type PlanUpdateContent = z.infer diff --git a/src/main/acp/generated/license.gen.ts b/src/main/acp/generated/license.gen.ts new file mode 100644 index 000000000000..51e227cb3ce1 --- /dev/null +++ b/src/main/acp/generated/license.gen.ts @@ -0,0 +1,197 @@ +// Generated by config/scripts/acp/generate-protocol.mjs; do not edit. +// ACP schema-v1.21.0, legacy model API v0.11.6; SPDX-License-Identifier: Apache-2.0. +/* +Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + Copyright 2025 Zed Industries, Inc. and contributors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ +export const ACP_SCHEMA_RELEASE = 'schema-v1.21.0' +export const ACP_LEGACY_MODEL_SCHEMA_RELEASE = 'v0.11.6' +export const ACP_PROTOCOL_VERSION = 1 diff --git a/src/main/acp/generated/mcp-server-acp.gen.ts b/src/main/acp/generated/mcp-server-acp.gen.ts new file mode 100644 index 000000000000..105054e2a883 --- /dev/null +++ b/src/main/acp/generated/mcp-server-acp.gen.ts @@ -0,0 +1,116 @@ +// Generated by config/scripts/acp/generate-protocol.mjs; do not edit. +// ACP schema-v1.21.0, legacy model API v0.11.6; SPDX-License-Identifier: Apache-2.0. +import { z } from 'zod' +import { + McpServerAcpIdSchema, + McpServerHttpSchema, + McpServerSseSchema +} from './nes-context-capabilities.gen' +export const McpServerAcpSchema = z.looseObject({ + name: z.string(), + serverId: McpServerAcpIdSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type McpServerAcp = z.infer + +export const EnvVariableSchema = z.looseObject({ + name: z.string(), + value: z.string(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type EnvVariable = z.infer + +export const McpServerStdioSchema = z.looseObject({ + name: z.string(), + command: z.string(), + args: z.array(z.string()), + env: z.array(EnvVariableSchema), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type McpServerStdio = z.infer + +export const McpServerSchema = z.union([ + z.intersection(z.looseObject({ type: z.literal('http') }), McpServerHttpSchema), + z.intersection(z.looseObject({ type: z.literal('sse') }), McpServerSseSchema), + z.intersection(z.looseObject({ type: z.literal('acp') }), McpServerAcpSchema), + McpServerStdioSchema +]) +export type McpServer = z.infer + +export const NewSessionRequestSchema = z.looseObject({ + cwd: z.string(), + additionalDirectories: z.array(z.string()).optional(), + mcpServers: z.array(McpServerSchema), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type NewSessionRequest = z.infer + +export const SessionIdSchema = z.string() +export type SessionId = z.infer + +export const SessionModeIdSchema = z.string() +export type SessionModeId = z.infer + +export const SessionModeSchema = z.looseObject({ + id: SessionModeIdSchema, + name: z.string(), + description: z.union([z.string(), z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type SessionMode = z.infer + +export const SessionModeStateSchema = z.looseObject({ + currentModeId: SessionModeIdSchema, + availableModes: z.array(SessionModeSchema), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type SessionModeState = z.infer + +export const SessionConfigIdSchema = z.string() +export type SessionConfigId = z.infer + +export const SessionConfigOptionCategorySchema = z.union([ + z.literal('mode'), + z.literal('model'), + z.literal('model_config'), + z.literal('thought_level'), + z.string() +]) +export type SessionConfigOptionCategory = z.infer + +export const SessionConfigValueIdSchema = z.string() +export type SessionConfigValueId = z.infer + +export const SessionConfigSelectOptionSchema = z.looseObject({ + value: SessionConfigValueIdSchema, + name: z.string(), + description: z.union([z.string(), z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type SessionConfigSelectOption = z.infer + +export const SessionConfigGroupIdSchema = z.string() +export type SessionConfigGroupId = z.infer + +export const SessionConfigSelectGroupSchema = z.looseObject({ + group: SessionConfigGroupIdSchema, + name: z.string(), + options: z.array(SessionConfigSelectOptionSchema), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type SessionConfigSelectGroup = z.infer + +export const SessionConfigSelectOptionsSchema = z.union([ + z.array(SessionConfigSelectOptionSchema), + z.array(SessionConfigSelectGroupSchema) +]) +export type SessionConfigSelectOptions = z.infer + +export const SessionConfigSelectSchema = z.looseObject({ + currentValue: SessionConfigValueIdSchema, + options: SessionConfigSelectOptionsSchema +}) +export type SessionConfigSelect = z.infer + +export const SessionConfigBooleanSchema = z.looseObject({ currentValue: z.boolean() }) +export type SessionConfigBoolean = z.infer diff --git a/src/main/acp/generated/nes-context-capabilities.gen.ts b/src/main/acp/generated/nes-context-capabilities.gen.ts new file mode 100644 index 000000000000..3f193cd78045 --- /dev/null +++ b/src/main/acp/generated/nes-context-capabilities.gen.ts @@ -0,0 +1,124 @@ +// Generated by config/scripts/acp/generate-protocol.mjs; do not edit. +// ACP schema-v1.21.0, legacy model API v0.11.6; SPDX-License-Identifier: Apache-2.0. +import { z } from 'zod' +import { + NesRecentFilesCapabilitiesSchema, + NesRelatedSnippetsCapabilitiesSchema, + NesEditHistoryCapabilitiesSchema, + NesUserActionsCapabilitiesSchema, + NesOpenFilesCapabilitiesSchema, + NesDiagnosticsCapabilitiesSchema, + NesEventCapabilitiesSchema, + ProvidersCapabilitiesSchema +} from './providers-capabilities.gen' +import { + PromptCapabilitiesSchema, + McpCapabilitiesSchema, + SessionCapabilitiesSchema, + AgentAuthCapabilitiesSchema, + ImplementationSchema +} from './client-capabilities.gen' +import { PositionEncodingKindSchema, ProtocolVersionSchema } from './protocol-version.gen' +export const NesContextCapabilitiesSchema = z.looseObject({ + recentFiles: z.union([NesRecentFilesCapabilitiesSchema, z.null()]).optional(), + relatedSnippets: z.union([NesRelatedSnippetsCapabilitiesSchema, z.null()]).optional(), + editHistory: z.union([NesEditHistoryCapabilitiesSchema, z.null()]).optional(), + userActions: z.union([NesUserActionsCapabilitiesSchema, z.null()]).optional(), + openFiles: z.union([NesOpenFilesCapabilitiesSchema, z.null()]).optional(), + diagnostics: z.union([NesDiagnosticsCapabilitiesSchema, z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type NesContextCapabilities = z.infer + +export const NesCapabilitiesSchema = z.looseObject({ + events: z.union([NesEventCapabilitiesSchema, z.null()]).optional(), + context: z.union([NesContextCapabilitiesSchema, z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type NesCapabilities = z.infer + +export const AgentCapabilitiesSchema = z.looseObject({ + loadSession: z.boolean().optional(), + promptCapabilities: PromptCapabilitiesSchema.optional(), + mcpCapabilities: McpCapabilitiesSchema.optional(), + sessionCapabilities: SessionCapabilitiesSchema.optional(), + auth: AgentAuthCapabilitiesSchema.optional(), + providers: z.union([ProvidersCapabilitiesSchema, z.null()]).optional(), + nes: z.union([NesCapabilitiesSchema, z.null()]).optional(), + positionEncoding: z.union([PositionEncodingKindSchema, z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type AgentCapabilities = z.infer + +export const AuthMethodIdSchema = z.string() +export type AuthMethodId = z.infer + +export const AuthMethodTerminalSchema = z.looseObject({ + id: AuthMethodIdSchema, + name: z.string(), + description: z.union([z.string(), z.null()]).optional(), + args: z.array(z.string()).optional(), + env: z.looseObject({}).catchall(z.string()).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type AuthMethodTerminal = z.infer + +export const AuthMethodAgentSchema = z.looseObject({ + id: AuthMethodIdSchema, + name: z.string(), + description: z.union([z.string(), z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type AuthMethodAgent = z.infer + +export const AuthMethodSchema = z.union([ + z.intersection(z.looseObject({ type: z.literal('terminal') }), AuthMethodTerminalSchema), + AuthMethodAgentSchema +]) +export type AuthMethod = z.infer + +export const InitializeResponseSchema = z.looseObject({ + protocolVersion: ProtocolVersionSchema, + agentCapabilities: AgentCapabilitiesSchema.optional(), + authMethods: z.array(AuthMethodSchema).optional(), + agentInfo: z.union([ImplementationSchema, z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type InitializeResponse = z.infer + +export const AuthenticateRequestSchema = z.looseObject({ + methodId: AuthMethodIdSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type AuthenticateRequest = z.infer + +export const AuthenticateResponseSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type AuthenticateResponse = z.infer + +export const HttpHeaderSchema = z.looseObject({ + name: z.string(), + value: z.string(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type HttpHeader = z.infer + +export const McpServerHttpSchema = z.looseObject({ + name: z.string(), + url: z.string(), + headers: z.array(HttpHeaderSchema), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type McpServerHttp = z.infer + +export const McpServerSseSchema = z.looseObject({ + name: z.string(), + url: z.string(), + headers: z.array(HttpHeaderSchema), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type McpServerSse = z.infer + +export const McpServerAcpIdSchema = z.string() +export type McpServerAcpId = z.infer diff --git a/src/main/acp/generated/plan-update.gen.ts b/src/main/acp/generated/plan-update.gen.ts new file mode 100644 index 000000000000..eda1e7474274 --- /dev/null +++ b/src/main/acp/generated/plan-update.gen.ts @@ -0,0 +1,103 @@ +// Generated by config/scripts/acp/generate-protocol.mjs; do not edit. +// ACP schema-v1.21.0, legacy model API v0.11.6; SPDX-License-Identifier: Apache-2.0. +import { z } from 'zod' +import { PlanUpdateContentSchema, PlanIdSchema } from './diff.gen' +import { SessionModeIdSchema } from './mcp-server-acp.gen' +import { SessionConfigOptionSchema } from './session-config-option.gen' +import { ContentBlockSchema } from './text-resource-contents.gen' +export const PlanUpdateSchema = z.looseObject({ + plan: PlanUpdateContentSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type PlanUpdate = z.infer + +export const PlanRemovedSchema = z.looseObject({ + planId: PlanIdSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type PlanRemoved = z.infer + +export const UnstructuredCommandInputSchema = z.looseObject({ + hint: z.string(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type UnstructuredCommandInput = z.infer + +export const AvailableCommandInputSchema = z.union([UnstructuredCommandInputSchema]) +export type AvailableCommandInput = z.infer + +export const AvailableCommandSchema = z.looseObject({ + name: z.string(), + description: z.string(), + input: z.union([AvailableCommandInputSchema, z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type AvailableCommand = z.infer + +export const AvailableCommandsUpdateSchema = z.looseObject({ + availableCommands: z.array(AvailableCommandSchema), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type AvailableCommandsUpdate = z.infer + +export const CurrentModeUpdateSchema = z.looseObject({ + currentModeId: SessionModeIdSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type CurrentModeUpdate = z.infer + +export const ConfigOptionUpdateSchema = z.looseObject({ + configOptions: z.array(SessionConfigOptionSchema), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type ConfigOptionUpdate = z.infer + +export const SessionInfoUpdateSchema = z.looseObject({ + title: z.union([z.string(), z.null()]).optional(), + updatedAt: z.union([z.string(), z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type SessionInfoUpdate = z.infer + +export const CostSchema = z.looseObject({ + amount: z.number(), + currency: z.string(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type Cost = z.infer + +export const UsageUpdateSchema = z.looseObject({ + used: z.number().int().min(0), + size: z.number().int().min(0), + cost: z.union([CostSchema, z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type UsageUpdate = z.infer + +export const CompactionIdSchema = z.string() +export type CompactionId = z.infer + +export const CompactionStatusSchema = z.union([ + z.literal('in_progress'), + z.literal('completed'), + z.literal('failed'), + z.literal('cancelled'), + z.string() +]) +export type CompactionStatus = z.infer + +export const CompactionUpdateSchema = z.looseObject({ + compactionId: CompactionIdSchema, + status: CompactionStatusSchema, + summary: z.union([z.array(ContentBlockSchema), z.null()]).optional(), + error: z.union([z.string(), z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type CompactionUpdate = z.infer + +export const CompactionSummaryChunkSchema = z.looseObject({ + compactionId: CompactionIdSchema, + content: ContentBlockSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type CompactionSummaryChunk = z.infer diff --git a/src/main/acp/generated/protocol-version.gen.ts b/src/main/acp/generated/protocol-version.gen.ts new file mode 100644 index 000000000000..865debf5666e --- /dev/null +++ b/src/main/acp/generated/protocol-version.gen.ts @@ -0,0 +1,93 @@ +// Generated by config/scripts/acp/generate-protocol.mjs; do not edit. +// ACP schema-v1.21.0, legacy model API v0.11.6; SPDX-License-Identifier: Apache-2.0. +import { z } from 'zod' +export const ProtocolVersionSchema = z.number().int().min(0).max(65535) +export type ProtocolVersion = z.infer + +export const FileSystemCapabilitiesSchema = z.looseObject({ + readTextFile: z.boolean().optional(), + writeTextFile: z.boolean().optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type FileSystemCapabilities = z.infer + +export const CompactionCapabilitiesSchema = z.looseObject({}) +export type CompactionCapabilities = z.infer + +export const BooleanConfigOptionCapabilitiesSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type BooleanConfigOptionCapabilities = z.infer + +export const SessionConfigOptionsCapabilitiesSchema = z.looseObject({ + boolean: z.union([BooleanConfigOptionCapabilitiesSchema, z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type SessionConfigOptionsCapabilities = z.infer< + typeof SessionConfigOptionsCapabilitiesSchema +> + +export const ClientSessionCapabilitiesSchema = z.looseObject({ + compaction: z.union([CompactionCapabilitiesSchema, z.null()]).optional(), + configOptions: z.union([SessionConfigOptionsCapabilitiesSchema, z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type ClientSessionCapabilities = z.infer + +export const PlanCapabilitiesSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type PlanCapabilities = z.infer + +export const AuthCapabilitiesSchema = z.looseObject({ + terminal: z.boolean().optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type AuthCapabilities = z.infer + +export const ElicitationFormCapabilitiesSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type ElicitationFormCapabilities = z.infer + +export const ElicitationUrlCapabilitiesSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type ElicitationUrlCapabilities = z.infer + +export const ElicitationCapabilitiesSchema = z.looseObject({ + form: z.union([ElicitationFormCapabilitiesSchema, z.null()]).optional(), + url: z.union([ElicitationUrlCapabilitiesSchema, z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type ElicitationCapabilities = z.infer + +export const NesJumpCapabilitiesSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type NesJumpCapabilities = z.infer + +export const NesRenameCapabilitiesSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type NesRenameCapabilities = z.infer + +export const NesSearchAndReplaceCapabilitiesSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type NesSearchAndReplaceCapabilities = z.infer + +export const ClientNesCapabilitiesSchema = z.looseObject({ + jump: z.union([NesJumpCapabilitiesSchema, z.null()]).optional(), + rename: z.union([NesRenameCapabilitiesSchema, z.null()]).optional(), + searchAndReplace: z.union([NesSearchAndReplaceCapabilitiesSchema, z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type ClientNesCapabilities = z.infer + +export const PositionEncodingKindSchema = z.union([ + z.literal('utf-16'), + z.literal('utf-32'), + z.literal('utf-8') +]) +export type PositionEncodingKind = z.infer diff --git a/src/main/acp/generated/protocol.gen.ts b/src/main/acp/generated/protocol.gen.ts new file mode 100644 index 000000000000..1c1cd14183c8 --- /dev/null +++ b/src/main/acp/generated/protocol.gen.ts @@ -0,0 +1,14 @@ +// Generated by config/scripts/acp/generate-protocol.mjs; do not edit. +// ACP schema-v1.21.0, legacy model API v0.11.6; SPDX-License-Identifier: Apache-2.0. +export * from './protocol-version.gen' +export * from './client-capabilities.gen' +export * from './providers-capabilities.gen' +export * from './nes-context-capabilities.gen' +export * from './mcp-server-acp.gen' +export * from './session-config-option.gen' +export * from './text-resource-contents.gen' +export * from './diff.gen' +export * from './plan-update.gen' +export * from './session-update.gen' +export * from './set-session-model-request.gen' +export * from './wait-for-terminal-exit-request.gen' diff --git a/src/main/acp/generated/providers-capabilities.gen.ts b/src/main/acp/generated/providers-capabilities.gen.ts new file mode 100644 index 000000000000..c9864207ed26 --- /dev/null +++ b/src/main/acp/generated/providers-capabilities.gen.ts @@ -0,0 +1,87 @@ +// Generated by config/scripts/acp/generate-protocol.mjs; do not edit. +// ACP schema-v1.21.0, legacy model API v0.11.6; SPDX-License-Identifier: Apache-2.0. +import { z } from 'zod' +export const ProvidersCapabilitiesSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type ProvidersCapabilities = z.infer + +export const NesDocumentDidOpenCapabilitiesSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type NesDocumentDidOpenCapabilities = z.infer + +export const TextDocumentSyncKindSchema = z.union([z.literal('full'), z.literal('incremental')]) +export type TextDocumentSyncKind = z.infer + +export const NesDocumentDidChangeCapabilitiesSchema = z.looseObject({ + syncKind: TextDocumentSyncKindSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type NesDocumentDidChangeCapabilities = z.infer< + typeof NesDocumentDidChangeCapabilitiesSchema +> + +export const NesDocumentDidCloseCapabilitiesSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type NesDocumentDidCloseCapabilities = z.infer + +export const NesDocumentDidSaveCapabilitiesSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type NesDocumentDidSaveCapabilities = z.infer + +export const NesDocumentDidFocusCapabilitiesSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type NesDocumentDidFocusCapabilities = z.infer + +export const NesDocumentEventCapabilitiesSchema = z.looseObject({ + didOpen: z.union([NesDocumentDidOpenCapabilitiesSchema, z.null()]).optional(), + didChange: z.union([NesDocumentDidChangeCapabilitiesSchema, z.null()]).optional(), + didClose: z.union([NesDocumentDidCloseCapabilitiesSchema, z.null()]).optional(), + didSave: z.union([NesDocumentDidSaveCapabilitiesSchema, z.null()]).optional(), + didFocus: z.union([NesDocumentDidFocusCapabilitiesSchema, z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type NesDocumentEventCapabilities = z.infer + +export const NesEventCapabilitiesSchema = z.looseObject({ + document: z.union([NesDocumentEventCapabilitiesSchema, z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type NesEventCapabilities = z.infer + +export const NesRecentFilesCapabilitiesSchema = z.looseObject({ + maxCount: z.union([z.number().int().min(0), z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type NesRecentFilesCapabilities = z.infer + +export const NesRelatedSnippetsCapabilitiesSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type NesRelatedSnippetsCapabilities = z.infer + +export const NesEditHistoryCapabilitiesSchema = z.looseObject({ + maxCount: z.union([z.number().int().min(0), z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type NesEditHistoryCapabilities = z.infer + +export const NesUserActionsCapabilitiesSchema = z.looseObject({ + maxCount: z.union([z.number().int().min(0), z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type NesUserActionsCapabilities = z.infer + +export const NesOpenFilesCapabilitiesSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type NesOpenFilesCapabilities = z.infer + +export const NesDiagnosticsCapabilitiesSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type NesDiagnosticsCapabilities = z.infer diff --git a/src/main/acp/generated/session-config-option.gen.ts b/src/main/acp/generated/session-config-option.gen.ts new file mode 100644 index 000000000000..3d33a869a6fc --- /dev/null +++ b/src/main/acp/generated/session-config-option.gen.ts @@ -0,0 +1,113 @@ +// Generated by config/scripts/acp/generate-protocol.mjs; do not edit. +// ACP schema-v1.21.0, legacy model API v0.11.6; SPDX-License-Identifier: Apache-2.0. +import { z } from 'zod' +import { + SessionConfigIdSchema, + SessionConfigOptionCategorySchema, + SessionConfigSelectSchema, + SessionConfigBooleanSchema, + SessionIdSchema, + SessionModeStateSchema, + McpServerSchema +} from './mcp-server-acp.gen' +export const SessionConfigOptionSchema = z.intersection( + z.looseObject({ + id: SessionConfigIdSchema, + name: z.string(), + description: z.union([z.string(), z.null()]).optional(), + category: z.union([SessionConfigOptionCategorySchema, z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() + }), + z.union([ + z.intersection(z.looseObject({ type: z.literal('select') }), SessionConfigSelectSchema), + z.intersection(z.looseObject({ type: z.literal('boolean') }), SessionConfigBooleanSchema) + ]) +) +export type SessionConfigOption = z.infer + +export const NewSessionResponseSchema = z.looseObject({ + sessionId: SessionIdSchema, + modes: z.union([SessionModeStateSchema, z.null()]).optional(), + configOptions: z.union([z.array(SessionConfigOptionSchema), z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type NewSessionResponse = z.infer + +export const LoadSessionRequestSchema = z.looseObject({ + mcpServers: z.array(McpServerSchema), + cwd: z.string(), + additionalDirectories: z.array(z.string()).optional(), + sessionId: SessionIdSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type LoadSessionRequest = z.infer + +export const LoadSessionResponseSchema = z.looseObject({ + modes: z.union([SessionModeStateSchema, z.null()]).optional(), + configOptions: z.union([z.array(SessionConfigOptionSchema), z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type LoadSessionResponse = z.infer + +export const ResumeSessionRequestSchema = z.looseObject({ + sessionId: SessionIdSchema, + cwd: z.string(), + additionalDirectories: z.array(z.string()).optional(), + mcpServers: z.array(McpServerSchema).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type ResumeSessionRequest = z.infer + +export const ResumeSessionResponseSchema = z.looseObject({ + modes: z.union([SessionModeStateSchema, z.null()]).optional(), + configOptions: z.union([z.array(SessionConfigOptionSchema), z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type ResumeSessionResponse = z.infer + +export const RoleSchema = z.union([z.literal('assistant'), z.literal('user')]) +export type Role = z.infer + +export const AnnotationsSchema = z.looseObject({ + audience: z.union([z.array(RoleSchema), z.null()]).optional(), + lastModified: z.union([z.string(), z.null()]).optional(), + priority: z.union([z.number(), z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type Annotations = z.infer + +export const TextContentSchema = z.looseObject({ + annotations: z.union([AnnotationsSchema, z.null()]).optional(), + text: z.string(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type TextContent = z.infer + +export const ImageContentSchema = z.looseObject({ + annotations: z.union([AnnotationsSchema, z.null()]).optional(), + data: z.string(), + mimeType: z.string(), + uri: z.union([z.string(), z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type ImageContent = z.infer + +export const AudioContentSchema = z.looseObject({ + annotations: z.union([AnnotationsSchema, z.null()]).optional(), + data: z.string(), + mimeType: z.string(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type AudioContent = z.infer + +export const ResourceLinkSchema = z.looseObject({ + annotations: z.union([AnnotationsSchema, z.null()]).optional(), + description: z.union([z.string(), z.null()]).optional(), + mimeType: z.union([z.string(), z.null()]).optional(), + name: z.string(), + size: z.union([z.number().int(), z.null()]).optional(), + title: z.union([z.string(), z.null()]).optional(), + uri: z.string(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type ResourceLink = z.infer diff --git a/src/main/acp/generated/session-update.gen.ts b/src/main/acp/generated/session-update.gen.ts new file mode 100644 index 000000000000..7b67951065d7 --- /dev/null +++ b/src/main/acp/generated/session-update.gen.ts @@ -0,0 +1,132 @@ +// Generated by config/scripts/acp/generate-protocol.mjs; do not edit. +// ACP schema-v1.21.0, legacy model API v0.11.6; SPDX-License-Identifier: Apache-2.0. +import { z } from 'zod' +import { ContentChunkSchema } from './text-resource-contents.gen' +import { ToolCallSchema, ToolCallUpdateSchema, PlanSchema } from './diff.gen' +import { + PlanUpdateSchema, + PlanRemovedSchema, + AvailableCommandsUpdateSchema, + CurrentModeUpdateSchema, + ConfigOptionUpdateSchema, + SessionInfoUpdateSchema, + UsageUpdateSchema, + CompactionUpdateSchema, + CompactionSummaryChunkSchema +} from './plan-update.gen' +import { SessionIdSchema, SessionModeIdSchema } from './mcp-server-acp.gen' +export const SessionUpdateSchema = z.union([ + z.intersection( + z.looseObject({ sessionUpdate: z.literal('user_message_chunk') }), + ContentChunkSchema + ), + z.intersection( + z.looseObject({ sessionUpdate: z.literal('agent_message_chunk') }), + ContentChunkSchema + ), + z.intersection( + z.looseObject({ sessionUpdate: z.literal('agent_thought_chunk') }), + ContentChunkSchema + ), + z.intersection(z.looseObject({ sessionUpdate: z.literal('tool_call') }), ToolCallSchema), + z.intersection( + z.looseObject({ sessionUpdate: z.literal('tool_call_update') }), + ToolCallUpdateSchema + ), + z.intersection(z.looseObject({ sessionUpdate: z.literal('plan') }), PlanSchema), + z.intersection(z.looseObject({ sessionUpdate: z.literal('plan_update') }), PlanUpdateSchema), + z.intersection(z.looseObject({ sessionUpdate: z.literal('plan_removed') }), PlanRemovedSchema), + z.intersection( + z.looseObject({ sessionUpdate: z.literal('available_commands_update') }), + AvailableCommandsUpdateSchema + ), + z.intersection( + z.looseObject({ sessionUpdate: z.literal('current_mode_update') }), + CurrentModeUpdateSchema + ), + z.intersection( + z.looseObject({ sessionUpdate: z.literal('config_option_update') }), + ConfigOptionUpdateSchema + ), + z.intersection( + z.looseObject({ sessionUpdate: z.literal('session_info_update') }), + SessionInfoUpdateSchema + ), + z.intersection(z.looseObject({ sessionUpdate: z.literal('usage_update') }), UsageUpdateSchema), + z.intersection( + z.looseObject({ sessionUpdate: z.literal('compaction_update') }), + CompactionUpdateSchema + ), + z.intersection( + z.looseObject({ sessionUpdate: z.literal('compaction_summary_chunk') }), + CompactionSummaryChunkSchema + ) +]) +export type SessionUpdate = z.infer + +export const SessionNotificationSchema = z.looseObject({ + sessionId: SessionIdSchema, + update: SessionUpdateSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type SessionNotification = z.infer + +export const PermissionOptionIdSchema = z.string() +export type PermissionOptionId = z.infer + +export const PermissionOptionKindSchema = z.union([ + z.literal('allow_once'), + z.literal('allow_always'), + z.literal('reject_once'), + z.literal('reject_always') +]) +export type PermissionOptionKind = z.infer + +export const PermissionOptionSchema = z.looseObject({ + optionId: PermissionOptionIdSchema, + name: z.string(), + kind: PermissionOptionKindSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type PermissionOption = z.infer + +export const RequestPermissionRequestSchema = z.looseObject({ + sessionId: SessionIdSchema, + toolCall: ToolCallUpdateSchema, + options: z.array(PermissionOptionSchema), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type RequestPermissionRequest = z.infer + +export const SelectedPermissionOutcomeSchema = z.looseObject({ + optionId: PermissionOptionIdSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type SelectedPermissionOutcome = z.infer + +export const RequestPermissionOutcomeSchema = z.union([ + z.looseObject({ outcome: z.literal('cancelled') }), + z.intersection(z.looseObject({ outcome: z.literal('selected') }), SelectedPermissionOutcomeSchema) +]) +export type RequestPermissionOutcome = z.infer + +export const RequestPermissionResponseSchema = z.looseObject({ + outcome: RequestPermissionOutcomeSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type RequestPermissionResponse = z.infer + +export const SetSessionModeRequestSchema = z.looseObject({ + sessionId: SessionIdSchema, + modeId: SessionModeIdSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type SetSessionModeRequest = z.infer + +export const SetSessionModeResponseSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type SetSessionModeResponse = z.infer + +export const ModelIdSchema = z.string() +export type ModelId = z.infer diff --git a/src/main/acp/generated/set-session-model-request.gen.ts b/src/main/acp/generated/set-session-model-request.gen.ts new file mode 100644 index 000000000000..c25c1f98808e --- /dev/null +++ b/src/main/acp/generated/set-session-model-request.gen.ts @@ -0,0 +1,121 @@ +// Generated by config/scripts/acp/generate-protocol.mjs; do not edit. +// ACP schema-v1.21.0, legacy model API v0.11.6; SPDX-License-Identifier: Apache-2.0. +import { z } from 'zod' +import { ModelIdSchema } from './session-update.gen' +import { + SessionIdSchema, + SessionConfigIdSchema, + SessionConfigValueIdSchema, + EnvVariableSchema +} from './mcp-server-acp.gen' +import { SessionConfigOptionSchema } from './session-config-option.gen' +import { TerminalIdSchema } from './diff.gen' +export const SetSessionModelRequestSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional(), + modelId: ModelIdSchema, + sessionId: SessionIdSchema +}) +export type SetSessionModelRequest = z.infer + +export const SetSessionModelResponseSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type SetSessionModelResponse = z.infer + +export const SetSessionConfigOptionRequestSchema = z.intersection( + z.looseObject({ + sessionId: SessionIdSchema, + configId: SessionConfigIdSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() + }), + z.union([ + z.looseObject({ value: z.boolean(), type: z.literal('boolean') }), + z.looseObject({ value: SessionConfigValueIdSchema }) + ]) +) +export type SetSessionConfigOptionRequest = z.infer + +export const SetSessionConfigOptionResponseSchema = z.looseObject({ + configOptions: z.array(SessionConfigOptionSchema), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type SetSessionConfigOptionResponse = z.infer + +export const ReadTextFileRequestSchema = z.looseObject({ + sessionId: SessionIdSchema, + path: z.string(), + line: z.union([z.number().int().min(0), z.null()]).optional(), + limit: z.union([z.number().int().min(0), z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type ReadTextFileRequest = z.infer + +export const ReadTextFileResponseSchema = z.looseObject({ + content: z.string(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type ReadTextFileResponse = z.infer + +export const WriteTextFileRequestSchema = z.looseObject({ + sessionId: SessionIdSchema, + path: z.string(), + content: z.string(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type WriteTextFileRequest = z.infer + +export const WriteTextFileResponseSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type WriteTextFileResponse = z.infer + +export const CreateTerminalRequestSchema = z.looseObject({ + sessionId: SessionIdSchema, + command: z.string(), + args: z.array(z.string()).optional(), + env: z.array(EnvVariableSchema).optional(), + cwd: z.union([z.string(), z.null()]).optional(), + outputByteLimit: z.union([z.number().int().min(0), z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type CreateTerminalRequest = z.infer + +export const CreateTerminalResponseSchema = z.looseObject({ + terminalId: TerminalIdSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type CreateTerminalResponse = z.infer + +export const TerminalOutputRequestSchema = z.looseObject({ + sessionId: SessionIdSchema, + terminalId: TerminalIdSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type TerminalOutputRequest = z.infer + +export const TerminalExitStatusSchema = z.looseObject({ + exitCode: z.union([z.number().int().min(0), z.null()]).optional(), + signal: z.union([z.string(), z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type TerminalExitStatus = z.infer + +export const TerminalOutputResponseSchema = z.looseObject({ + output: z.string(), + truncated: z.boolean(), + exitStatus: z.union([TerminalExitStatusSchema, z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type TerminalOutputResponse = z.infer + +export const ReleaseTerminalRequestSchema = z.looseObject({ + sessionId: SessionIdSchema, + terminalId: TerminalIdSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type ReleaseTerminalRequest = z.infer + +export const ReleaseTerminalResponseSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type ReleaseTerminalResponse = z.infer diff --git a/src/main/acp/generated/text-resource-contents.gen.ts b/src/main/acp/generated/text-resource-contents.gen.ts new file mode 100644 index 000000000000..c69e01fb6fb1 --- /dev/null +++ b/src/main/acp/generated/text-resource-contents.gen.ts @@ -0,0 +1,129 @@ +// Generated by config/scripts/acp/generate-protocol.mjs; do not edit. +// ACP schema-v1.21.0, legacy model API v0.11.6; SPDX-License-Identifier: Apache-2.0. +import { z } from 'zod' +import { + AnnotationsSchema, + TextContentSchema, + ImageContentSchema, + AudioContentSchema, + ResourceLinkSchema +} from './session-config-option.gen' +import { SessionIdSchema } from './mcp-server-acp.gen' +export const TextResourceContentsSchema = z.looseObject({ + mimeType: z.union([z.string(), z.null()]).optional(), + text: z.string(), + uri: z.string(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type TextResourceContents = z.infer + +export const BlobResourceContentsSchema = z.looseObject({ + blob: z.string(), + mimeType: z.union([z.string(), z.null()]).optional(), + uri: z.string(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type BlobResourceContents = z.infer + +export const EmbeddedResourceResourceSchema = z.union([ + TextResourceContentsSchema, + BlobResourceContentsSchema +]) +export type EmbeddedResourceResource = z.infer + +export const EmbeddedResourceSchema = z.looseObject({ + annotations: z.union([AnnotationsSchema, z.null()]).optional(), + resource: EmbeddedResourceResourceSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type EmbeddedResource = z.infer + +export const ContentBlockSchema = z.union([ + z.intersection(z.looseObject({ type: z.literal('text') }), TextContentSchema), + z.intersection(z.looseObject({ type: z.literal('image') }), ImageContentSchema), + z.intersection(z.looseObject({ type: z.literal('audio') }), AudioContentSchema), + z.intersection(z.looseObject({ type: z.literal('resource_link') }), ResourceLinkSchema), + z.intersection(z.looseObject({ type: z.literal('resource') }), EmbeddedResourceSchema) +]) +export type ContentBlock = z.infer + +export const PromptRequestSchema = z.looseObject({ + sessionId: SessionIdSchema, + prompt: z.array(ContentBlockSchema), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type PromptRequest = z.infer + +export const StopReasonSchema = z.union([ + z.literal('end_turn'), + z.literal('max_tokens'), + z.literal('max_turn_requests'), + z.literal('refusal'), + z.literal('cancelled') +]) +export type StopReason = z.infer + +export const UsageSchema = z.looseObject({ + totalTokens: z.number().int().min(0), + inputTokens: z.number().int().min(0), + outputTokens: z.number().int().min(0), + thoughtTokens: z.union([z.number().int().min(0), z.null()]).optional(), + cachedReadTokens: z.union([z.number().int().min(0), z.null()]).optional(), + cachedWriteTokens: z.union([z.number().int().min(0), z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type Usage = z.infer + +export const PromptResponseSchema = z.looseObject({ + stopReason: StopReasonSchema, + usage: z.union([UsageSchema, z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type PromptResponse = z.infer + +export const CancelNotificationSchema = z.looseObject({ + sessionId: SessionIdSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type CancelNotification = z.infer + +export const MessageIdSchema = z.string() +export type MessageId = z.infer + +export const ContentChunkSchema = z.looseObject({ + content: ContentBlockSchema, + messageId: z.union([MessageIdSchema, z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type ContentChunk = z.infer + +export const ToolCallIdSchema = z.string() +export type ToolCallId = z.infer + +export const ToolKindSchema = z.union([ + z.literal('read'), + z.literal('edit'), + z.literal('delete'), + z.literal('move'), + z.literal('search'), + z.literal('execute'), + z.literal('think'), + z.literal('fetch'), + z.literal('switch_mode'), + z.literal('other') +]) +export type ToolKind = z.infer + +export const ToolCallStatusSchema = z.union([ + z.literal('pending'), + z.literal('in_progress'), + z.literal('completed'), + z.literal('failed') +]) +export type ToolCallStatus = z.infer + +export const ContentSchema = z.looseObject({ + content: ContentBlockSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type Content = z.infer diff --git a/src/main/acp/generated/wait-for-terminal-exit-request.gen.ts b/src/main/acp/generated/wait-for-terminal-exit-request.gen.ts new file mode 100644 index 000000000000..1f5fc01db7cc --- /dev/null +++ b/src/main/acp/generated/wait-for-terminal-exit-request.gen.ts @@ -0,0 +1,30 @@ +// Generated by config/scripts/acp/generate-protocol.mjs; do not edit. +// ACP schema-v1.21.0, legacy model API v0.11.6; SPDX-License-Identifier: Apache-2.0. +import { z } from 'zod' +import { SessionIdSchema } from './mcp-server-acp.gen' +import { TerminalIdSchema } from './diff.gen' +export const WaitForTerminalExitRequestSchema = z.looseObject({ + sessionId: SessionIdSchema, + terminalId: TerminalIdSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type WaitForTerminalExitRequest = z.infer + +export const WaitForTerminalExitResponseSchema = z.looseObject({ + exitCode: z.union([z.number().int().min(0), z.null()]).optional(), + signal: z.union([z.string(), z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type WaitForTerminalExitResponse = z.infer + +export const KillTerminalRequestSchema = z.looseObject({ + sessionId: SessionIdSchema, + terminalId: TerminalIdSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type KillTerminalRequest = z.infer + +export const KillTerminalResponseSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type KillTerminalResponse = z.infer From d09c278c4b46b12d0fac27488b7bb22a6e19567e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 02:02:23 -0700 Subject: [PATCH 02/62] Protect ACP transport teardown from late stream errors --- src/main/acp/acp-json-rpc-peer.test.ts | 15 +++++++++++++++ src/main/acp/acp-json-rpc-peer.ts | 5 +++-- src/main/acp/acp-stdio-error-boundary.ts | 16 ++++++++++++++++ 3 files changed, 34 insertions(+), 2 deletions(-) create mode 100644 src/main/acp/acp-stdio-error-boundary.ts diff --git a/src/main/acp/acp-json-rpc-peer.test.ts b/src/main/acp/acp-json-rpc-peer.test.ts index 3aba7848293a..b68a09c8aec7 100644 --- a/src/main/acp/acp-json-rpc-peer.test.ts +++ b/src/main/acp/acp-json-rpc-peer.test.ts @@ -250,4 +250,19 @@ describe('ACP JSON-RPC peer', () => { await rejected expect(peer.closed).toBe(true) }) + + it('handles asynchronous write callback errors without an unhandled stream error', async () => { + const input = new PassThrough() + const output = new Writable({ + write(_chunk, _encoding, callback) { + setImmediate(() => callback(new Error('Broken pipe from write'))) + } + }) + const peer = new AcpJsonRpcPeer(input, output) + peers.push(peer) + await expect(peer.request('wait', {})).rejects.toThrow('Broken pipe from write') + await tick() + expect(peer.closed).toBe(true) + input.destroy() + }) }) diff --git a/src/main/acp/acp-json-rpc-peer.ts b/src/main/acp/acp-json-rpc-peer.ts index 0924a1f797fa..cac23f60b57e 100644 --- a/src/main/acp/acp-json-rpc-peer.ts +++ b/src/main/acp/acp-json-rpc-peer.ts @@ -12,6 +12,7 @@ import { } from './acp-errors' import { AcpIncomingRequests } from './acp-incoming-requests' import { AcpWriteQueue } from './acp-write-queue' +import { detachAcpStreamErrorHandler } from './acp-stdio-error-boundary' const idSchema = z.union([z.string(), z.number(), z.null()]) const envelopeSchema = z.looseObject({ @@ -149,10 +150,10 @@ export class AcpJsonRpcPeer { this.input.removeListener('data', this.onData) this.input.removeListener('end', this.onEnd) this.input.removeListener('close', this.onEnd) - this.input.removeListener('error', this.onError) + detachAcpStreamErrorHandler(this.input, this.onError) this.output.removeListener('close', this.onEnd) this.output.removeListener('finish', this.onEnd) - this.output.removeListener('error', this.onError) + detachAcpStreamErrorHandler(this.output, this.onError) this.framer.reset() this.writer.close(error) for (const pending of this.pending.values()) { diff --git a/src/main/acp/acp-stdio-error-boundary.ts b/src/main/acp/acp-stdio-error-boundary.ts new file mode 100644 index 000000000000..67854b6f95a2 --- /dev/null +++ b/src/main/acp/acp-stdio-error-boundary.ts @@ -0,0 +1,16 @@ +import type { Readable, Writable } from 'node:stream' + +function ignoreLateError(): void {} + +export function detachAcpStreamErrorHandler( + stream: Readable | Writable, + handler: (error: Error) => void +): void { + stream.removeListener('error', handler) + if (stream.closed) { + return + } + // Node may emit the write error after its callback has already closed the peer. + stream.on('error', ignoreLateError) + stream.once('close', () => stream.removeListener('error', ignoreLateError)) +} From c3aa650d18eec279ed5090f51c400213b6110ad6 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 02:09:09 -0700 Subject: [PATCH 03/62] Retire incoming ACP request ids before publishing responses --- src/main/acp/acp-incoming-requests.ts | 12 ++++++++++-- src/main/acp/acp-json-rpc-peer.test.ts | 10 ++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/src/main/acp/acp-incoming-requests.ts b/src/main/acp/acp-incoming-requests.ts index 5b55fb8d5835..c594e1d31e8a 100644 --- a/src/main/acp/acp-incoming-requests.ts +++ b/src/main/acp/acp-incoming-requests.ts @@ -41,6 +41,12 @@ export class AcpIncomingRequests { ) }) this.open.set(id, { controller, abandon }) + const retire = (): void => { + clearTimeout(timer) + if (this.open.get(id)?.controller === controller) { + this.open.delete(id) + } + } void Promise.race([ deadline, Promise.resolve().then(() => { @@ -57,12 +63,15 @@ export class AcpIncomingRequests { if (result === undefined) { throw new AcpRpcError(-32601, `Unknown ACP client method: ${method}`) } + // The agent may reuse the id as soon as it reads the response. + retire() await this.send({ jsonrpc: '2.0', id, result }) }) .catch(async (error) => { if (controller.signal.aborted) { return } + retire() await this.sendError( id, error instanceof AcpRpcError @@ -71,8 +80,7 @@ export class AcpIncomingRequests { ) }) .finally(() => { - clearTimeout(timer) - this.open.delete(id) + retire() controller.abort() }) } diff --git a/src/main/acp/acp-json-rpc-peer.test.ts b/src/main/acp/acp-json-rpc-peer.test.ts index b68a09c8aec7..1019049d51fb 100644 --- a/src/main/acp/acp-json-rpc-peer.test.ts +++ b/src/main/acp/acp-json-rpc-peer.test.ts @@ -65,6 +65,16 @@ describe('ACP JSON-RPC peer', () => { }) }) + it('allows the agent to reuse a request id after receiving its response', async () => { + const { agent } = fixture({ onRequest: () => ({ answer: true }) }) + expect(await agent.request('reused', '_question', {})).toMatchObject({ + result: { answer: true } + }) + expect(await agent.request('reused', '_question', {})).toMatchObject({ + result: { answer: true } + }) + }) + it('ignores malformed and oversized lines then resumes at the next newline', async () => { const diagnostics: string[] = [] const notified: unknown[] = [] From cdad7d2fb2d2163985374bdf4913ef46400c7307 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 02:14:08 -0700 Subject: [PATCH 04/62] Narrow ACP configuration requests and transport message types --- src/main/acp/acp-incoming-requests.ts | 4 ++-- src/main/acp/acp-json-rpc-peer.ts | 3 ++- src/main/acp/acp-session-runtime.ts | 16 ++++++---------- 3 files changed, 10 insertions(+), 13 deletions(-) diff --git a/src/main/acp/acp-incoming-requests.ts b/src/main/acp/acp-incoming-requests.ts index c594e1d31e8a..ffdeae1a0992 100644 --- a/src/main/acp/acp-incoming-requests.ts +++ b/src/main/acp/acp-incoming-requests.ts @@ -1,5 +1,5 @@ import { AcpRpcError } from './acp-errors' -import type { AcpPeerHandlers } from './acp-json-rpc-peer' +import type { AcpJsonRpcMessage, AcpPeerHandlers } from './acp-json-rpc-peer' type OpenRequest = { controller: AbortController; abandon: () => void } @@ -8,7 +8,7 @@ export class AcpIncomingRequests { constructor( private readonly handler: AcpPeerHandlers['onRequest'], - private readonly send: (message: object) => Promise, + private readonly send: (message: AcpJsonRpcMessage) => Promise, private readonly onFailure: (error: Error) => void, private readonly capacity: number, private readonly timeoutMs: number diff --git a/src/main/acp/acp-json-rpc-peer.ts b/src/main/acp/acp-json-rpc-peer.ts index cac23f60b57e..5fda716003bb 100644 --- a/src/main/acp/acp-json-rpc-peer.ts +++ b/src/main/acp/acp-json-rpc-peer.ts @@ -25,6 +25,7 @@ const envelopeSchema = z.looseObject({ .object({ code: z.number().int(), message: z.string(), data: z.unknown().optional() }) .optional() }) +export type AcpJsonRpcMessage = z.infer export type AcpRequestContext = { id: string | number | null; signal: AbortSignal } export type AcpPeerHandlers = { onRequest?: ( @@ -186,7 +187,7 @@ export class AcpJsonRpcPeer { } } - private send(message: object, signal?: AbortSignal): Promise { + private send(message: AcpJsonRpcMessage, signal?: AbortSignal): Promise { if (this.terminalError) { return Promise.reject(this.terminalError) } diff --git a/src/main/acp/acp-session-runtime.ts b/src/main/acp/acp-session-runtime.ts index f9296d6c82da..16f8eafe33ec 100644 --- a/src/main/acp/acp-session-runtime.ts +++ b/src/main/acp/acp-session-runtime.ts @@ -217,16 +217,12 @@ export class AcpSessionRuntime { configId: SetSessionConfigOptionRequest['configId'], value: SetSessionConfigOptionRequest['value'] ): Promise { - return this.call( - 'session/set_config_option', - { - configId, - value, - sessionId: this.sessionId(), - ...(typeof value === 'boolean' ? { type: 'boolean' as const } : {}) - } satisfies SetSessionConfigOptionRequest, - SetSessionConfigOptionResponseSchema - ) + const sessionId = this.sessionId() + const request = + typeof value === 'boolean' + ? ({ configId, value, sessionId, type: 'boolean' } satisfies SetSessionConfigOptionRequest) + : ({ configId, value, sessionId } satisfies SetSessionConfigOptionRequest) + return this.call('session/set_config_option', request, SetSessionConfigOptionResponseSchema) } close(error?: Error): void { From 2ae4ab5452de296918344e8711ebd7b0e06bf72c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 02:18:14 -0700 Subject: [PATCH 05/62] Remove redundant ACP request handler return unions --- src/main/acp/acp-json-rpc-peer.ts | 6 +----- src/main/acp/acp-session-runtime.ts | 12 ++---------- 2 files changed, 3 insertions(+), 15 deletions(-) diff --git a/src/main/acp/acp-json-rpc-peer.ts b/src/main/acp/acp-json-rpc-peer.ts index 5fda716003bb..f48a1b63ad01 100644 --- a/src/main/acp/acp-json-rpc-peer.ts +++ b/src/main/acp/acp-json-rpc-peer.ts @@ -28,11 +28,7 @@ const envelopeSchema = z.looseObject({ export type AcpJsonRpcMessage = z.infer export type AcpRequestContext = { id: string | number | null; signal: AbortSignal } export type AcpPeerHandlers = { - onRequest?: ( - method: string, - params: unknown, - context: AcpRequestContext - ) => unknown | Promise + onRequest?: (method: string, params: unknown, context: AcpRequestContext) => unknown onNotification?: (method: string, params: unknown) => void onDiagnostic?: (message: string) => void onClose?: (error: Error) => void diff --git a/src/main/acp/acp-session-runtime.ts b/src/main/acp/acp-session-runtime.ts index 16f8eafe33ec..095573150ce6 100644 --- a/src/main/acp/acp-session-runtime.ts +++ b/src/main/acp/acp-session-runtime.ts @@ -37,11 +37,7 @@ export type AcpSessionRuntimeOptions = { promptTimeoutMs?: number cancelTimeoutMs?: number onPermission?: AcpPermissionHandler - onRequest?: ( - method: string, - params: unknown, - context: AcpRequestContext - ) => unknown | Promise + onRequest?: (method: string, params: unknown, context: AcpRequestContext) => unknown onDiagnostic?: (message: string) => void onClose?: (error: Error) => void } @@ -257,11 +253,7 @@ export class AcpSessionRuntime { return parsed.data } - private handleRequest( - method: string, - params: unknown, - context: AcpRequestContext - ): unknown | Promise { + private handleRequest(method: string, params: unknown, context: AcpRequestContext): unknown { if (method !== 'session/request_permission') { return this.options.onRequest?.(method, params, context) } From e30b885444e9ad1093d27a34aec68d974fe22015 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 13:12:45 -0700 Subject: [PATCH 06/62] Keep ACP waits caller-owned and preserve protocol extensions --- .gitattributes | 3 + .oxlintrc.json | 1 + config/scripts/acp/generate-protocol.mjs | 76 +- src/main/acp/acp-errors.ts | 8 +- src/main/acp/acp-incoming-requests.ts | 50 +- src/main/acp/acp-json-rpc-peer.test.ts | 40 +- src/main/acp/acp-json-rpc-peer.ts | 109 +- src/main/acp/acp-permission-requests.ts | 2 +- src/main/acp/acp-session-lifecycle.test.ts | 281 ++++ .../acp/acp-session-notifications.test.ts | 80 + src/main/acp/acp-session-runtime.test.ts | 78 +- src/main/acp/acp-session-runtime.ts | 91 +- src/main/acp/acp-session-setup.ts | 54 +- .../acp/generated/acp-protocol.generated.ts | 1390 +++++++++++++++++ .../acp/generated/client-capabilities.gen.ts | 113 -- src/main/acp/generated/diff.gen.ts | 126 -- src/main/acp/generated/license.gen.ts | 197 --- src/main/acp/generated/mcp-server-acp.gen.ts | 116 -- .../generated/nes-context-capabilities.gen.ts | 124 -- src/main/acp/generated/plan-update.gen.ts | 103 -- .../acp/generated/protocol-version.gen.ts | 93 -- src/main/acp/generated/protocol.gen.ts | 14 - .../generated/providers-capabilities.gen.ts | 87 -- .../generated/session-config-option.gen.ts | 113 -- src/main/acp/generated/session-update.gen.ts | 132 -- .../set-session-model-request.gen.ts | 121 -- .../generated/text-resource-contents.gen.ts | 129 -- .../wait-for-terminal-exit-request.gen.ts | 30 - 28 files changed, 2025 insertions(+), 1736 deletions(-) create mode 100644 src/main/acp/acp-session-lifecycle.test.ts create mode 100644 src/main/acp/acp-session-notifications.test.ts create mode 100644 src/main/acp/generated/acp-protocol.generated.ts delete mode 100644 src/main/acp/generated/client-capabilities.gen.ts delete mode 100644 src/main/acp/generated/diff.gen.ts delete mode 100644 src/main/acp/generated/license.gen.ts delete mode 100644 src/main/acp/generated/mcp-server-acp.gen.ts delete mode 100644 src/main/acp/generated/nes-context-capabilities.gen.ts delete mode 100644 src/main/acp/generated/plan-update.gen.ts delete mode 100644 src/main/acp/generated/protocol-version.gen.ts delete mode 100644 src/main/acp/generated/protocol.gen.ts delete mode 100644 src/main/acp/generated/providers-capabilities.gen.ts delete mode 100644 src/main/acp/generated/session-config-option.gen.ts delete mode 100644 src/main/acp/generated/session-update.gen.ts delete mode 100644 src/main/acp/generated/set-session-model-request.gen.ts delete mode 100644 src/main/acp/generated/text-resource-contents.gen.ts delete mode 100644 src/main/acp/generated/wait-for-terminal-exit-request.gen.ts diff --git a/.gitattributes b/.gitattributes index e0e0282b69f8..d995215b26cf 100644 --- a/.gitattributes +++ b/.gitattributes @@ -88,3 +88,6 @@ /mobile/web-entry/**/*.otf -text /mobile/web-entry/**/*.woff -text /mobile/web-entry/**/*.woff2 -text + +# Generated ACP schemas are checked byte-for-byte against formatted output. +/src/main/acp/generated/*.generated.ts linguist-generated=true text eol=lf diff --git a/.oxlintrc.json b/.oxlintrc.json index 7a0753c06e27..fec10ce318ec 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -208,6 +208,7 @@ } ], "ignorePatterns": [ + "src/main/acp/generated/acp-protocol.generated.ts", "src/shared/rpc-contract/rpc-params-catalog.generated.ts", "**/node_modules", "**/dist", diff --git a/config/scripts/acp/generate-protocol.mjs b/config/scripts/acp/generate-protocol.mjs index db3de9cf5e31..9e154fde5052 100644 --- a/config/scripts/acp/generate-protocol.mjs +++ b/config/scripts/acp/generate-protocol.mjs @@ -60,6 +60,7 @@ const roots = [ 'SetSessionModeResponse', 'SetSessionModelRequest', 'SetSessionModelResponse', + 'SessionModelState', 'SetSessionConfigOptionRequest', 'SetSessionConfigOptionResponse', 'ReadTextFileRequest', @@ -196,72 +197,17 @@ function expression(value) { return result } -// Pack in dependency order so generated imports stay acyclic and each module stays small. -const groups = [] -let group = [] -let length = 0 -for (const name of ordered) { - const body = `export const ${name}Schema = ${expression(definitions[name])}\nexport type ${name} = z.infer\n` - if (length + body.length > 4300 && group.length) { - groups.push(group) - group = [] - length = 0 - } - group.push({ name, body }) - length += body.length -} -if (group.length) { - groups.push(group) -} -const filenames = new Map() -for (const entries of groups) { - const filename = entries[0].name.replace( - /[A-Z]/g, - (c, offset) => `${offset ? '-' : ''}${c.toLowerCase()}` - ) - for (const { name } of entries) { - filenames.set(name, `${filename}.gen.ts`) - } -} - -const generated = new Map() -for (const entries of groups) { - const file = filenames.get(entries[0].name) - const imports = new Map() - for (const { body } of entries) { - for (const dependency of [...body.matchAll(/\b(\w+)Schema\b/g)].map((match) => match[1])) { - const source = filenames.get(dependency) - if (source === file) { - continue - } - if (!imports.has(source)) { - imports.set(source, new Set()) - } - imports.get(source).add(`${dependency}Schema`) - } - } - generated.set( - file, - `${header}import { z } from 'zod'\n${[...imports] +const generated = new Map([ + [ + 'acp-protocol.generated.ts', + `${header}/*\n${license.trim()}\n*/\nimport { z } from 'zod'\nexport const ACP_SCHEMA_RELEASE = '${release}'\nexport const ACP_LEGACY_MODEL_SCHEMA_RELEASE = '${legacyRelease}'\nexport const ACP_PROTOCOL_VERSION = 1\n${ordered .map( - ([source, names]) => `import { ${[...names].join(',')} } from './${source.slice(0, -3)}'\n` + (name) => + `export const ${name}Schema = ${expression(definitions[name])}\nexport type ${name} = z.infer\n` ) - .join('')}${entries.map((entry) => entry.body).join('\n')}` - ) -} -generated.set( - 'protocol.gen.ts', - `${ - header + - [...new Set(filenames.values())] - .map((file) => `export * from './${file.slice(0, -3)}'`) - .join('\n') - }\n` -) -generated.set( - 'license.gen.ts', - `${header}/*\n${license.trim()}\n*/\nexport const ACP_SCHEMA_RELEASE = '${release}'\nexport const ACP_LEGACY_MODEL_SCHEMA_RELEASE = '${legacyRelease}'\nexport const ACP_PROTOCOL_VERSION = 1\n` -) + .join('\n')}` + ] +]) // Use the repository formatter without spawning a platform-dependent executable shim. const { format } = await import('oxfmt') @@ -286,7 +232,7 @@ for (const [file, source] of generated) { } } for (const file of await readdir(output)) { - if (file.endsWith('.gen.ts') && !generated.has(file)) { + if ((file.endsWith('.gen.ts') || file.endsWith('.generated.ts')) && !generated.has(file)) { if (check || !(await readFile(resolve(output, file), 'utf8')).startsWith(header)) { throw new Error(`Unexpected generated file: ${file}`) } diff --git a/src/main/acp/acp-errors.ts b/src/main/acp/acp-errors.ts index 732db5c26cfc..41aff9694e2e 100644 --- a/src/main/acp/acp-errors.ts +++ b/src/main/acp/acp-errors.ts @@ -1,3 +1,5 @@ +import type { AuthMethod } from './generated/acp-protocol.generated' + export class AcpRpcError extends Error { constructor( readonly code: number, @@ -10,7 +12,11 @@ export class AcpRpcError extends Error { } export class AcpAuthRequiredError extends AcpRpcError { - constructor(message: string, data?: unknown) { + constructor( + message: string, + data?: unknown, + readonly authMethods: AuthMethod[] = [] + ) { super(-32000, message, data) this.name = 'AcpAuthRequiredError' } diff --git a/src/main/acp/acp-incoming-requests.ts b/src/main/acp/acp-incoming-requests.ts index ffdeae1a0992..b565c00c207a 100644 --- a/src/main/acp/acp-incoming-requests.ts +++ b/src/main/acp/acp-incoming-requests.ts @@ -1,7 +1,7 @@ import { AcpRpcError } from './acp-errors' import type { AcpJsonRpcMessage, AcpPeerHandlers } from './acp-json-rpc-peer' -type OpenRequest = { controller: AbortController; abandon: () => void } +type OpenRequest = { method: string; controller: AbortController; abandon: () => void } export class AcpIncomingRequests { private readonly open = new Map() @@ -11,7 +11,7 @@ export class AcpIncomingRequests { private readonly send: (message: AcpJsonRpcMessage) => Promise, private readonly onFailure: (error: Error) => void, private readonly capacity: number, - private readonly timeoutMs: number + private readonly diagnose: (message: string) => void ) {} close(error: Error): void { @@ -22,50 +22,58 @@ export class AcpIncomingRequests { this.open.clear() } + cancel(exceptMethod?: string): void { + for (const [id, request] of this.open) { + if (request.method === exceptMethod) { + continue + } + const error = new AcpRpcError(-32800, 'Request cancelled') + this.open.delete(id) + request.controller.abort(error) + request.abandon() + void this.sendError(id, error) + } + } + handle(id: string | number | null, method: string, params: unknown): void { - if (this.open.has(id) || this.open.size >= this.capacity) { - void this.sendError( - id, - new AcpRpcError(-32600, 'Duplicate request id or incoming request capacity exceeded') - ) + if (this.open.has(id)) { + this.diagnose('Ignored duplicate ACP incoming request id') + return + } + if (this.open.size >= this.capacity) { + void this.sendError(id, new AcpRpcError(-32603, 'ACP incoming request capacity exceeded')) return } const controller = new AbortController() - let timer: ReturnType | undefined let abandon = (): void => {} - const deadline = new Promise((_resolve, reject) => { + const abandoned = new Promise((_resolve, reject) => { abandon = () => reject(controller.signal.reason) - timer = setTimeout( - () => reject(new AcpRpcError(-32001, `ACP client request timed out: ${method}`)), - this.timeoutMs - ) }) - this.open.set(id, { controller, abandon }) + this.open.set(id, { method, controller, abandon }) const retire = (): void => { - clearTimeout(timer) if (this.open.get(id)?.controller === controller) { this.open.delete(id) } } void Promise.race([ - deadline, + abandoned, Promise.resolve().then(() => { if (controller.signal.aborted) { return undefined } - return this.handler?.(method, params, { id, signal: controller.signal }) + if (!this.handler) { + throw new AcpRpcError(-32601, `Unknown ACP client method: ${method}`) + } + return this.handler(method, params, { id, signal: controller.signal }) }) ]) .then(async (result) => { if (controller.signal.aborted) { return } - if (result === undefined) { - throw new AcpRpcError(-32601, `Unknown ACP client method: ${method}`) - } // The agent may reuse the id as soon as it reads the response. retire() - await this.send({ jsonrpc: '2.0', id, result }) + await this.send({ jsonrpc: '2.0', id, result: result ?? null }) }) .catch(async (error) => { if (controller.signal.aborted) { diff --git a/src/main/acp/acp-json-rpc-peer.test.ts b/src/main/acp/acp-json-rpc-peer.test.ts index 1019049d51fb..9ead2e2ddc0c 100644 --- a/src/main/acp/acp-json-rpc-peer.test.ts +++ b/src/main/acp/acp-json-rpc-peer.test.ts @@ -51,7 +51,7 @@ describe('ACP JSON-RPC peer', () => { if (method === '_crash') { throw new Error('Handler failed') } - return undefined + throw new AcpRpcError(-32601, 'Unknown method') } }) expect(await agent.request('missing', '_missing', {})).toMatchObject({ @@ -102,13 +102,14 @@ describe('ACP JSON-RPC peer', () => { expect(diagnostics).toContain('Ignored invalid ACP JSON-RPC envelope') }) - it('ignores malformed response envelopes without settling pending requests', async () => { + it('rejects malformed matching responses instead of leaving calls pending', async () => { const { peer, agent } = fixture() - const pending = peer.request('wait', {}) + const rejected = expect(peer.request('wait', {})).rejects.toMatchObject({ code: -32603 }) agent.stdout.write('{"jsonrpc":"2.0","id":1}\n') - agent.stdout.write('{"jsonrpc":"2.0","id":1,"result":"x","error":{"code":1,"message":"y"}}\n') - agent.stdout.write('{"jsonrpc":"2.0","id":1,"result":"ok"}\n') - expect(await pending).toBe('ok') + await rejected + const contradictory = expect(peer.request('wait', {})).rejects.toMatchObject({ code: -32603 }) + agent.stdout.write('{"jsonrpc":"2.0","id":2,"result":"x","error":{"code":1,"message":"y"}}\n') + await contradictory }) it('rejects all pending calls on exit and stops accepting requests', async () => { @@ -138,30 +139,29 @@ describe('ACP JSON-RPC peer', () => { expect(agent.frames.map((frame) => frame.method)).toEqual(['wait', 'next']) }) - it('expires unanswered incoming requests and aborts their hooks', async () => { + it('bounds incoming requests without expiring user decisions', async () => { vi.useFakeTimers() - const signals: AbortSignal[] = [] + const signal = deferred() + const answer = deferred() const { agent } = fixture( { onRequest: (_method, _params, context) => { - signals.push(context.signal) - return new Promise(() => {}) + signal.resolve(context.signal) + return answer.promise } }, - { maxIncomingRequests: 1, incomingRequestTimeoutMs: 20 } + { maxIncomingRequests: 1, requestTimeoutMs: 20 } ) const first = agent.request('first', '_question', {}) - await vi.advanceTimersByTimeAsync(0) + const context = await signal.promise expect(await agent.request('overflow', '_question', {})).toMatchObject({ - error: { code: -32600 } + error: { code: -32603 } }) - await vi.advanceTimersByTimeAsync(20) - expect(await first).toMatchObject({ error: { code: -32001 } }) - expect(signals[0].aborted).toBe(true) - const next = agent.request('next', '_question', {}) - await vi.advanceTimersByTimeAsync(20) - expect(await next).toMatchObject({ error: { code: -32001 } }) - expect(signals).toHaveLength(2) + await vi.advanceTimersByTimeAsync(120_001) + expect(context.aborted).toBe(false) + answer.resolve({ answer: true }) + expect(await first).toMatchObject({ result: { answer: true } }) + expect(await agent.request('next', '_question', {})).toMatchObject({ result: { answer: true } }) }) it('aborts an in-flight incoming hook on close and never writes its late response', async () => { diff --git a/src/main/acp/acp-json-rpc-peer.ts b/src/main/acp/acp-json-rpc-peer.ts index f48a1b63ad01..08df74ef89ae 100644 --- a/src/main/acp/acp-json-rpc-peer.ts +++ b/src/main/acp/acp-json-rpc-peer.ts @@ -1,33 +1,33 @@ import type { Readable, Writable } from 'node:stream' import { z } from 'zod' +import { isSafeTimerDelayMs } from '../../shared/timer-delay' import { createIncrementalNdjsonFramer, encodeNdjson } from '../../shared/main-process-ndjson-framer' -import { - AcpAuthRequiredError, - AcpConnectionClosedError, - AcpRequestTimeoutError, - AcpRpcError -} from './acp-errors' +import { AcpConnectionClosedError, AcpRequestTimeoutError, AcpRpcError } from './acp-errors' import { AcpIncomingRequests } from './acp-incoming-requests' import { AcpWriteQueue } from './acp-write-queue' import { detachAcpStreamErrorHandler } from './acp-stdio-error-boundary' const idSchema = z.union([z.string(), z.number(), z.null()]) +const errorSchema = z.object({ + code: z.number().int(), + message: z.string(), + data: z.unknown().optional() +}) const envelopeSchema = z.looseObject({ jsonrpc: z.literal('2.0'), id: idSchema.optional(), method: z.string().optional(), params: z.unknown().optional(), result: z.unknown().optional(), - error: z - .object({ code: z.number().int(), message: z.string(), data: z.unknown().optional() }) - .optional() + error: z.unknown().optional() }) export type AcpJsonRpcMessage = z.infer export type AcpRequestContext = { id: string | number | null; signal: AbortSignal } export type AcpPeerHandlers = { + // Void means handled; unsupported methods must throw AcpRpcError(-32601). onRequest?: (method: string, params: unknown, context: AcpRequestContext) => unknown onNotification?: (method: string, params: unknown) => void onDiagnostic?: (message: string) => void @@ -38,13 +38,13 @@ export type AcpPeerOptions = { maxQueuedWriteBytes?: number maxPendingRequests?: number maxIncomingRequests?: number - requestTimeoutMs?: number - incomingRequestTimeoutMs?: number + // Caller-selected outgoing deadlines only; omitted or null means unlimited. + requestTimeoutMs?: number | null } type Pending = { resolve: (value: unknown) => void reject: (error: Error) => void - timer: ReturnType + timer?: ReturnType } export class AcpJsonRpcPeer { @@ -57,7 +57,7 @@ export class AcpJsonRpcPeer { private readonly maxLineBytes: number private readonly maxPending: number private readonly maxIncoming: number - private readonly timeoutMs: number + private readonly timeoutMs: number | null constructor( private readonly input: Readable, @@ -68,7 +68,7 @@ export class AcpJsonRpcPeer { this.maxLineBytes = bounded(options.maxLineBytes, 16 * 1024 * 1024) this.maxPending = bounded(options.maxPendingRequests, 128) this.maxIncoming = bounded(options.maxIncomingRequests, 128) - this.timeoutMs = bounded(options.requestTimeoutMs, 120_000) + this.timeoutMs = requestTimeout(options.requestTimeoutMs) this.writer = new AcpWriteQueue( output, bounded(options.maxQueuedWriteBytes, 32 * 1024 * 1024), @@ -79,7 +79,7 @@ export class AcpJsonRpcPeer { (message) => this.send(message), (error) => this.close(error), this.maxIncoming, - bounded(options.incomingRequestTimeoutMs, this.timeoutMs) + (message) => this.diagnose(message) ) this.framer = createIncrementalNdjsonFramer( (record) => this.dispatch(record), @@ -103,25 +103,37 @@ export class AcpJsonRpcPeer { return this.terminalError !== undefined } - request(method: string, params: unknown, options: { timeoutMs?: number } = {}): Promise { + request( + method: string, + params: unknown, + options: { timeoutMs?: number | null } = {} + ): Promise { if (this.terminalError) { return Promise.reject(this.terminalError) } if (this.pending.size >= this.maxPending) { return Promise.reject(new Error('ACP pending request capacity exceeded')) } + let timeoutMs: number | null + try { + timeoutMs = requestTimeout( + options.timeoutMs === undefined ? this.timeoutMs : options.timeoutMs + ) + } catch (error) { + return Promise.reject(error) + } const id = this.nextId++ const controller = new AbortController() return new Promise((resolve, reject) => { - const timer = setTimeout( - () => { - this.pending.delete(id) - const error = new AcpRequestTimeoutError(method) - controller.abort(error) - reject(error) - }, - bounded(options.timeoutMs, this.timeoutMs) - ) + const timer = + timeoutMs === null + ? undefined + : setTimeout(() => { + this.pending.delete(id) + const error = new AcpRequestTimeoutError(method) + controller.abort(error) + reject(error) + }, timeoutMs) this.pending.set(id, { resolve, reject, timer }) void this.send({ jsonrpc: '2.0', id, method, params }, controller.signal).catch((error) => { const pending = this.pending.get(id) @@ -139,6 +151,10 @@ export class AcpJsonRpcPeer { return this.send({ jsonrpc: '2.0', method, params }) } + cancelIncomingRequests(exceptMethod?: string): void { + this.incoming.cancel(exceptMethod) + } + close(error: Error = new AcpConnectionClosedError()): void { if (this.terminalError) { return @@ -194,6 +210,15 @@ export class AcpJsonRpcPeer { } } + private rejectInvalidResponse(id: number, raw: unknown): void { + const pending = this.pending.get(id) + if (pending) { + this.pending.delete(id) + clearTimeout(pending.timer) + pending.reject(new AcpRpcError(-32603, 'Invalid ACP response envelope', raw)) + } + } + private dispatch(record: unknown): void { if (this.closed) { return @@ -201,6 +226,12 @@ export class AcpJsonRpcPeer { const parsed = envelopeSchema.safeParse(record) if (!parsed.success) { this.diagnose('Ignored invalid ACP JSON-RPC envelope') + const response = z + .object({ id: z.number(), method: z.undefined().optional() }) + .safeParse(record) + if (response.success) { + this.rejectInvalidResponse(response.data.id, record) + } return } const frame = parsed.data @@ -222,6 +253,9 @@ export class AcpJsonRpcPeer { } if ('result' in frame === 'error' in frame) { this.diagnose('Ignored invalid ACP response') + if (typeof frame.id === 'number') { + this.rejectInvalidResponse(frame.id, record) + } return } if (typeof frame.id !== 'number') { @@ -233,12 +267,15 @@ export class AcpJsonRpcPeer { } this.pending.delete(frame.id) clearTimeout(pending.timer) - if (frame.error) { - pending.reject( - frame.error.code === -32000 - ? new AcpAuthRequiredError(frame.error.message, frame.error.data) - : new AcpRpcError(frame.error.code, frame.error.message, frame.error.data) - ) + if ('error' in frame) { + const parsedError = errorSchema.safeParse(frame.error) + if (!parsedError.success) { + this.diagnose('Invalid ACP error response') + pending.reject(new AcpRpcError(-32603, 'Invalid ACP error response', frame.error)) + } else { + const error = parsedError.data + pending.reject(new AcpRpcError(error.code, error.message, error.data)) + } } else { pending.resolve(frame.result) } @@ -254,3 +291,13 @@ function bounded(value: number | undefined, fallback: number): number { } return value } + +function requestTimeout(value: number | null | undefined): number | null { + if (value == null) { + return null + } + if (!isSafeTimerDelayMs(value) || value <= 0) { + throw new Error('ACP timeouts must be positive finite timer durations') + } + return value +} diff --git a/src/main/acp/acp-permission-requests.ts b/src/main/acp/acp-permission-requests.ts index 120f896be2b6..4701359d11b1 100644 --- a/src/main/acp/acp-permission-requests.ts +++ b/src/main/acp/acp-permission-requests.ts @@ -4,7 +4,7 @@ import { RequestPermissionResponseSchema, type RequestPermissionRequest, type RequestPermissionResponse -} from './generated/protocol.gen' +} from './generated/acp-protocol.generated' export type AcpPermissionHandler = ( request: RequestPermissionRequest, diff --git a/src/main/acp/acp-session-lifecycle.test.ts b/src/main/acp/acp-session-lifecycle.test.ts new file mode 100644 index 000000000000..ec79badfbb5f --- /dev/null +++ b/src/main/acp/acp-session-lifecycle.test.ts @@ -0,0 +1,281 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { AcpJsonRpcPeer } from './acp-json-rpc-peer' +import { AcpSessionRuntime, type AcpSessionRuntimeOptions } from './acp-session-runtime' +import { AcpScriptedAgent, deferred, tick } from './acp-scripted-agent.test-support' +import { AcpConnectionClosedError, AcpRequestTimeoutError } from './acp-errors' +import type { RequestPermissionResponse } from './generated/acp-protocol.generated' + +const opened: { close: () => void }[] = [] +const startOptions = { cwd: '/runtime/project', mcpServers: [] } +const prompt = [{ type: 'text', text: 'hello' }] as const +const permission = { + sessionId: 'session-1', + toolCall: { toolCallId: 'tool-1', title: 'Edit file' }, + options: [{ optionId: 'allow', name: 'Allow once', kind: 'allow_once' }] +} +function fixture(options: AcpSessionRuntimeOptions = {}) { + const agent = new AcpScriptedAgent() + const runtime = new AcpSessionRuntime(agent.stdout, agent.stdin, options) + opened.push(agent, runtime) + agent.on('initialize', (frame) => agent.reply(frame, { protocolVersion: 1 })) + agent.on('session/new', (frame) => agent.reply(frame, { sessionId: 'session-1' })) + return { agent, runtime } +} +afterEach(() => { + opened + .splice(0) + .toReversed() + .forEach((resource) => resource.close()) + vi.useRealTimers() +}) + +describe('ACP caller-owned waits', () => { + it('keeps permission requests open beyond two minutes and delivers the late user decision', async () => { + vi.useFakeTimers() + const entered = deferred() + const decision = deferred() + const { agent, runtime } = fixture({ + onPermission: (_request, context) => { + entered.resolve(context.signal) + return decision.promise + } + }) + agent.on('session/prompt', () => {}) + await runtime.start(startOptions) + const pending = runtime.prompt([...prompt]) + const rejected = expect(pending).rejects.toBeInstanceOf(AcpConnectionClosedError) + let answered = false + const response = agent.request('permission', 'session/request_permission', permission) + void response.then(() => { + answered = true + }) + const signal = await entered.promise + await vi.advanceTimersByTimeAsync(120_001) + expect(signal.aborted).toBe(false) + expect(answered).toBe(false) + decision.resolve({ outcome: { outcome: 'selected', optionId: 'allow' } }) + expect(await response).toMatchObject({ + result: { outcome: { outcome: 'selected', optionId: 'allow' } } + }) + runtime.close() + await rejected + }) + + it('lets a streaming turn run beyond thirty minutes and keeps the session usable', async () => { + vi.useFakeTimers() + const onClose = vi.fn() + const event = vi.fn() + const { agent, runtime } = fixture({ onClose }) + runtime.subscribe(event) + agent.on('session/prompt', () => {}) + agent.on('session/set_mode', (frame) => agent.reply(frame, {})) + await runtime.start(startOptions) + let finished = false + const pending = runtime.prompt([...prompt]) + void pending.then(() => { + finished = true + }) + await vi.advanceTimersByTimeAsync(30 * 60_000 + 1) + agent.notify('session/update', { + sessionId: 'session-1', + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'Still working' } + } + }) + expect(event).toHaveBeenCalledTimes(1) + expect(finished).toBe(false) + expect(onClose).not.toHaveBeenCalled() + expect(agent.frames.some((frame) => frame.method === 'session/cancel')).toBe(false) + await expect(runtime.setMode('plan')).resolves.toEqual({}) + const frame = agent.frames.find((frame) => frame.method === 'session/prompt') + expect(frame).toBeDefined() + if (frame) { + agent.reply(frame, { stopReason: 'end_turn' }) + } + await expect(pending).resolves.toEqual({ stopReason: 'end_turn' }) + }) + + it.each([ + { id: 'key', name: 'API key', type: 'env_var', vars: [{ name: 'X_API_KEY' }] }, + { id: 'browser', name: 'Browser login', type: 'agent' }, + { id: 'default', name: 'Default login' }, + { id: 'terminal', name: 'Terminal login', type: 'terminal' } + ])('surfaces $id authentication without choosing it for the caller', async (method) => { + const { agent, runtime } = fixture() + agent.on('initialize', (frame) => + agent.reply(frame, { protocolVersion: 1, authMethods: [method] }) + ) + agent.on('session/new', (frame) => agent.fail(frame, -32000, 'Authentication required')) + await expect(runtime.start(startOptions)).rejects.toMatchObject({ + name: 'AcpAuthRequiredError', + authMethods: [method] + }) + expect(agent.frames.map((frame) => frame.method)).toEqual(['initialize', 'session/new']) + }) + + it.each(['prompt', 'cancel', 'setMode', 'setModel', 'setConfigOption'] as const)( + 'rejects %s before start through its promise', + async (method) => { + const { runtime } = fixture() + let pending: Promise | undefined + expect(() => { + switch (method) { + case 'prompt': + pending = runtime.prompt([...prompt]) + break + case 'cancel': + pending = runtime.cancel() + break + case 'setMode': + pending = runtime.setMode('plan') + break + case 'setModel': + pending = runtime.setModel('model') + break + case 'setConfigOption': + pending = runtime.setConfigOption('thinking', 'high') + break + } + }).not.toThrow() + await expect(pending).rejects.toThrow('ACP session has not started') + } + ) + + it('answers a handled void vendor request with null', async () => { + const handler = vi.fn() + const { agent } = fixture({ onRequest: handler }) + expect(await agent.request(7, '_vendor/ack', {})).toMatchObject({ id: 7, result: null }) + expect(handler).toHaveBeenCalledTimes(1) + }) + + it('ignores duplicate incoming ids without answering the original request', async () => { + const answer = deferred() + const handler = vi.fn(() => answer.promise) + const diagnostics: string[] = [] + const { agent } = fixture({ + onRequest: handler, + onDiagnostic: (message) => diagnostics.push(message) + }) + const original = agent.request(5, '_vendor/question', {}) + agent.send({ jsonrpc: '2.0', id: 5, method: '_vendor/question', params: {} }) + await tick() + expect(agent.frames).toEqual([]) + expect(handler).toHaveBeenCalledTimes(1) + expect(diagnostics).toContain('Ignored duplicate ACP incoming request id') + answer.resolve({ answer: true }) + expect(await original).toMatchObject({ id: 5, result: { answer: true } }) + expect(agent.frames).toHaveLength(1) + }) + + it.each([{ code: 'E1', message: 'boom' }, { code: -1, message: null }, null])( + 'rejects a malformed error reply immediately with its raw error: %j', + async (error) => { + const { agent, runtime } = fixture() + agent.on('initialize', (frame) => { + agent.stdout.write(`${JSON.stringify({ jsonrpc: '2.0', id: frame.id, error })}\n`) + }) + await expect(runtime.initialize()).rejects.toMatchObject({ code: -32603, data: error }) + } + ) + + it('maps authentication-required and retries only the explicitly chosen advertised method once', async () => { + const { agent, runtime } = fixture() + const authMethods = [{ id: 'login', name: 'Login' }] + agent.on('initialize', (frame) => agent.reply(frame, { protocolVersion: 1, authMethods })) + agent.on('session/new', (frame) => agent.fail(frame, -32000, 'Authentication required')) + agent.on('authenticate', (frame) => agent.reply(frame, {})) + await expect(runtime.start({ ...startOptions, authMethodId: 'login' })).rejects.toMatchObject({ + name: 'AcpAuthRequiredError', + authMethods + }) + expect(agent.frames.map((frame) => frame.method)).toEqual([ + 'initialize', + 'session/new', + 'authenticate', + 'session/new' + ]) + }) + + it('rejects overflowing peer timeouts and supports an explicit unlimited wait', async () => { + vi.useFakeTimers() + const agent = new AcpScriptedAgent() + opened.push(agent) + expect( + () => new AcpJsonRpcPeer(agent.stdout, agent.stdin, {}, { requestTimeoutMs: 3_000_000_000 }) + ).toThrow('timer durations') + const peer = new AcpJsonRpcPeer(agent.stdout, agent.stdin, {}, { requestTimeoutMs: 20 }) + opened.push(peer) + await expect(peer.request('overflow', {}, { timeoutMs: 3_000_000_000 })).rejects.toThrow( + 'timer durations' + ) + const pending = peer.request('wait', {}, { timeoutMs: null }) + const rejected = expect(pending).rejects.toBeInstanceOf(AcpConnectionClosedError) + await vi.advanceTimersByTimeAsync(3_000_000_000) + expect(peer.closed).toBe(false) + expect(agent.frames.map((frame) => frame.method)).toEqual(['wait']) + peer.close() + await rejected + }) + + it('clamps cancellation confirmation waits to the safe timer range', async () => { + vi.useFakeTimers() + const { agent, runtime } = fixture({ cancelTimeoutMs: 3_000_000_000 }) + agent.on('session/prompt', () => {}) + await runtime.start(startOptions) + const rejected = expect(runtime.prompt([...prompt])).rejects.toBeInstanceOf( + AcpRequestTimeoutError + ) + const cancelled = expect(runtime.cancel()).rejects.toBeInstanceOf(AcpRequestTimeoutError) + await vi.advanceTimersByTimeAsync(1) + expect(agent.frames.at(-1)?.method).toBe('session/cancel') + await vi.advanceTimersByTimeAsync(2_147_483_646) + await Promise.all([rejected, cancelled]) + }) + + it('lets initialize and explicit authentication wait for slow first-run startup and login', async () => { + vi.useFakeTimers() + const { agent, runtime } = fixture({ peer: { requestTimeoutMs: 20 } }) + agent.on('initialize', () => {}) + const initialized = runtime.initialize() + await vi.advanceTimersByTimeAsync(120_001) + agent.reply(agent.frames[0], { protocolVersion: 1 }) + await initialized + const authenticated = runtime.authenticate('caller-chosen') + await vi.advanceTimersByTimeAsync(120_001) + agent.reply(agent.frames[1], {}) + await expect(authenticated).resolves.toEqual({}) + }) + + it('lets cancel abort vendor decision hooks with request-cancelled instead of a timeout', async () => { + const entered = deferred() + const { agent, runtime } = fixture({ + onRequest: (_method, _params, context) => { + entered.resolve(context.signal) + return new Promise(() => {}) + } + }) + agent.on('session/prompt', (frame) => + agent.on('session/cancel', () => agent.reply(frame, { stopReason: 'cancelled' })) + ) + await runtime.start(startOptions) + const pending = runtime.prompt([...prompt]) + const response = agent.request('question', '_vendor/question', {}) + const signal = await entered.promise + await runtime.cancel() + expect(signal.aborted).toBe(true) + expect(await response).toMatchObject({ error: { code: -32800 } }) + await pending + }) + + it('settles requests when the process owner closes on exit even if stdout stays open', async () => { + const { agent, runtime } = fixture() + agent.on('session/prompt', () => {}) + await runtime.start(startOptions) + const rejected = expect(runtime.prompt([...prompt])).rejects.toThrow('Agent exited') + expect(agent.stdout.readableEnded).toBe(false) + runtime.close(new Error('Agent exited')) + await rejected + expect(agent.stdout.readableEnded).toBe(false) + }) +}) diff --git a/src/main/acp/acp-session-notifications.test.ts b/src/main/acp/acp-session-notifications.test.ts new file mode 100644 index 000000000000..181a7cb8dedc --- /dev/null +++ b/src/main/acp/acp-session-notifications.test.ts @@ -0,0 +1,80 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { AcpSessionRuntime, type AcpSessionEvent } from './acp-session-runtime' +import { AcpScriptedAgent } from './acp-scripted-agent.test-support' + +const opened: { close: () => void }[] = [] +function fixture() { + const agent = new AcpScriptedAgent() + const diagnostic = vi.fn() + const runtime = new AcpSessionRuntime(agent.stdout, agent.stdin, { onDiagnostic: diagnostic }) + opened.push(agent, runtime) + agent.on('initialize', (frame) => + agent.reply(frame, { protocolVersion: 1, agentCapabilities: { vendor: true } }) + ) + agent.on('session/new', (frame) => agent.reply(frame, { sessionId: 'session-1', vendor: true })) + return { agent, runtime, diagnostic } +} +afterEach(() => + opened + .splice(0) + .toReversed() + .forEach((resource) => resource.close()) +) + +describe('ACP session update compatibility', () => { + it.each([ + ['known', { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'hi' } }], + [ + 'unrecognized', + { sessionUpdate: 'tool_call', toolCallId: 't', title: 'Search', kind: 'web_search' } + ], + ['unrecognized', { sessionUpdate: 'tool_call_update', toolCallId: 't', status: 'cancelled' }], + ['unrecognized', { sessionUpdate: 'vendor_usage', tokens: 1 }], + [ + 'unrecognized', + { sessionUpdate: 'agent_message_chunk', content: { type: 'reasoning_summary', text: 'x' } } + ] + ] as const)( + 'delivers %s updates without losing their original fields (%j)', + async (kind, update) => { + const { agent, runtime, diagnostic } = fixture() + const events: AcpSessionEvent[] = [] + runtime.subscribe((event) => events.push(event)) + await runtime.start({ cwd: '/runtime/project', mcpServers: [] }) + const notification = { sessionId: 'session-1', update, vendor: 'retained' } + agent.notify('session/update', notification) + agent.notify('session/update', notification) + expect(events).toHaveLength(2) + expect(events[0]).toEqual( + kind === 'known' + ? { kind, notification } + : { kind, sessionId: 'session-1', raw: notification } + ) + expect(diagnostic).toHaveBeenCalledTimes(kind === 'known' ? 0 : 1) + } + ) + + it('rejects only envelopes missing the session id or update discriminator', async () => { + const { agent, runtime } = fixture() + const received = vi.fn() + runtime.subscribe(received) + await runtime.start({ cwd: '/runtime/project', mcpServers: [] }) + agent.notify('session/update', { update: { sessionUpdate: 'vendor' } }) + agent.notify('session/update', { sessionId: 'session-1', update: {} }) + expect(received).not.toHaveBeenCalled() + }) + + it('preserves extra session fields and exposes legacy model state as typed data', async () => { + const { agent, runtime } = fixture() + const models = { + currentModelId: 'model-1', + availableModels: [{ modelId: 'model-1', name: 'Model one' }] + } + agent.on('session/new', (frame) => + agent.reply(frame, { sessionId: 'session-1', models, vendor: true }) + ) + const started = await runtime.start({ cwd: '/runtime/project', mcpServers: [] }) + expect(started.response.models?.availableModels[0].modelId).toBe('model-1') + expect(started.response.vendor).toBe(true) + }) +}) diff --git a/src/main/acp/acp-session-runtime.test.ts b/src/main/acp/acp-session-runtime.test.ts index bdc1e0cd8256..068da3f4742f 100644 --- a/src/main/acp/acp-session-runtime.test.ts +++ b/src/main/acp/acp-session-runtime.test.ts @@ -2,16 +2,20 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { AcpAuthRequiredError, AcpConnectionClosedError, - AcpRequestTimeoutError + AcpRequestTimeoutError, + AcpRpcError } from './acp-errors' -import { AcpSessionRuntime, type AcpSessionRuntimeOptions } from './acp-session-runtime' +import { + AcpSessionRuntime, + type AcpSessionRuntimeOptions, + type AcpSessionEvent +} from './acp-session-runtime' import { AcpScriptedAgent, deferred, tick } from './acp-scripted-agent.test-support' import type { AgentCapabilities, - RequestPermissionResponse, - SessionNotification -} from './generated/protocol.gen' -import { SetSessionConfigOptionRequestSchema } from './generated/protocol.gen' + RequestPermissionResponse +} from './generated/acp-protocol.generated' +import { SetSessionConfigOptionRequestSchema } from './generated/acp-protocol.generated' const opened: { runtime: AcpSessionRuntime; agent: AcpScriptedAgent }[] = [] const startOptions = { cwd: '/runtime/project', mcpServers: [] } @@ -45,7 +49,7 @@ afterEach(() => { describe('ACP session runtime', () => { it('initializes once, starts a session, streams typed updates, and completes the turn', async () => { const { runtime, agent } = fixture() - const events: SessionNotification[] = [] + const events: AcpSessionEvent[] = [] const unsubscribe = runtime.subscribe((event) => events.push(event)) const updates = [ { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'Hello' } }, @@ -72,7 +76,9 @@ describe('ACP session runtime', () => { await Promise.all([runtime.initialize(), runtime.initialize()]) expect(await runtime.start(startOptions)).toMatchObject({ kind: 'new', sessionId: 'session-1' }) expect(await runtime.prompt([...textPrompt])).toEqual({ stopReason: 'end_turn' }) - expect(events.map((event) => event.update)).toEqual(updates) + expect( + events.map((event) => (event.kind === 'known' ? event.notification.update : event.raw.update)) + ).toEqual(updates) expect(agent.frames.filter((frame) => frame.method === 'initialize')).toHaveLength(1) expect(agent.frames[0].params).toEqual({ protocolVersion: 1, @@ -127,7 +133,11 @@ describe('ACP session runtime', () => { return { outcome: { outcome: 'selected', optionId: 'allow' } } }, onRequest: (method, params) => - method === '_vendor/question' ? { answer: params } : undefined + method === '_vendor/question' + ? { answer: params } + : (() => { + throw new AcpRpcError(-32601, 'Unknown method') + })() } ) agent.on('session/prompt', (frame) => { @@ -233,30 +243,16 @@ describe('ACP session runtime', () => { expect(agent.frames[2].params).toEqual({ methodId: 'login' }) }) - it('chooses an advertised agent authentication method when login is required', async () => { + it('leaves advertised authentication methods for the caller to choose', async () => { const { runtime, agent } = fixture() - let authenticated = false - agent.on('initialize', (frame) => - agent.reply(frame, { - protocolVersion: 1, - authMethods: [ - { id: 'terminal', name: 'Interactive login', type: 'terminal' }, - { id: 'agent', name: 'Agent login' } - ] - }) - ) - agent.on('session/new', (frame) => - authenticated - ? agent.reply(frame, { sessionId: 'session-1' }) - : agent.fail(frame, -32000, 'Login required') - ) - agent.on('authenticate', (frame) => { - expect(frame.params).toEqual({ methodId: 'agent' }) - authenticated = true - agent.reply(frame, {}) - }) - await runtime.start(startOptions) - expect(authenticated).toBe(true) + const authMethods = [ + { id: 'terminal', name: 'Interactive login', type: 'terminal' }, + { id: 'agent', name: 'Agent login' } + ] + agent.on('initialize', (frame) => agent.reply(frame, { protocolVersion: 1, authMethods })) + agent.on('session/new', (frame) => agent.fail(frame, -32000, 'Login required')) + await expect(runtime.start(startOptions)).rejects.toMatchObject({ authMethods }) + expect(agent.frames.map((frame) => frame.method)).toEqual(['initialize', 'session/new']) }) it('does not retry authentication indefinitely or start an interactive login', async () => { @@ -281,7 +277,9 @@ describe('ACP session runtime', () => { retry.agent.on('session/new', (frame) => retry.agent.fail(frame, -32000, 'Still requires login') ) - await expect(retry.runtime.start(startOptions)).rejects.toBeInstanceOf(AcpAuthRequiredError) + await expect( + retry.runtime.start({ ...startOptions, authMethodId: 'agent' }) + ).rejects.toBeInstanceOf(AcpAuthRequiredError) expect(retry.agent.frames.map((frame) => frame.method)).toEqual([ 'initialize', 'session/new', @@ -348,10 +346,10 @@ describe('ACP session runtime', () => { await rejected }) - it('ignores invalid updates and isolates event listener failures', async () => { + it('preserves unrecognized updates and isolates event listener failures', async () => { const diagnostics: string[] = [] const { runtime, agent } = fixture({}, { onDiagnostic: (message) => diagnostics.push(message) }) - const updates: SessionNotification[] = [] + const updates: AcpSessionEvent[] = [] runtime.subscribe(() => { throw new Error('Consumer failed') }) @@ -368,9 +366,13 @@ describe('ACP session runtime', () => { content: { type: 'text', text: 'valid', _meta: { vendor: true } } } }) - expect(updates).toHaveLength(1) - expect(updates[0].update).toMatchObject({ content: { _meta: { vendor: true } } }) - expect(diagnostics).toContain('Ignored invalid ACP session update') + expect(updates).toHaveLength(2) + expect(updates[0]).toMatchObject({ kind: 'unrecognized' }) + expect(updates[1]).toMatchObject({ + kind: 'known', + notification: { update: { content: { _meta: { vendor: true } } } } + }) + expect(diagnostics).toContain('Forwarded unrecognized ACP session update') expect(diagnostics.some((message) => message.includes('Consumer failed'))).toBe(true) }) diff --git a/src/main/acp/acp-session-runtime.ts b/src/main/acp/acp-session-runtime.ts index 095573150ce6..bfd0e02226d7 100644 --- a/src/main/acp/acp-session-runtime.ts +++ b/src/main/acp/acp-session-runtime.ts @@ -1,6 +1,7 @@ import type { Readable, Writable } from 'node:stream' -import type { z } from 'zod' -import { AcpRequestTimeoutError, AcpRpcError } from './acp-errors' +import { z } from 'zod' +import { MAX_TIMER_DELAY_MS } from '../../shared/timer-delay' +import { AcpAuthRequiredError, AcpRequestTimeoutError, AcpRpcError } from './acp-errors' import { AcpJsonRpcPeer, type AcpPeerOptions, type AcpRequestContext } from './acp-json-rpc-peer' import { AcpPermissionRequests, type AcpPermissionHandler } from './acp-permission-requests' import { @@ -9,7 +10,7 @@ import { type AcpSessionStartOptions } from './acp-session-setup' export type { AcpSessionStarted, AcpSessionStartOptions } from './acp-session-setup' -import { ACP_PROTOCOL_VERSION } from './generated/license.gen' +import { ACP_PROTOCOL_VERSION } from './generated/acp-protocol.generated' import { InitializeResponseSchema, AuthenticateResponseSchema, @@ -29,12 +30,19 @@ import { type SetSessionConfigOptionResponse, type SetSessionModeResponse, type SetSessionModelResponse -} from './generated/protocol.gen' +} from './generated/acp-protocol.generated' + +const updateEnvelopeSchema = z.looseObject({ + sessionId: z.string(), + update: z.looseObject({ sessionUpdate: z.string() }) +}) +export type AcpSessionEvent = + | { kind: 'known'; notification: SessionNotification } + | { kind: 'unrecognized'; sessionId: string; raw: z.infer } export type AcpSessionRuntimeOptions = { clientInfo?: InitializeRequest['clientInfo'] peer?: AcpPeerOptions - promptTimeoutMs?: number cancelTimeoutMs?: number onPermission?: AcpPermissionHandler onRequest?: (method: string, params: unknown, context: AcpRequestContext) => unknown @@ -50,24 +58,21 @@ type ActivePrompt = { export class AcpSessionRuntime { private readonly peer: AcpJsonRpcPeer private readonly permissions = new AcpPermissionRequests() - private readonly listeners = new Set<(event: SessionNotification) => void>() + private readonly listeners = new Set<(event: AcpSessionEvent) => void>() private initialized?: Promise private starting?: Promise private started?: AcpSessionStarted private activePrompt?: ActivePrompt + private reportedUpdateAnomaly = false constructor( input: Readable, output: Writable, private readonly options: AcpSessionRuntimeOptions = {} ) { - for (const timeout of [options.promptTimeoutMs, options.cancelTimeoutMs]) { - if ( - timeout !== undefined && - (!Number.isSafeInteger(timeout) || timeout <= 0 || timeout > 2_147_483_647) - ) { - throw new Error('ACP timeouts must be positive finite timer durations') - } + const timeout = options.cancelTimeoutMs + if (timeout !== undefined && (!Number.isSafeInteger(timeout) || timeout <= 0)) { + throw new Error('ACP timeouts must be positive finite timer durations') } this.peer = new AcpJsonRpcPeer( input, @@ -82,7 +87,7 @@ export class AcpSessionRuntime { ) } - subscribe(listener: (event: SessionNotification) => void): () => void { + subscribe(listener: (event: AcpSessionEvent) => void): () => void { this.listeners.add(listener) return () => { this.listeners.delete(listener) @@ -143,16 +148,14 @@ export class AcpSessionRuntime { return this.starting } - prompt(prompt: PromptRequest['prompt']): Promise { + async prompt(prompt: PromptRequest['prompt']): Promise { if (this.activePrompt) { - return Promise.reject(new Error('ACP prompt already in progress')) + throw new Error('ACP prompt already in progress') } const sessionId = this.sessionId() const active: ActivePrompt = { cancelling: false, - response: this.call('session/prompt', { sessionId, prompt }, PromptResponseSchema, { - timeoutMs: this.options.promptTimeoutMs ?? 30 * 60_000 - }) + response: this.call('session/prompt', { sessionId, prompt }, PromptResponseSchema) } this.activePrompt = active active.response = active.response.finally(() => { @@ -165,19 +168,23 @@ export class AcpSessionRuntime { } cancel(): Promise { - const sessionId = this.sessionId() + if (!this.started) { + return Promise.reject(new Error('ACP session has not started')) + } + const sessionId = this.started.sessionId const active = this.activePrompt if (!active) { return Promise.resolve() } active.cancelling = true this.permissions.cancel(sessionId) + this.peer.cancelIncomingRequests('session/request_permission') active.cancelPromise ??= this.cancelActive(sessionId, active) return active.cancelPromise } private async cancelActive(sessionId: string, active: ActivePrompt): Promise { - const timeoutMs = this.options.cancelTimeoutMs ?? 10_000 + const timeoutMs = Math.min(this.options.cancelTimeoutMs ?? 10_000, MAX_TIMER_DELAY_MS) let timer: ReturnType | undefined try { await Promise.race([ @@ -195,21 +202,21 @@ export class AcpSessionRuntime { } } - setMode(modeId: string): Promise { + async setMode(modeId: string): Promise { return this.call( 'session/set_mode', { sessionId: this.sessionId(), modeId }, SetSessionModeResponseSchema ) } - setModel(modelId: string): Promise { + async setModel(modelId: string): Promise { return this.call( 'session/set_model', { sessionId: this.sessionId(), modelId }, SetSessionModelResponseSchema ) } - setConfigOption( + async setConfigOption( configId: SetSessionConfigOptionRequest['configId'], value: SetSessionConfigOptionRequest['value'] ): Promise { @@ -221,6 +228,7 @@ export class AcpSessionRuntime { return this.call('session/set_config_option', request, SetSessionConfigOptionResponseSchema) } + // The process owner must call close on child exit, even if descendants keep stdio open. close(error?: Error): void { this.peer.close(error) this.listeners.clear() @@ -233,16 +241,10 @@ export class AcpSessionRuntime { return this.started.sessionId } - private async call( - method: string, - params: unknown, - schema: z.ZodType, - options?: { timeoutMs?: number } - ): Promise { - const result = await this.peer.request(method, params, options).catch((error) => { - // A timed-out mutation may still be executing; this runtime cannot safely reuse the session. - if (error instanceof AcpRequestTimeoutError) { - this.peer.close(error) + private async call(method: string, params: unknown, schema: z.ZodType): Promise { + const result = await this.peer.request(method, params, { timeoutMs: null }).catch((error) => { + if (error instanceof AcpRpcError && error.code === -32000) { + throw new AcpAuthRequiredError(error.message, error.data) } throw error }) @@ -255,7 +257,10 @@ export class AcpSessionRuntime { private handleRequest(method: string, params: unknown, context: AcpRequestContext): unknown { if (method !== 'session/request_permission') { - return this.options.onRequest?.(method, params, context) + if (!this.options.onRequest) { + throw new AcpRpcError(-32601, `Unknown ACP client method: ${method}`) + } + return this.options.onRequest(method, params, context) } const parsed = RequestPermissionRequestSchema.safeParse(params) if (!parsed.success) { @@ -275,14 +280,22 @@ export class AcpSessionRuntime { if (method !== 'session/update') { return } - const parsed = SessionNotificationSchema.safeParse(params) - if (!parsed.success) { - this.options.onDiagnostic?.('Ignored invalid ACP session update') + const envelope = updateEnvelopeSchema.safeParse(params) + if (!envelope.success) { + this.options.onDiagnostic?.('Ignored invalid ACP session update envelope') return } + const parsed = SessionNotificationSchema.safeParse(params) + const event: AcpSessionEvent = parsed.success + ? { kind: 'known', notification: parsed.data } + : { kind: 'unrecognized', sessionId: envelope.data.sessionId, raw: envelope.data } + if (!parsed.success && !this.reportedUpdateAnomaly) { + this.reportedUpdateAnomaly = true + this.options.onDiagnostic?.('Forwarded unrecognized ACP session update') + } for (const listener of this.listeners) { try { - listener(parsed.data) + listener(event) } catch (error) { this.options.onDiagnostic?.(`ACP event listener failed: ${String(error)}`) } diff --git a/src/main/acp/acp-session-setup.ts b/src/main/acp/acp-session-setup.ts index 6c81f0fc78e5..8296315eb269 100644 --- a/src/main/acp/acp-session-setup.ts +++ b/src/main/acp/acp-session-setup.ts @@ -5,17 +5,25 @@ import { LoadSessionResponseSchema, ResumeSessionResponseSchema, AuthenticateResponseSchema, + SessionModelStateSchema, type InitializeResponse, - type NewSessionRequest, - type NewSessionResponse, - type LoadSessionResponse, - type ResumeSessionResponse -} from './generated/protocol.gen' + type NewSessionRequest +} from './generated/acp-protocol.generated' + +const newSessionSchema = NewSessionResponseSchema.extend({ + models: SessionModelStateSchema.optional() +}) +const loadSessionSchema = LoadSessionResponseSchema.extend({ + models: SessionModelStateSchema.optional() +}) +const resumeSessionSchema = ResumeSessionResponseSchema.extend({ + models: SessionModelStateSchema.optional() +}) export type AcpSessionStarted = - | { kind: 'new'; sessionId: string; response: NewSessionResponse } - | { kind: 'load'; sessionId: string; response: LoadSessionResponse } - | { kind: 'resume'; sessionId: string; response: ResumeSessionResponse } + | { kind: 'new'; sessionId: string; response: z.infer } + | { kind: 'load'; sessionId: string; response: z.infer } + | { kind: 'resume'; sessionId: string; response: z.infer } export type AcpSessionStartOptions = NewSessionRequest & { sessionId?: string resumePreference?: 'load' | 'resume' @@ -31,7 +39,7 @@ export async function setupAcpSession( const setup = async (): Promise => { const { sessionId, resumePreference, authMethodId: _auth, ...params } = options if (sessionId === undefined) { - const response = await request('session/new', params, NewSessionResponseSchema) + const response = await request('session/new', params, newSessionSchema) return { kind: 'new', sessionId: response.sessionId, response } } const capabilities = initialized.agentCapabilities @@ -41,18 +49,14 @@ export async function setupAcpSession( return { kind: 'load', sessionId, - response: await request('session/load', { ...params, sessionId }, LoadSessionResponseSchema) + response: await request('session/load', { ...params, sessionId }, loadSessionSchema) } } if (resume) { return { kind: 'resume', sessionId, - response: await request( - 'session/resume', - { ...params, sessionId }, - ResumeSessionResponseSchema - ) + response: await request('session/resume', { ...params, sessionId }, resumeSessionSchema) } } throw new AcpRpcError(-32601, 'ACP agent cannot load or resume this session') @@ -63,13 +67,19 @@ export async function setupAcpSession( if (!(error instanceof AcpAuthRequiredError)) { throw error } - const method = initialized.authMethods?.find((method) => - options.authMethodId ? method.id === options.authMethodId : method.type !== 'terminal' - ) - if (!method || method.type === 'terminal') { - throw error + const required = new AcpAuthRequiredError(error.message, error.data, initialized.authMethods) + const method = initialized.authMethods?.find((method) => method.id === options.authMethodId) + if (!method) { + throw required + } + try { + await request('authenticate', { methodId: method.id }, AuthenticateResponseSchema) + return await setup() + } catch (failure) { + if (failure instanceof AcpAuthRequiredError) { + throw new AcpAuthRequiredError(failure.message, failure.data, initialized.authMethods) + } + throw failure } - await request('authenticate', { methodId: method.id }, AuthenticateResponseSchema) - return setup() } } diff --git a/src/main/acp/generated/acp-protocol.generated.ts b/src/main/acp/generated/acp-protocol.generated.ts new file mode 100644 index 000000000000..4438895c9e31 --- /dev/null +++ b/src/main/acp/generated/acp-protocol.generated.ts @@ -0,0 +1,1390 @@ +// Generated by config/scripts/acp/generate-protocol.mjs; do not edit. +// ACP schema-v1.21.0, legacy model API v0.11.6; SPDX-License-Identifier: Apache-2.0. +/* +Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + Copyright 2025 Zed Industries, Inc. and contributors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ +import { z } from 'zod' +export const ACP_SCHEMA_RELEASE = 'schema-v1.21.0' +export const ACP_LEGACY_MODEL_SCHEMA_RELEASE = 'v0.11.6' +export const ACP_PROTOCOL_VERSION = 1 +export const ProtocolVersionSchema = z.number().int().min(0).max(65535) +export type ProtocolVersion = z.infer + +export const FileSystemCapabilitiesSchema = z.looseObject({ + readTextFile: z.boolean().optional(), + writeTextFile: z.boolean().optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type FileSystemCapabilities = z.infer + +export const CompactionCapabilitiesSchema = z.looseObject({}) +export type CompactionCapabilities = z.infer + +export const BooleanConfigOptionCapabilitiesSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type BooleanConfigOptionCapabilities = z.infer + +export const SessionConfigOptionsCapabilitiesSchema = z.looseObject({ + boolean: z.union([BooleanConfigOptionCapabilitiesSchema, z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type SessionConfigOptionsCapabilities = z.infer< + typeof SessionConfigOptionsCapabilitiesSchema +> + +export const ClientSessionCapabilitiesSchema = z.looseObject({ + compaction: z.union([CompactionCapabilitiesSchema, z.null()]).optional(), + configOptions: z.union([SessionConfigOptionsCapabilitiesSchema, z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type ClientSessionCapabilities = z.infer + +export const PlanCapabilitiesSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type PlanCapabilities = z.infer + +export const AuthCapabilitiesSchema = z.looseObject({ + terminal: z.boolean().optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type AuthCapabilities = z.infer + +export const ElicitationFormCapabilitiesSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type ElicitationFormCapabilities = z.infer + +export const ElicitationUrlCapabilitiesSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type ElicitationUrlCapabilities = z.infer + +export const ElicitationCapabilitiesSchema = z.looseObject({ + form: z.union([ElicitationFormCapabilitiesSchema, z.null()]).optional(), + url: z.union([ElicitationUrlCapabilitiesSchema, z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type ElicitationCapabilities = z.infer + +export const NesJumpCapabilitiesSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type NesJumpCapabilities = z.infer + +export const NesRenameCapabilitiesSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type NesRenameCapabilities = z.infer + +export const NesSearchAndReplaceCapabilitiesSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type NesSearchAndReplaceCapabilities = z.infer + +export const ClientNesCapabilitiesSchema = z.looseObject({ + jump: z.union([NesJumpCapabilitiesSchema, z.null()]).optional(), + rename: z.union([NesRenameCapabilitiesSchema, z.null()]).optional(), + searchAndReplace: z.union([NesSearchAndReplaceCapabilitiesSchema, z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type ClientNesCapabilities = z.infer + +export const PositionEncodingKindSchema = z.union([ + z.literal('utf-16'), + z.literal('utf-32'), + z.literal('utf-8') +]) +export type PositionEncodingKind = z.infer + +export const ClientCapabilitiesSchema = z.looseObject({ + fs: FileSystemCapabilitiesSchema.optional(), + terminal: z.boolean().optional(), + session: z.union([ClientSessionCapabilitiesSchema, z.null()]).optional(), + plan: z.union([PlanCapabilitiesSchema, z.null()]).optional(), + auth: AuthCapabilitiesSchema.optional(), + elicitation: z.union([ElicitationCapabilitiesSchema, z.null()]).optional(), + nes: z.union([ClientNesCapabilitiesSchema, z.null()]).optional(), + positionEncodings: z.array(PositionEncodingKindSchema).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type ClientCapabilities = z.infer + +export const ImplementationSchema = z.looseObject({ + name: z.string(), + title: z.union([z.string(), z.null()]).optional(), + version: z.string(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type Implementation = z.infer + +export const InitializeRequestSchema = z.looseObject({ + protocolVersion: ProtocolVersionSchema, + clientCapabilities: ClientCapabilitiesSchema.optional(), + clientInfo: z.union([ImplementationSchema, z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type InitializeRequest = z.infer + +export const PromptCapabilitiesSchema = z.looseObject({ + image: z.boolean().optional(), + audio: z.boolean().optional(), + embeddedContext: z.boolean().optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type PromptCapabilities = z.infer + +export const McpCapabilitiesSchema = z.looseObject({ + http: z.boolean().optional(), + sse: z.boolean().optional(), + acp: z.boolean().optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type McpCapabilities = z.infer + +export const SessionListCapabilitiesSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type SessionListCapabilities = z.infer + +export const SessionDeleteCapabilitiesSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type SessionDeleteCapabilities = z.infer + +export const SessionAdditionalDirectoriesCapabilitiesSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type SessionAdditionalDirectoriesCapabilities = z.infer< + typeof SessionAdditionalDirectoriesCapabilitiesSchema +> + +export const SessionForkCapabilitiesSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type SessionForkCapabilities = z.infer + +export const SessionResumeCapabilitiesSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type SessionResumeCapabilities = z.infer + +export const SessionCloseCapabilitiesSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type SessionCloseCapabilities = z.infer + +export const SessionCapabilitiesSchema = z.looseObject({ + list: z.union([SessionListCapabilitiesSchema, z.null()]).optional(), + delete: z.union([SessionDeleteCapabilitiesSchema, z.null()]).optional(), + additionalDirectories: z + .union([SessionAdditionalDirectoriesCapabilitiesSchema, z.null()]) + .optional(), + fork: z.union([SessionForkCapabilitiesSchema, z.null()]).optional(), + resume: z.union([SessionResumeCapabilitiesSchema, z.null()]).optional(), + close: z.union([SessionCloseCapabilitiesSchema, z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type SessionCapabilities = z.infer + +export const LogoutCapabilitiesSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type LogoutCapabilities = z.infer + +export const AgentAuthCapabilitiesSchema = z.looseObject({ + logout: z.union([LogoutCapabilitiesSchema, z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type AgentAuthCapabilities = z.infer + +export const ProvidersCapabilitiesSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type ProvidersCapabilities = z.infer + +export const NesDocumentDidOpenCapabilitiesSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type NesDocumentDidOpenCapabilities = z.infer + +export const TextDocumentSyncKindSchema = z.union([z.literal('full'), z.literal('incremental')]) +export type TextDocumentSyncKind = z.infer + +export const NesDocumentDidChangeCapabilitiesSchema = z.looseObject({ + syncKind: TextDocumentSyncKindSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type NesDocumentDidChangeCapabilities = z.infer< + typeof NesDocumentDidChangeCapabilitiesSchema +> + +export const NesDocumentDidCloseCapabilitiesSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type NesDocumentDidCloseCapabilities = z.infer + +export const NesDocumentDidSaveCapabilitiesSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type NesDocumentDidSaveCapabilities = z.infer + +export const NesDocumentDidFocusCapabilitiesSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type NesDocumentDidFocusCapabilities = z.infer + +export const NesDocumentEventCapabilitiesSchema = z.looseObject({ + didOpen: z.union([NesDocumentDidOpenCapabilitiesSchema, z.null()]).optional(), + didChange: z.union([NesDocumentDidChangeCapabilitiesSchema, z.null()]).optional(), + didClose: z.union([NesDocumentDidCloseCapabilitiesSchema, z.null()]).optional(), + didSave: z.union([NesDocumentDidSaveCapabilitiesSchema, z.null()]).optional(), + didFocus: z.union([NesDocumentDidFocusCapabilitiesSchema, z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type NesDocumentEventCapabilities = z.infer + +export const NesEventCapabilitiesSchema = z.looseObject({ + document: z.union([NesDocumentEventCapabilitiesSchema, z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type NesEventCapabilities = z.infer + +export const NesRecentFilesCapabilitiesSchema = z.looseObject({ + maxCount: z.union([z.number().int().min(0), z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type NesRecentFilesCapabilities = z.infer + +export const NesRelatedSnippetsCapabilitiesSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type NesRelatedSnippetsCapabilities = z.infer + +export const NesEditHistoryCapabilitiesSchema = z.looseObject({ + maxCount: z.union([z.number().int().min(0), z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type NesEditHistoryCapabilities = z.infer + +export const NesUserActionsCapabilitiesSchema = z.looseObject({ + maxCount: z.union([z.number().int().min(0), z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type NesUserActionsCapabilities = z.infer + +export const NesOpenFilesCapabilitiesSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type NesOpenFilesCapabilities = z.infer + +export const NesDiagnosticsCapabilitiesSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type NesDiagnosticsCapabilities = z.infer + +export const NesContextCapabilitiesSchema = z.looseObject({ + recentFiles: z.union([NesRecentFilesCapabilitiesSchema, z.null()]).optional(), + relatedSnippets: z.union([NesRelatedSnippetsCapabilitiesSchema, z.null()]).optional(), + editHistory: z.union([NesEditHistoryCapabilitiesSchema, z.null()]).optional(), + userActions: z.union([NesUserActionsCapabilitiesSchema, z.null()]).optional(), + openFiles: z.union([NesOpenFilesCapabilitiesSchema, z.null()]).optional(), + diagnostics: z.union([NesDiagnosticsCapabilitiesSchema, z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type NesContextCapabilities = z.infer + +export const NesCapabilitiesSchema = z.looseObject({ + events: z.union([NesEventCapabilitiesSchema, z.null()]).optional(), + context: z.union([NesContextCapabilitiesSchema, z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type NesCapabilities = z.infer + +export const AgentCapabilitiesSchema = z.looseObject({ + loadSession: z.boolean().optional(), + promptCapabilities: PromptCapabilitiesSchema.optional(), + mcpCapabilities: McpCapabilitiesSchema.optional(), + sessionCapabilities: SessionCapabilitiesSchema.optional(), + auth: AgentAuthCapabilitiesSchema.optional(), + providers: z.union([ProvidersCapabilitiesSchema, z.null()]).optional(), + nes: z.union([NesCapabilitiesSchema, z.null()]).optional(), + positionEncoding: z.union([PositionEncodingKindSchema, z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type AgentCapabilities = z.infer + +export const AuthMethodIdSchema = z.string() +export type AuthMethodId = z.infer + +export const AuthMethodTerminalSchema = z.looseObject({ + id: AuthMethodIdSchema, + name: z.string(), + description: z.union([z.string(), z.null()]).optional(), + args: z.array(z.string()).optional(), + env: z.looseObject({}).catchall(z.string()).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type AuthMethodTerminal = z.infer + +export const AuthMethodAgentSchema = z.looseObject({ + id: AuthMethodIdSchema, + name: z.string(), + description: z.union([z.string(), z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type AuthMethodAgent = z.infer + +export const AuthMethodSchema = z.union([ + z.intersection(z.looseObject({ type: z.literal('terminal') }), AuthMethodTerminalSchema), + AuthMethodAgentSchema +]) +export type AuthMethod = z.infer + +export const InitializeResponseSchema = z.looseObject({ + protocolVersion: ProtocolVersionSchema, + agentCapabilities: AgentCapabilitiesSchema.optional(), + authMethods: z.array(AuthMethodSchema).optional(), + agentInfo: z.union([ImplementationSchema, z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type InitializeResponse = z.infer + +export const AuthenticateRequestSchema = z.looseObject({ + methodId: AuthMethodIdSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type AuthenticateRequest = z.infer + +export const AuthenticateResponseSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type AuthenticateResponse = z.infer + +export const HttpHeaderSchema = z.looseObject({ + name: z.string(), + value: z.string(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type HttpHeader = z.infer + +export const McpServerHttpSchema = z.looseObject({ + name: z.string(), + url: z.string(), + headers: z.array(HttpHeaderSchema), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type McpServerHttp = z.infer + +export const McpServerSseSchema = z.looseObject({ + name: z.string(), + url: z.string(), + headers: z.array(HttpHeaderSchema), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type McpServerSse = z.infer + +export const McpServerAcpIdSchema = z.string() +export type McpServerAcpId = z.infer + +export const McpServerAcpSchema = z.looseObject({ + name: z.string(), + serverId: McpServerAcpIdSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type McpServerAcp = z.infer + +export const EnvVariableSchema = z.looseObject({ + name: z.string(), + value: z.string(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type EnvVariable = z.infer + +export const McpServerStdioSchema = z.looseObject({ + name: z.string(), + command: z.string(), + args: z.array(z.string()), + env: z.array(EnvVariableSchema), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type McpServerStdio = z.infer + +export const McpServerSchema = z.union([ + z.intersection(z.looseObject({ type: z.literal('http') }), McpServerHttpSchema), + z.intersection(z.looseObject({ type: z.literal('sse') }), McpServerSseSchema), + z.intersection(z.looseObject({ type: z.literal('acp') }), McpServerAcpSchema), + McpServerStdioSchema +]) +export type McpServer = z.infer + +export const NewSessionRequestSchema = z.looseObject({ + cwd: z.string(), + additionalDirectories: z.array(z.string()).optional(), + mcpServers: z.array(McpServerSchema), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type NewSessionRequest = z.infer + +export const SessionIdSchema = z.string() +export type SessionId = z.infer + +export const SessionModeIdSchema = z.string() +export type SessionModeId = z.infer + +export const SessionModeSchema = z.looseObject({ + id: SessionModeIdSchema, + name: z.string(), + description: z.union([z.string(), z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type SessionMode = z.infer + +export const SessionModeStateSchema = z.looseObject({ + currentModeId: SessionModeIdSchema, + availableModes: z.array(SessionModeSchema), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type SessionModeState = z.infer + +export const SessionConfigIdSchema = z.string() +export type SessionConfigId = z.infer + +export const SessionConfigOptionCategorySchema = z.union([ + z.literal('mode'), + z.literal('model'), + z.literal('model_config'), + z.literal('thought_level'), + z.string() +]) +export type SessionConfigOptionCategory = z.infer + +export const SessionConfigValueIdSchema = z.string() +export type SessionConfigValueId = z.infer + +export const SessionConfigSelectOptionSchema = z.looseObject({ + value: SessionConfigValueIdSchema, + name: z.string(), + description: z.union([z.string(), z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type SessionConfigSelectOption = z.infer + +export const SessionConfigGroupIdSchema = z.string() +export type SessionConfigGroupId = z.infer + +export const SessionConfigSelectGroupSchema = z.looseObject({ + group: SessionConfigGroupIdSchema, + name: z.string(), + options: z.array(SessionConfigSelectOptionSchema), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type SessionConfigSelectGroup = z.infer + +export const SessionConfigSelectOptionsSchema = z.union([ + z.array(SessionConfigSelectOptionSchema), + z.array(SessionConfigSelectGroupSchema) +]) +export type SessionConfigSelectOptions = z.infer + +export const SessionConfigSelectSchema = z.looseObject({ + currentValue: SessionConfigValueIdSchema, + options: SessionConfigSelectOptionsSchema +}) +export type SessionConfigSelect = z.infer + +export const SessionConfigBooleanSchema = z.looseObject({ currentValue: z.boolean() }) +export type SessionConfigBoolean = z.infer + +export const SessionConfigOptionSchema = z.intersection( + z.looseObject({ + id: SessionConfigIdSchema, + name: z.string(), + description: z.union([z.string(), z.null()]).optional(), + category: z.union([SessionConfigOptionCategorySchema, z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() + }), + z.union([ + z.intersection(z.looseObject({ type: z.literal('select') }), SessionConfigSelectSchema), + z.intersection(z.looseObject({ type: z.literal('boolean') }), SessionConfigBooleanSchema) + ]) +) +export type SessionConfigOption = z.infer + +export const NewSessionResponseSchema = z.looseObject({ + sessionId: SessionIdSchema, + modes: z.union([SessionModeStateSchema, z.null()]).optional(), + configOptions: z.union([z.array(SessionConfigOptionSchema), z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type NewSessionResponse = z.infer + +export const LoadSessionRequestSchema = z.looseObject({ + mcpServers: z.array(McpServerSchema), + cwd: z.string(), + additionalDirectories: z.array(z.string()).optional(), + sessionId: SessionIdSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type LoadSessionRequest = z.infer + +export const LoadSessionResponseSchema = z.looseObject({ + modes: z.union([SessionModeStateSchema, z.null()]).optional(), + configOptions: z.union([z.array(SessionConfigOptionSchema), z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type LoadSessionResponse = z.infer + +export const ResumeSessionRequestSchema = z.looseObject({ + sessionId: SessionIdSchema, + cwd: z.string(), + additionalDirectories: z.array(z.string()).optional(), + mcpServers: z.array(McpServerSchema).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type ResumeSessionRequest = z.infer + +export const ResumeSessionResponseSchema = z.looseObject({ + modes: z.union([SessionModeStateSchema, z.null()]).optional(), + configOptions: z.union([z.array(SessionConfigOptionSchema), z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type ResumeSessionResponse = z.infer + +export const RoleSchema = z.union([z.literal('assistant'), z.literal('user')]) +export type Role = z.infer + +export const AnnotationsSchema = z.looseObject({ + audience: z.union([z.array(RoleSchema), z.null()]).optional(), + lastModified: z.union([z.string(), z.null()]).optional(), + priority: z.union([z.number(), z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type Annotations = z.infer + +export const TextContentSchema = z.looseObject({ + annotations: z.union([AnnotationsSchema, z.null()]).optional(), + text: z.string(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type TextContent = z.infer + +export const ImageContentSchema = z.looseObject({ + annotations: z.union([AnnotationsSchema, z.null()]).optional(), + data: z.string(), + mimeType: z.string(), + uri: z.union([z.string(), z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type ImageContent = z.infer + +export const AudioContentSchema = z.looseObject({ + annotations: z.union([AnnotationsSchema, z.null()]).optional(), + data: z.string(), + mimeType: z.string(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type AudioContent = z.infer + +export const ResourceLinkSchema = z.looseObject({ + annotations: z.union([AnnotationsSchema, z.null()]).optional(), + description: z.union([z.string(), z.null()]).optional(), + mimeType: z.union([z.string(), z.null()]).optional(), + name: z.string(), + size: z.union([z.number().int(), z.null()]).optional(), + title: z.union([z.string(), z.null()]).optional(), + uri: z.string(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type ResourceLink = z.infer + +export const TextResourceContentsSchema = z.looseObject({ + mimeType: z.union([z.string(), z.null()]).optional(), + text: z.string(), + uri: z.string(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type TextResourceContents = z.infer + +export const BlobResourceContentsSchema = z.looseObject({ + blob: z.string(), + mimeType: z.union([z.string(), z.null()]).optional(), + uri: z.string(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type BlobResourceContents = z.infer + +export const EmbeddedResourceResourceSchema = z.union([ + TextResourceContentsSchema, + BlobResourceContentsSchema +]) +export type EmbeddedResourceResource = z.infer + +export const EmbeddedResourceSchema = z.looseObject({ + annotations: z.union([AnnotationsSchema, z.null()]).optional(), + resource: EmbeddedResourceResourceSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type EmbeddedResource = z.infer + +export const ContentBlockSchema = z.union([ + z.intersection(z.looseObject({ type: z.literal('text') }), TextContentSchema), + z.intersection(z.looseObject({ type: z.literal('image') }), ImageContentSchema), + z.intersection(z.looseObject({ type: z.literal('audio') }), AudioContentSchema), + z.intersection(z.looseObject({ type: z.literal('resource_link') }), ResourceLinkSchema), + z.intersection(z.looseObject({ type: z.literal('resource') }), EmbeddedResourceSchema) +]) +export type ContentBlock = z.infer + +export const PromptRequestSchema = z.looseObject({ + sessionId: SessionIdSchema, + prompt: z.array(ContentBlockSchema), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type PromptRequest = z.infer + +export const StopReasonSchema = z.union([ + z.literal('end_turn'), + z.literal('max_tokens'), + z.literal('max_turn_requests'), + z.literal('refusal'), + z.literal('cancelled') +]) +export type StopReason = z.infer + +export const UsageSchema = z.looseObject({ + totalTokens: z.number().int().min(0), + inputTokens: z.number().int().min(0), + outputTokens: z.number().int().min(0), + thoughtTokens: z.union([z.number().int().min(0), z.null()]).optional(), + cachedReadTokens: z.union([z.number().int().min(0), z.null()]).optional(), + cachedWriteTokens: z.union([z.number().int().min(0), z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type Usage = z.infer + +export const PromptResponseSchema = z.looseObject({ + stopReason: StopReasonSchema, + usage: z.union([UsageSchema, z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type PromptResponse = z.infer + +export const CancelNotificationSchema = z.looseObject({ + sessionId: SessionIdSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type CancelNotification = z.infer + +export const MessageIdSchema = z.string() +export type MessageId = z.infer + +export const ContentChunkSchema = z.looseObject({ + content: ContentBlockSchema, + messageId: z.union([MessageIdSchema, z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type ContentChunk = z.infer + +export const ToolCallIdSchema = z.string() +export type ToolCallId = z.infer + +export const ToolKindSchema = z.union([ + z.literal('read'), + z.literal('edit'), + z.literal('delete'), + z.literal('move'), + z.literal('search'), + z.literal('execute'), + z.literal('think'), + z.literal('fetch'), + z.literal('switch_mode'), + z.literal('other') +]) +export type ToolKind = z.infer + +export const ToolCallStatusSchema = z.union([ + z.literal('pending'), + z.literal('in_progress'), + z.literal('completed'), + z.literal('failed') +]) +export type ToolCallStatus = z.infer + +export const ContentSchema = z.looseObject({ + content: ContentBlockSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type Content = z.infer + +export const DiffSchema = z.looseObject({ + path: z.string(), + oldText: z.union([z.string(), z.null()]).optional(), + newText: z.string(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type Diff = z.infer + +export const TerminalIdSchema = z.string() +export type TerminalId = z.infer + +export const TerminalSchema = z.looseObject({ + terminalId: TerminalIdSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type Terminal = z.infer + +export const ToolCallContentSchema = z.union([ + z.intersection(z.looseObject({ type: z.literal('content') }), ContentSchema), + z.intersection(z.looseObject({ type: z.literal('diff') }), DiffSchema), + z.intersection(z.looseObject({ type: z.literal('terminal') }), TerminalSchema) +]) +export type ToolCallContent = z.infer + +export const ToolCallLocationSchema = z.looseObject({ + path: z.string(), + line: z.union([z.number().int().min(0), z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type ToolCallLocation = z.infer + +export const ToolCallSchema = z.looseObject({ + toolCallId: ToolCallIdSchema, + title: z.string(), + name: z.union([z.string(), z.null()]).optional(), + kind: ToolKindSchema.optional(), + status: ToolCallStatusSchema.optional(), + content: z.array(ToolCallContentSchema).optional(), + locations: z.array(ToolCallLocationSchema).optional(), + rawInput: z.unknown().optional(), + rawOutput: z.unknown().optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type ToolCall = z.infer + +export const ToolCallUpdateSchema = z.looseObject({ + toolCallId: ToolCallIdSchema, + kind: z.union([ToolKindSchema, z.null()]).optional(), + status: z.union([ToolCallStatusSchema, z.null()]).optional(), + title: z.union([z.string(), z.null()]).optional(), + name: z.union([z.string(), z.null()]).optional(), + content: z.union([z.array(ToolCallContentSchema), z.null()]).optional(), + locations: z.union([z.array(ToolCallLocationSchema), z.null()]).optional(), + rawInput: z.unknown().optional(), + rawOutput: z.unknown().optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type ToolCallUpdate = z.infer + +export const PlanEntryPrioritySchema = z.union([ + z.literal('high'), + z.literal('medium'), + z.literal('low') +]) +export type PlanEntryPriority = z.infer + +export const PlanEntryStatusSchema = z.union([ + z.literal('pending'), + z.literal('in_progress'), + z.literal('completed') +]) +export type PlanEntryStatus = z.infer + +export const PlanEntrySchema = z.looseObject({ + content: z.string(), + priority: PlanEntryPrioritySchema, + status: PlanEntryStatusSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type PlanEntry = z.infer + +export const PlanSchema = z.looseObject({ + entries: z.array(PlanEntrySchema), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type Plan = z.infer + +export const PlanIdSchema = z.string() +export type PlanId = z.infer + +export const PlanItemsSchema = z.looseObject({ + planId: PlanIdSchema, + entries: z.array(PlanEntrySchema), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type PlanItems = z.infer + +export const PlanFileSchema = z.looseObject({ + planId: PlanIdSchema, + uri: z.string(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type PlanFile = z.infer + +export const PlanMarkdownSchema = z.looseObject({ + planId: PlanIdSchema, + content: z.string(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type PlanMarkdown = z.infer + +export const PlanUpdateContentSchema = z.union([ + z.intersection(z.looseObject({ type: z.literal('items') }), PlanItemsSchema), + z.intersection(z.looseObject({ type: z.literal('file') }), PlanFileSchema), + z.intersection(z.looseObject({ type: z.literal('markdown') }), PlanMarkdownSchema) +]) +export type PlanUpdateContent = z.infer + +export const PlanUpdateSchema = z.looseObject({ + plan: PlanUpdateContentSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type PlanUpdate = z.infer + +export const PlanRemovedSchema = z.looseObject({ + planId: PlanIdSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type PlanRemoved = z.infer + +export const UnstructuredCommandInputSchema = z.looseObject({ + hint: z.string(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type UnstructuredCommandInput = z.infer + +export const AvailableCommandInputSchema = z.union([UnstructuredCommandInputSchema]) +export type AvailableCommandInput = z.infer + +export const AvailableCommandSchema = z.looseObject({ + name: z.string(), + description: z.string(), + input: z.union([AvailableCommandInputSchema, z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type AvailableCommand = z.infer + +export const AvailableCommandsUpdateSchema = z.looseObject({ + availableCommands: z.array(AvailableCommandSchema), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type AvailableCommandsUpdate = z.infer + +export const CurrentModeUpdateSchema = z.looseObject({ + currentModeId: SessionModeIdSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type CurrentModeUpdate = z.infer + +export const ConfigOptionUpdateSchema = z.looseObject({ + configOptions: z.array(SessionConfigOptionSchema), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type ConfigOptionUpdate = z.infer + +export const SessionInfoUpdateSchema = z.looseObject({ + title: z.union([z.string(), z.null()]).optional(), + updatedAt: z.union([z.string(), z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type SessionInfoUpdate = z.infer + +export const CostSchema = z.looseObject({ + amount: z.number(), + currency: z.string(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type Cost = z.infer + +export const UsageUpdateSchema = z.looseObject({ + used: z.number().int().min(0), + size: z.number().int().min(0), + cost: z.union([CostSchema, z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type UsageUpdate = z.infer + +export const CompactionIdSchema = z.string() +export type CompactionId = z.infer + +export const CompactionStatusSchema = z.union([ + z.literal('in_progress'), + z.literal('completed'), + z.literal('failed'), + z.literal('cancelled'), + z.string() +]) +export type CompactionStatus = z.infer + +export const CompactionUpdateSchema = z.looseObject({ + compactionId: CompactionIdSchema, + status: CompactionStatusSchema, + summary: z.union([z.array(ContentBlockSchema), z.null()]).optional(), + error: z.union([z.string(), z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type CompactionUpdate = z.infer + +export const CompactionSummaryChunkSchema = z.looseObject({ + compactionId: CompactionIdSchema, + content: ContentBlockSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type CompactionSummaryChunk = z.infer + +export const SessionUpdateSchema = z.union([ + z.intersection( + z.looseObject({ sessionUpdate: z.literal('user_message_chunk') }), + ContentChunkSchema + ), + z.intersection( + z.looseObject({ sessionUpdate: z.literal('agent_message_chunk') }), + ContentChunkSchema + ), + z.intersection( + z.looseObject({ sessionUpdate: z.literal('agent_thought_chunk') }), + ContentChunkSchema + ), + z.intersection(z.looseObject({ sessionUpdate: z.literal('tool_call') }), ToolCallSchema), + z.intersection( + z.looseObject({ sessionUpdate: z.literal('tool_call_update') }), + ToolCallUpdateSchema + ), + z.intersection(z.looseObject({ sessionUpdate: z.literal('plan') }), PlanSchema), + z.intersection(z.looseObject({ sessionUpdate: z.literal('plan_update') }), PlanUpdateSchema), + z.intersection(z.looseObject({ sessionUpdate: z.literal('plan_removed') }), PlanRemovedSchema), + z.intersection( + z.looseObject({ sessionUpdate: z.literal('available_commands_update') }), + AvailableCommandsUpdateSchema + ), + z.intersection( + z.looseObject({ sessionUpdate: z.literal('current_mode_update') }), + CurrentModeUpdateSchema + ), + z.intersection( + z.looseObject({ sessionUpdate: z.literal('config_option_update') }), + ConfigOptionUpdateSchema + ), + z.intersection( + z.looseObject({ sessionUpdate: z.literal('session_info_update') }), + SessionInfoUpdateSchema + ), + z.intersection(z.looseObject({ sessionUpdate: z.literal('usage_update') }), UsageUpdateSchema), + z.intersection( + z.looseObject({ sessionUpdate: z.literal('compaction_update') }), + CompactionUpdateSchema + ), + z.intersection( + z.looseObject({ sessionUpdate: z.literal('compaction_summary_chunk') }), + CompactionSummaryChunkSchema + ) +]) +export type SessionUpdate = z.infer + +export const SessionNotificationSchema = z.looseObject({ + sessionId: SessionIdSchema, + update: SessionUpdateSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type SessionNotification = z.infer + +export const PermissionOptionIdSchema = z.string() +export type PermissionOptionId = z.infer + +export const PermissionOptionKindSchema = z.union([ + z.literal('allow_once'), + z.literal('allow_always'), + z.literal('reject_once'), + z.literal('reject_always') +]) +export type PermissionOptionKind = z.infer + +export const PermissionOptionSchema = z.looseObject({ + optionId: PermissionOptionIdSchema, + name: z.string(), + kind: PermissionOptionKindSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type PermissionOption = z.infer + +export const RequestPermissionRequestSchema = z.looseObject({ + sessionId: SessionIdSchema, + toolCall: ToolCallUpdateSchema, + options: z.array(PermissionOptionSchema), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type RequestPermissionRequest = z.infer + +export const SelectedPermissionOutcomeSchema = z.looseObject({ + optionId: PermissionOptionIdSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type SelectedPermissionOutcome = z.infer + +export const RequestPermissionOutcomeSchema = z.union([ + z.looseObject({ outcome: z.literal('cancelled') }), + z.intersection(z.looseObject({ outcome: z.literal('selected') }), SelectedPermissionOutcomeSchema) +]) +export type RequestPermissionOutcome = z.infer + +export const RequestPermissionResponseSchema = z.looseObject({ + outcome: RequestPermissionOutcomeSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type RequestPermissionResponse = z.infer + +export const SetSessionModeRequestSchema = z.looseObject({ + sessionId: SessionIdSchema, + modeId: SessionModeIdSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type SetSessionModeRequest = z.infer + +export const SetSessionModeResponseSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type SetSessionModeResponse = z.infer + +export const ModelIdSchema = z.string() +export type ModelId = z.infer + +export const SetSessionModelRequestSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional(), + modelId: ModelIdSchema, + sessionId: SessionIdSchema +}) +export type SetSessionModelRequest = z.infer + +export const SetSessionModelResponseSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type SetSessionModelResponse = z.infer + +export const ModelInfoSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional(), + description: z.union([z.string(), z.null()]).optional(), + modelId: ModelIdSchema, + name: z.string() +}) +export type ModelInfo = z.infer + +export const SessionModelStateSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional(), + availableModels: z.array(ModelInfoSchema), + currentModelId: ModelIdSchema +}) +export type SessionModelState = z.infer + +export const SetSessionConfigOptionRequestSchema = z.intersection( + z.looseObject({ + sessionId: SessionIdSchema, + configId: SessionConfigIdSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() + }), + z.union([ + z.looseObject({ value: z.boolean(), type: z.literal('boolean') }), + z.looseObject({ value: SessionConfigValueIdSchema }) + ]) +) +export type SetSessionConfigOptionRequest = z.infer + +export const SetSessionConfigOptionResponseSchema = z.looseObject({ + configOptions: z.array(SessionConfigOptionSchema), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type SetSessionConfigOptionResponse = z.infer + +export const ReadTextFileRequestSchema = z.looseObject({ + sessionId: SessionIdSchema, + path: z.string(), + line: z.union([z.number().int().min(0), z.null()]).optional(), + limit: z.union([z.number().int().min(0), z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type ReadTextFileRequest = z.infer + +export const ReadTextFileResponseSchema = z.looseObject({ + content: z.string(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type ReadTextFileResponse = z.infer + +export const WriteTextFileRequestSchema = z.looseObject({ + sessionId: SessionIdSchema, + path: z.string(), + content: z.string(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type WriteTextFileRequest = z.infer + +export const WriteTextFileResponseSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type WriteTextFileResponse = z.infer + +export const CreateTerminalRequestSchema = z.looseObject({ + sessionId: SessionIdSchema, + command: z.string(), + args: z.array(z.string()).optional(), + env: z.array(EnvVariableSchema).optional(), + cwd: z.union([z.string(), z.null()]).optional(), + outputByteLimit: z.union([z.number().int().min(0), z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type CreateTerminalRequest = z.infer + +export const CreateTerminalResponseSchema = z.looseObject({ + terminalId: TerminalIdSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type CreateTerminalResponse = z.infer + +export const TerminalOutputRequestSchema = z.looseObject({ + sessionId: SessionIdSchema, + terminalId: TerminalIdSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type TerminalOutputRequest = z.infer + +export const TerminalExitStatusSchema = z.looseObject({ + exitCode: z.union([z.number().int().min(0), z.null()]).optional(), + signal: z.union([z.string(), z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type TerminalExitStatus = z.infer + +export const TerminalOutputResponseSchema = z.looseObject({ + output: z.string(), + truncated: z.boolean(), + exitStatus: z.union([TerminalExitStatusSchema, z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type TerminalOutputResponse = z.infer + +export const ReleaseTerminalRequestSchema = z.looseObject({ + sessionId: SessionIdSchema, + terminalId: TerminalIdSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type ReleaseTerminalRequest = z.infer + +export const ReleaseTerminalResponseSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type ReleaseTerminalResponse = z.infer + +export const WaitForTerminalExitRequestSchema = z.looseObject({ + sessionId: SessionIdSchema, + terminalId: TerminalIdSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type WaitForTerminalExitRequest = z.infer + +export const WaitForTerminalExitResponseSchema = z.looseObject({ + exitCode: z.union([z.number().int().min(0), z.null()]).optional(), + signal: z.union([z.string(), z.null()]).optional(), + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type WaitForTerminalExitResponse = z.infer + +export const KillTerminalRequestSchema = z.looseObject({ + sessionId: SessionIdSchema, + terminalId: TerminalIdSchema, + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type KillTerminalRequest = z.infer + +export const KillTerminalResponseSchema = z.looseObject({ + _meta: z.union([z.looseObject({}), z.null()]).optional() +}) +export type KillTerminalResponse = z.infer diff --git a/src/main/acp/generated/client-capabilities.gen.ts b/src/main/acp/generated/client-capabilities.gen.ts deleted file mode 100644 index 723ef2fddb15..000000000000 --- a/src/main/acp/generated/client-capabilities.gen.ts +++ /dev/null @@ -1,113 +0,0 @@ -// Generated by config/scripts/acp/generate-protocol.mjs; do not edit. -// ACP schema-v1.21.0, legacy model API v0.11.6; SPDX-License-Identifier: Apache-2.0. -import { z } from 'zod' -import { - FileSystemCapabilitiesSchema, - ClientSessionCapabilitiesSchema, - PlanCapabilitiesSchema, - AuthCapabilitiesSchema, - ElicitationCapabilitiesSchema, - ClientNesCapabilitiesSchema, - PositionEncodingKindSchema, - ProtocolVersionSchema -} from './protocol-version.gen' -export const ClientCapabilitiesSchema = z.looseObject({ - fs: FileSystemCapabilitiesSchema.optional(), - terminal: z.boolean().optional(), - session: z.union([ClientSessionCapabilitiesSchema, z.null()]).optional(), - plan: z.union([PlanCapabilitiesSchema, z.null()]).optional(), - auth: AuthCapabilitiesSchema.optional(), - elicitation: z.union([ElicitationCapabilitiesSchema, z.null()]).optional(), - nes: z.union([ClientNesCapabilitiesSchema, z.null()]).optional(), - positionEncodings: z.array(PositionEncodingKindSchema).optional(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type ClientCapabilities = z.infer - -export const ImplementationSchema = z.looseObject({ - name: z.string(), - title: z.union([z.string(), z.null()]).optional(), - version: z.string(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type Implementation = z.infer - -export const InitializeRequestSchema = z.looseObject({ - protocolVersion: ProtocolVersionSchema, - clientCapabilities: ClientCapabilitiesSchema.optional(), - clientInfo: z.union([ImplementationSchema, z.null()]).optional(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type InitializeRequest = z.infer - -export const PromptCapabilitiesSchema = z.looseObject({ - image: z.boolean().optional(), - audio: z.boolean().optional(), - embeddedContext: z.boolean().optional(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type PromptCapabilities = z.infer - -export const McpCapabilitiesSchema = z.looseObject({ - http: z.boolean().optional(), - sse: z.boolean().optional(), - acp: z.boolean().optional(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type McpCapabilities = z.infer - -export const SessionListCapabilitiesSchema = z.looseObject({ - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type SessionListCapabilities = z.infer - -export const SessionDeleteCapabilitiesSchema = z.looseObject({ - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type SessionDeleteCapabilities = z.infer - -export const SessionAdditionalDirectoriesCapabilitiesSchema = z.looseObject({ - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type SessionAdditionalDirectoriesCapabilities = z.infer< - typeof SessionAdditionalDirectoriesCapabilitiesSchema -> - -export const SessionForkCapabilitiesSchema = z.looseObject({ - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type SessionForkCapabilities = z.infer - -export const SessionResumeCapabilitiesSchema = z.looseObject({ - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type SessionResumeCapabilities = z.infer - -export const SessionCloseCapabilitiesSchema = z.looseObject({ - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type SessionCloseCapabilities = z.infer - -export const SessionCapabilitiesSchema = z.looseObject({ - list: z.union([SessionListCapabilitiesSchema, z.null()]).optional(), - delete: z.union([SessionDeleteCapabilitiesSchema, z.null()]).optional(), - additionalDirectories: z - .union([SessionAdditionalDirectoriesCapabilitiesSchema, z.null()]) - .optional(), - fork: z.union([SessionForkCapabilitiesSchema, z.null()]).optional(), - resume: z.union([SessionResumeCapabilitiesSchema, z.null()]).optional(), - close: z.union([SessionCloseCapabilitiesSchema, z.null()]).optional(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type SessionCapabilities = z.infer - -export const LogoutCapabilitiesSchema = z.looseObject({ - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type LogoutCapabilities = z.infer - -export const AgentAuthCapabilitiesSchema = z.looseObject({ - logout: z.union([LogoutCapabilitiesSchema, z.null()]).optional(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type AgentAuthCapabilities = z.infer diff --git a/src/main/acp/generated/diff.gen.ts b/src/main/acp/generated/diff.gen.ts deleted file mode 100644 index 21ec2dbe11ba..000000000000 --- a/src/main/acp/generated/diff.gen.ts +++ /dev/null @@ -1,126 +0,0 @@ -// Generated by config/scripts/acp/generate-protocol.mjs; do not edit. -// ACP schema-v1.21.0, legacy model API v0.11.6; SPDX-License-Identifier: Apache-2.0. -import { z } from 'zod' -import { - ContentSchema, - ToolCallIdSchema, - ToolKindSchema, - ToolCallStatusSchema -} from './text-resource-contents.gen' -export const DiffSchema = z.looseObject({ - path: z.string(), - oldText: z.union([z.string(), z.null()]).optional(), - newText: z.string(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type Diff = z.infer - -export const TerminalIdSchema = z.string() -export type TerminalId = z.infer - -export const TerminalSchema = z.looseObject({ - terminalId: TerminalIdSchema, - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type Terminal = z.infer - -export const ToolCallContentSchema = z.union([ - z.intersection(z.looseObject({ type: z.literal('content') }), ContentSchema), - z.intersection(z.looseObject({ type: z.literal('diff') }), DiffSchema), - z.intersection(z.looseObject({ type: z.literal('terminal') }), TerminalSchema) -]) -export type ToolCallContent = z.infer - -export const ToolCallLocationSchema = z.looseObject({ - path: z.string(), - line: z.union([z.number().int().min(0), z.null()]).optional(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type ToolCallLocation = z.infer - -export const ToolCallSchema = z.looseObject({ - toolCallId: ToolCallIdSchema, - title: z.string(), - name: z.union([z.string(), z.null()]).optional(), - kind: ToolKindSchema.optional(), - status: ToolCallStatusSchema.optional(), - content: z.array(ToolCallContentSchema).optional(), - locations: z.array(ToolCallLocationSchema).optional(), - rawInput: z.unknown().optional(), - rawOutput: z.unknown().optional(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type ToolCall = z.infer - -export const ToolCallUpdateSchema = z.looseObject({ - toolCallId: ToolCallIdSchema, - kind: z.union([ToolKindSchema, z.null()]).optional(), - status: z.union([ToolCallStatusSchema, z.null()]).optional(), - title: z.union([z.string(), z.null()]).optional(), - name: z.union([z.string(), z.null()]).optional(), - content: z.union([z.array(ToolCallContentSchema), z.null()]).optional(), - locations: z.union([z.array(ToolCallLocationSchema), z.null()]).optional(), - rawInput: z.unknown().optional(), - rawOutput: z.unknown().optional(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type ToolCallUpdate = z.infer - -export const PlanEntryPrioritySchema = z.union([ - z.literal('high'), - z.literal('medium'), - z.literal('low') -]) -export type PlanEntryPriority = z.infer - -export const PlanEntryStatusSchema = z.union([ - z.literal('pending'), - z.literal('in_progress'), - z.literal('completed') -]) -export type PlanEntryStatus = z.infer - -export const PlanEntrySchema = z.looseObject({ - content: z.string(), - priority: PlanEntryPrioritySchema, - status: PlanEntryStatusSchema, - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type PlanEntry = z.infer - -export const PlanSchema = z.looseObject({ - entries: z.array(PlanEntrySchema), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type Plan = z.infer - -export const PlanIdSchema = z.string() -export type PlanId = z.infer - -export const PlanItemsSchema = z.looseObject({ - planId: PlanIdSchema, - entries: z.array(PlanEntrySchema), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type PlanItems = z.infer - -export const PlanFileSchema = z.looseObject({ - planId: PlanIdSchema, - uri: z.string(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type PlanFile = z.infer - -export const PlanMarkdownSchema = z.looseObject({ - planId: PlanIdSchema, - content: z.string(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type PlanMarkdown = z.infer - -export const PlanUpdateContentSchema = z.union([ - z.intersection(z.looseObject({ type: z.literal('items') }), PlanItemsSchema), - z.intersection(z.looseObject({ type: z.literal('file') }), PlanFileSchema), - z.intersection(z.looseObject({ type: z.literal('markdown') }), PlanMarkdownSchema) -]) -export type PlanUpdateContent = z.infer diff --git a/src/main/acp/generated/license.gen.ts b/src/main/acp/generated/license.gen.ts deleted file mode 100644 index 51e227cb3ce1..000000000000 --- a/src/main/acp/generated/license.gen.ts +++ /dev/null @@ -1,197 +0,0 @@ -// Generated by config/scripts/acp/generate-protocol.mjs; do not edit. -// ACP schema-v1.21.0, legacy model API v0.11.6; SPDX-License-Identifier: Apache-2.0. -/* -Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - Copyright 2025 Zed Industries, Inc. and contributors - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. -*/ -export const ACP_SCHEMA_RELEASE = 'schema-v1.21.0' -export const ACP_LEGACY_MODEL_SCHEMA_RELEASE = 'v0.11.6' -export const ACP_PROTOCOL_VERSION = 1 diff --git a/src/main/acp/generated/mcp-server-acp.gen.ts b/src/main/acp/generated/mcp-server-acp.gen.ts deleted file mode 100644 index 105054e2a883..000000000000 --- a/src/main/acp/generated/mcp-server-acp.gen.ts +++ /dev/null @@ -1,116 +0,0 @@ -// Generated by config/scripts/acp/generate-protocol.mjs; do not edit. -// ACP schema-v1.21.0, legacy model API v0.11.6; SPDX-License-Identifier: Apache-2.0. -import { z } from 'zod' -import { - McpServerAcpIdSchema, - McpServerHttpSchema, - McpServerSseSchema -} from './nes-context-capabilities.gen' -export const McpServerAcpSchema = z.looseObject({ - name: z.string(), - serverId: McpServerAcpIdSchema, - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type McpServerAcp = z.infer - -export const EnvVariableSchema = z.looseObject({ - name: z.string(), - value: z.string(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type EnvVariable = z.infer - -export const McpServerStdioSchema = z.looseObject({ - name: z.string(), - command: z.string(), - args: z.array(z.string()), - env: z.array(EnvVariableSchema), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type McpServerStdio = z.infer - -export const McpServerSchema = z.union([ - z.intersection(z.looseObject({ type: z.literal('http') }), McpServerHttpSchema), - z.intersection(z.looseObject({ type: z.literal('sse') }), McpServerSseSchema), - z.intersection(z.looseObject({ type: z.literal('acp') }), McpServerAcpSchema), - McpServerStdioSchema -]) -export type McpServer = z.infer - -export const NewSessionRequestSchema = z.looseObject({ - cwd: z.string(), - additionalDirectories: z.array(z.string()).optional(), - mcpServers: z.array(McpServerSchema), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type NewSessionRequest = z.infer - -export const SessionIdSchema = z.string() -export type SessionId = z.infer - -export const SessionModeIdSchema = z.string() -export type SessionModeId = z.infer - -export const SessionModeSchema = z.looseObject({ - id: SessionModeIdSchema, - name: z.string(), - description: z.union([z.string(), z.null()]).optional(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type SessionMode = z.infer - -export const SessionModeStateSchema = z.looseObject({ - currentModeId: SessionModeIdSchema, - availableModes: z.array(SessionModeSchema), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type SessionModeState = z.infer - -export const SessionConfigIdSchema = z.string() -export type SessionConfigId = z.infer - -export const SessionConfigOptionCategorySchema = z.union([ - z.literal('mode'), - z.literal('model'), - z.literal('model_config'), - z.literal('thought_level'), - z.string() -]) -export type SessionConfigOptionCategory = z.infer - -export const SessionConfigValueIdSchema = z.string() -export type SessionConfigValueId = z.infer - -export const SessionConfigSelectOptionSchema = z.looseObject({ - value: SessionConfigValueIdSchema, - name: z.string(), - description: z.union([z.string(), z.null()]).optional(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type SessionConfigSelectOption = z.infer - -export const SessionConfigGroupIdSchema = z.string() -export type SessionConfigGroupId = z.infer - -export const SessionConfigSelectGroupSchema = z.looseObject({ - group: SessionConfigGroupIdSchema, - name: z.string(), - options: z.array(SessionConfigSelectOptionSchema), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type SessionConfigSelectGroup = z.infer - -export const SessionConfigSelectOptionsSchema = z.union([ - z.array(SessionConfigSelectOptionSchema), - z.array(SessionConfigSelectGroupSchema) -]) -export type SessionConfigSelectOptions = z.infer - -export const SessionConfigSelectSchema = z.looseObject({ - currentValue: SessionConfigValueIdSchema, - options: SessionConfigSelectOptionsSchema -}) -export type SessionConfigSelect = z.infer - -export const SessionConfigBooleanSchema = z.looseObject({ currentValue: z.boolean() }) -export type SessionConfigBoolean = z.infer diff --git a/src/main/acp/generated/nes-context-capabilities.gen.ts b/src/main/acp/generated/nes-context-capabilities.gen.ts deleted file mode 100644 index 3f193cd78045..000000000000 --- a/src/main/acp/generated/nes-context-capabilities.gen.ts +++ /dev/null @@ -1,124 +0,0 @@ -// Generated by config/scripts/acp/generate-protocol.mjs; do not edit. -// ACP schema-v1.21.0, legacy model API v0.11.6; SPDX-License-Identifier: Apache-2.0. -import { z } from 'zod' -import { - NesRecentFilesCapabilitiesSchema, - NesRelatedSnippetsCapabilitiesSchema, - NesEditHistoryCapabilitiesSchema, - NesUserActionsCapabilitiesSchema, - NesOpenFilesCapabilitiesSchema, - NesDiagnosticsCapabilitiesSchema, - NesEventCapabilitiesSchema, - ProvidersCapabilitiesSchema -} from './providers-capabilities.gen' -import { - PromptCapabilitiesSchema, - McpCapabilitiesSchema, - SessionCapabilitiesSchema, - AgentAuthCapabilitiesSchema, - ImplementationSchema -} from './client-capabilities.gen' -import { PositionEncodingKindSchema, ProtocolVersionSchema } from './protocol-version.gen' -export const NesContextCapabilitiesSchema = z.looseObject({ - recentFiles: z.union([NesRecentFilesCapabilitiesSchema, z.null()]).optional(), - relatedSnippets: z.union([NesRelatedSnippetsCapabilitiesSchema, z.null()]).optional(), - editHistory: z.union([NesEditHistoryCapabilitiesSchema, z.null()]).optional(), - userActions: z.union([NesUserActionsCapabilitiesSchema, z.null()]).optional(), - openFiles: z.union([NesOpenFilesCapabilitiesSchema, z.null()]).optional(), - diagnostics: z.union([NesDiagnosticsCapabilitiesSchema, z.null()]).optional(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type NesContextCapabilities = z.infer - -export const NesCapabilitiesSchema = z.looseObject({ - events: z.union([NesEventCapabilitiesSchema, z.null()]).optional(), - context: z.union([NesContextCapabilitiesSchema, z.null()]).optional(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type NesCapabilities = z.infer - -export const AgentCapabilitiesSchema = z.looseObject({ - loadSession: z.boolean().optional(), - promptCapabilities: PromptCapabilitiesSchema.optional(), - mcpCapabilities: McpCapabilitiesSchema.optional(), - sessionCapabilities: SessionCapabilitiesSchema.optional(), - auth: AgentAuthCapabilitiesSchema.optional(), - providers: z.union([ProvidersCapabilitiesSchema, z.null()]).optional(), - nes: z.union([NesCapabilitiesSchema, z.null()]).optional(), - positionEncoding: z.union([PositionEncodingKindSchema, z.null()]).optional(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type AgentCapabilities = z.infer - -export const AuthMethodIdSchema = z.string() -export type AuthMethodId = z.infer - -export const AuthMethodTerminalSchema = z.looseObject({ - id: AuthMethodIdSchema, - name: z.string(), - description: z.union([z.string(), z.null()]).optional(), - args: z.array(z.string()).optional(), - env: z.looseObject({}).catchall(z.string()).optional(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type AuthMethodTerminal = z.infer - -export const AuthMethodAgentSchema = z.looseObject({ - id: AuthMethodIdSchema, - name: z.string(), - description: z.union([z.string(), z.null()]).optional(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type AuthMethodAgent = z.infer - -export const AuthMethodSchema = z.union([ - z.intersection(z.looseObject({ type: z.literal('terminal') }), AuthMethodTerminalSchema), - AuthMethodAgentSchema -]) -export type AuthMethod = z.infer - -export const InitializeResponseSchema = z.looseObject({ - protocolVersion: ProtocolVersionSchema, - agentCapabilities: AgentCapabilitiesSchema.optional(), - authMethods: z.array(AuthMethodSchema).optional(), - agentInfo: z.union([ImplementationSchema, z.null()]).optional(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type InitializeResponse = z.infer - -export const AuthenticateRequestSchema = z.looseObject({ - methodId: AuthMethodIdSchema, - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type AuthenticateRequest = z.infer - -export const AuthenticateResponseSchema = z.looseObject({ - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type AuthenticateResponse = z.infer - -export const HttpHeaderSchema = z.looseObject({ - name: z.string(), - value: z.string(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type HttpHeader = z.infer - -export const McpServerHttpSchema = z.looseObject({ - name: z.string(), - url: z.string(), - headers: z.array(HttpHeaderSchema), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type McpServerHttp = z.infer - -export const McpServerSseSchema = z.looseObject({ - name: z.string(), - url: z.string(), - headers: z.array(HttpHeaderSchema), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type McpServerSse = z.infer - -export const McpServerAcpIdSchema = z.string() -export type McpServerAcpId = z.infer diff --git a/src/main/acp/generated/plan-update.gen.ts b/src/main/acp/generated/plan-update.gen.ts deleted file mode 100644 index eda1e7474274..000000000000 --- a/src/main/acp/generated/plan-update.gen.ts +++ /dev/null @@ -1,103 +0,0 @@ -// Generated by config/scripts/acp/generate-protocol.mjs; do not edit. -// ACP schema-v1.21.0, legacy model API v0.11.6; SPDX-License-Identifier: Apache-2.0. -import { z } from 'zod' -import { PlanUpdateContentSchema, PlanIdSchema } from './diff.gen' -import { SessionModeIdSchema } from './mcp-server-acp.gen' -import { SessionConfigOptionSchema } from './session-config-option.gen' -import { ContentBlockSchema } from './text-resource-contents.gen' -export const PlanUpdateSchema = z.looseObject({ - plan: PlanUpdateContentSchema, - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type PlanUpdate = z.infer - -export const PlanRemovedSchema = z.looseObject({ - planId: PlanIdSchema, - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type PlanRemoved = z.infer - -export const UnstructuredCommandInputSchema = z.looseObject({ - hint: z.string(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type UnstructuredCommandInput = z.infer - -export const AvailableCommandInputSchema = z.union([UnstructuredCommandInputSchema]) -export type AvailableCommandInput = z.infer - -export const AvailableCommandSchema = z.looseObject({ - name: z.string(), - description: z.string(), - input: z.union([AvailableCommandInputSchema, z.null()]).optional(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type AvailableCommand = z.infer - -export const AvailableCommandsUpdateSchema = z.looseObject({ - availableCommands: z.array(AvailableCommandSchema), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type AvailableCommandsUpdate = z.infer - -export const CurrentModeUpdateSchema = z.looseObject({ - currentModeId: SessionModeIdSchema, - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type CurrentModeUpdate = z.infer - -export const ConfigOptionUpdateSchema = z.looseObject({ - configOptions: z.array(SessionConfigOptionSchema), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type ConfigOptionUpdate = z.infer - -export const SessionInfoUpdateSchema = z.looseObject({ - title: z.union([z.string(), z.null()]).optional(), - updatedAt: z.union([z.string(), z.null()]).optional(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type SessionInfoUpdate = z.infer - -export const CostSchema = z.looseObject({ - amount: z.number(), - currency: z.string(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type Cost = z.infer - -export const UsageUpdateSchema = z.looseObject({ - used: z.number().int().min(0), - size: z.number().int().min(0), - cost: z.union([CostSchema, z.null()]).optional(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type UsageUpdate = z.infer - -export const CompactionIdSchema = z.string() -export type CompactionId = z.infer - -export const CompactionStatusSchema = z.union([ - z.literal('in_progress'), - z.literal('completed'), - z.literal('failed'), - z.literal('cancelled'), - z.string() -]) -export type CompactionStatus = z.infer - -export const CompactionUpdateSchema = z.looseObject({ - compactionId: CompactionIdSchema, - status: CompactionStatusSchema, - summary: z.union([z.array(ContentBlockSchema), z.null()]).optional(), - error: z.union([z.string(), z.null()]).optional(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type CompactionUpdate = z.infer - -export const CompactionSummaryChunkSchema = z.looseObject({ - compactionId: CompactionIdSchema, - content: ContentBlockSchema, - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type CompactionSummaryChunk = z.infer diff --git a/src/main/acp/generated/protocol-version.gen.ts b/src/main/acp/generated/protocol-version.gen.ts deleted file mode 100644 index 865debf5666e..000000000000 --- a/src/main/acp/generated/protocol-version.gen.ts +++ /dev/null @@ -1,93 +0,0 @@ -// Generated by config/scripts/acp/generate-protocol.mjs; do not edit. -// ACP schema-v1.21.0, legacy model API v0.11.6; SPDX-License-Identifier: Apache-2.0. -import { z } from 'zod' -export const ProtocolVersionSchema = z.number().int().min(0).max(65535) -export type ProtocolVersion = z.infer - -export const FileSystemCapabilitiesSchema = z.looseObject({ - readTextFile: z.boolean().optional(), - writeTextFile: z.boolean().optional(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type FileSystemCapabilities = z.infer - -export const CompactionCapabilitiesSchema = z.looseObject({}) -export type CompactionCapabilities = z.infer - -export const BooleanConfigOptionCapabilitiesSchema = z.looseObject({ - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type BooleanConfigOptionCapabilities = z.infer - -export const SessionConfigOptionsCapabilitiesSchema = z.looseObject({ - boolean: z.union([BooleanConfigOptionCapabilitiesSchema, z.null()]).optional(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type SessionConfigOptionsCapabilities = z.infer< - typeof SessionConfigOptionsCapabilitiesSchema -> - -export const ClientSessionCapabilitiesSchema = z.looseObject({ - compaction: z.union([CompactionCapabilitiesSchema, z.null()]).optional(), - configOptions: z.union([SessionConfigOptionsCapabilitiesSchema, z.null()]).optional(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type ClientSessionCapabilities = z.infer - -export const PlanCapabilitiesSchema = z.looseObject({ - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type PlanCapabilities = z.infer - -export const AuthCapabilitiesSchema = z.looseObject({ - terminal: z.boolean().optional(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type AuthCapabilities = z.infer - -export const ElicitationFormCapabilitiesSchema = z.looseObject({ - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type ElicitationFormCapabilities = z.infer - -export const ElicitationUrlCapabilitiesSchema = z.looseObject({ - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type ElicitationUrlCapabilities = z.infer - -export const ElicitationCapabilitiesSchema = z.looseObject({ - form: z.union([ElicitationFormCapabilitiesSchema, z.null()]).optional(), - url: z.union([ElicitationUrlCapabilitiesSchema, z.null()]).optional(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type ElicitationCapabilities = z.infer - -export const NesJumpCapabilitiesSchema = z.looseObject({ - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type NesJumpCapabilities = z.infer - -export const NesRenameCapabilitiesSchema = z.looseObject({ - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type NesRenameCapabilities = z.infer - -export const NesSearchAndReplaceCapabilitiesSchema = z.looseObject({ - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type NesSearchAndReplaceCapabilities = z.infer - -export const ClientNesCapabilitiesSchema = z.looseObject({ - jump: z.union([NesJumpCapabilitiesSchema, z.null()]).optional(), - rename: z.union([NesRenameCapabilitiesSchema, z.null()]).optional(), - searchAndReplace: z.union([NesSearchAndReplaceCapabilitiesSchema, z.null()]).optional(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type ClientNesCapabilities = z.infer - -export const PositionEncodingKindSchema = z.union([ - z.literal('utf-16'), - z.literal('utf-32'), - z.literal('utf-8') -]) -export type PositionEncodingKind = z.infer diff --git a/src/main/acp/generated/protocol.gen.ts b/src/main/acp/generated/protocol.gen.ts deleted file mode 100644 index 1c1cd14183c8..000000000000 --- a/src/main/acp/generated/protocol.gen.ts +++ /dev/null @@ -1,14 +0,0 @@ -// Generated by config/scripts/acp/generate-protocol.mjs; do not edit. -// ACP schema-v1.21.0, legacy model API v0.11.6; SPDX-License-Identifier: Apache-2.0. -export * from './protocol-version.gen' -export * from './client-capabilities.gen' -export * from './providers-capabilities.gen' -export * from './nes-context-capabilities.gen' -export * from './mcp-server-acp.gen' -export * from './session-config-option.gen' -export * from './text-resource-contents.gen' -export * from './diff.gen' -export * from './plan-update.gen' -export * from './session-update.gen' -export * from './set-session-model-request.gen' -export * from './wait-for-terminal-exit-request.gen' diff --git a/src/main/acp/generated/providers-capabilities.gen.ts b/src/main/acp/generated/providers-capabilities.gen.ts deleted file mode 100644 index c9864207ed26..000000000000 --- a/src/main/acp/generated/providers-capabilities.gen.ts +++ /dev/null @@ -1,87 +0,0 @@ -// Generated by config/scripts/acp/generate-protocol.mjs; do not edit. -// ACP schema-v1.21.0, legacy model API v0.11.6; SPDX-License-Identifier: Apache-2.0. -import { z } from 'zod' -export const ProvidersCapabilitiesSchema = z.looseObject({ - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type ProvidersCapabilities = z.infer - -export const NesDocumentDidOpenCapabilitiesSchema = z.looseObject({ - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type NesDocumentDidOpenCapabilities = z.infer - -export const TextDocumentSyncKindSchema = z.union([z.literal('full'), z.literal('incremental')]) -export type TextDocumentSyncKind = z.infer - -export const NesDocumentDidChangeCapabilitiesSchema = z.looseObject({ - syncKind: TextDocumentSyncKindSchema, - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type NesDocumentDidChangeCapabilities = z.infer< - typeof NesDocumentDidChangeCapabilitiesSchema -> - -export const NesDocumentDidCloseCapabilitiesSchema = z.looseObject({ - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type NesDocumentDidCloseCapabilities = z.infer - -export const NesDocumentDidSaveCapabilitiesSchema = z.looseObject({ - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type NesDocumentDidSaveCapabilities = z.infer - -export const NesDocumentDidFocusCapabilitiesSchema = z.looseObject({ - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type NesDocumentDidFocusCapabilities = z.infer - -export const NesDocumentEventCapabilitiesSchema = z.looseObject({ - didOpen: z.union([NesDocumentDidOpenCapabilitiesSchema, z.null()]).optional(), - didChange: z.union([NesDocumentDidChangeCapabilitiesSchema, z.null()]).optional(), - didClose: z.union([NesDocumentDidCloseCapabilitiesSchema, z.null()]).optional(), - didSave: z.union([NesDocumentDidSaveCapabilitiesSchema, z.null()]).optional(), - didFocus: z.union([NesDocumentDidFocusCapabilitiesSchema, z.null()]).optional(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type NesDocumentEventCapabilities = z.infer - -export const NesEventCapabilitiesSchema = z.looseObject({ - document: z.union([NesDocumentEventCapabilitiesSchema, z.null()]).optional(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type NesEventCapabilities = z.infer - -export const NesRecentFilesCapabilitiesSchema = z.looseObject({ - maxCount: z.union([z.number().int().min(0), z.null()]).optional(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type NesRecentFilesCapabilities = z.infer - -export const NesRelatedSnippetsCapabilitiesSchema = z.looseObject({ - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type NesRelatedSnippetsCapabilities = z.infer - -export const NesEditHistoryCapabilitiesSchema = z.looseObject({ - maxCount: z.union([z.number().int().min(0), z.null()]).optional(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type NesEditHistoryCapabilities = z.infer - -export const NesUserActionsCapabilitiesSchema = z.looseObject({ - maxCount: z.union([z.number().int().min(0), z.null()]).optional(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type NesUserActionsCapabilities = z.infer - -export const NesOpenFilesCapabilitiesSchema = z.looseObject({ - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type NesOpenFilesCapabilities = z.infer - -export const NesDiagnosticsCapabilitiesSchema = z.looseObject({ - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type NesDiagnosticsCapabilities = z.infer diff --git a/src/main/acp/generated/session-config-option.gen.ts b/src/main/acp/generated/session-config-option.gen.ts deleted file mode 100644 index 3d33a869a6fc..000000000000 --- a/src/main/acp/generated/session-config-option.gen.ts +++ /dev/null @@ -1,113 +0,0 @@ -// Generated by config/scripts/acp/generate-protocol.mjs; do not edit. -// ACP schema-v1.21.0, legacy model API v0.11.6; SPDX-License-Identifier: Apache-2.0. -import { z } from 'zod' -import { - SessionConfigIdSchema, - SessionConfigOptionCategorySchema, - SessionConfigSelectSchema, - SessionConfigBooleanSchema, - SessionIdSchema, - SessionModeStateSchema, - McpServerSchema -} from './mcp-server-acp.gen' -export const SessionConfigOptionSchema = z.intersection( - z.looseObject({ - id: SessionConfigIdSchema, - name: z.string(), - description: z.union([z.string(), z.null()]).optional(), - category: z.union([SessionConfigOptionCategorySchema, z.null()]).optional(), - _meta: z.union([z.looseObject({}), z.null()]).optional() - }), - z.union([ - z.intersection(z.looseObject({ type: z.literal('select') }), SessionConfigSelectSchema), - z.intersection(z.looseObject({ type: z.literal('boolean') }), SessionConfigBooleanSchema) - ]) -) -export type SessionConfigOption = z.infer - -export const NewSessionResponseSchema = z.looseObject({ - sessionId: SessionIdSchema, - modes: z.union([SessionModeStateSchema, z.null()]).optional(), - configOptions: z.union([z.array(SessionConfigOptionSchema), z.null()]).optional(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type NewSessionResponse = z.infer - -export const LoadSessionRequestSchema = z.looseObject({ - mcpServers: z.array(McpServerSchema), - cwd: z.string(), - additionalDirectories: z.array(z.string()).optional(), - sessionId: SessionIdSchema, - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type LoadSessionRequest = z.infer - -export const LoadSessionResponseSchema = z.looseObject({ - modes: z.union([SessionModeStateSchema, z.null()]).optional(), - configOptions: z.union([z.array(SessionConfigOptionSchema), z.null()]).optional(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type LoadSessionResponse = z.infer - -export const ResumeSessionRequestSchema = z.looseObject({ - sessionId: SessionIdSchema, - cwd: z.string(), - additionalDirectories: z.array(z.string()).optional(), - mcpServers: z.array(McpServerSchema).optional(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type ResumeSessionRequest = z.infer - -export const ResumeSessionResponseSchema = z.looseObject({ - modes: z.union([SessionModeStateSchema, z.null()]).optional(), - configOptions: z.union([z.array(SessionConfigOptionSchema), z.null()]).optional(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type ResumeSessionResponse = z.infer - -export const RoleSchema = z.union([z.literal('assistant'), z.literal('user')]) -export type Role = z.infer - -export const AnnotationsSchema = z.looseObject({ - audience: z.union([z.array(RoleSchema), z.null()]).optional(), - lastModified: z.union([z.string(), z.null()]).optional(), - priority: z.union([z.number(), z.null()]).optional(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type Annotations = z.infer - -export const TextContentSchema = z.looseObject({ - annotations: z.union([AnnotationsSchema, z.null()]).optional(), - text: z.string(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type TextContent = z.infer - -export const ImageContentSchema = z.looseObject({ - annotations: z.union([AnnotationsSchema, z.null()]).optional(), - data: z.string(), - mimeType: z.string(), - uri: z.union([z.string(), z.null()]).optional(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type ImageContent = z.infer - -export const AudioContentSchema = z.looseObject({ - annotations: z.union([AnnotationsSchema, z.null()]).optional(), - data: z.string(), - mimeType: z.string(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type AudioContent = z.infer - -export const ResourceLinkSchema = z.looseObject({ - annotations: z.union([AnnotationsSchema, z.null()]).optional(), - description: z.union([z.string(), z.null()]).optional(), - mimeType: z.union([z.string(), z.null()]).optional(), - name: z.string(), - size: z.union([z.number().int(), z.null()]).optional(), - title: z.union([z.string(), z.null()]).optional(), - uri: z.string(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type ResourceLink = z.infer diff --git a/src/main/acp/generated/session-update.gen.ts b/src/main/acp/generated/session-update.gen.ts deleted file mode 100644 index 7b67951065d7..000000000000 --- a/src/main/acp/generated/session-update.gen.ts +++ /dev/null @@ -1,132 +0,0 @@ -// Generated by config/scripts/acp/generate-protocol.mjs; do not edit. -// ACP schema-v1.21.0, legacy model API v0.11.6; SPDX-License-Identifier: Apache-2.0. -import { z } from 'zod' -import { ContentChunkSchema } from './text-resource-contents.gen' -import { ToolCallSchema, ToolCallUpdateSchema, PlanSchema } from './diff.gen' -import { - PlanUpdateSchema, - PlanRemovedSchema, - AvailableCommandsUpdateSchema, - CurrentModeUpdateSchema, - ConfigOptionUpdateSchema, - SessionInfoUpdateSchema, - UsageUpdateSchema, - CompactionUpdateSchema, - CompactionSummaryChunkSchema -} from './plan-update.gen' -import { SessionIdSchema, SessionModeIdSchema } from './mcp-server-acp.gen' -export const SessionUpdateSchema = z.union([ - z.intersection( - z.looseObject({ sessionUpdate: z.literal('user_message_chunk') }), - ContentChunkSchema - ), - z.intersection( - z.looseObject({ sessionUpdate: z.literal('agent_message_chunk') }), - ContentChunkSchema - ), - z.intersection( - z.looseObject({ sessionUpdate: z.literal('agent_thought_chunk') }), - ContentChunkSchema - ), - z.intersection(z.looseObject({ sessionUpdate: z.literal('tool_call') }), ToolCallSchema), - z.intersection( - z.looseObject({ sessionUpdate: z.literal('tool_call_update') }), - ToolCallUpdateSchema - ), - z.intersection(z.looseObject({ sessionUpdate: z.literal('plan') }), PlanSchema), - z.intersection(z.looseObject({ sessionUpdate: z.literal('plan_update') }), PlanUpdateSchema), - z.intersection(z.looseObject({ sessionUpdate: z.literal('plan_removed') }), PlanRemovedSchema), - z.intersection( - z.looseObject({ sessionUpdate: z.literal('available_commands_update') }), - AvailableCommandsUpdateSchema - ), - z.intersection( - z.looseObject({ sessionUpdate: z.literal('current_mode_update') }), - CurrentModeUpdateSchema - ), - z.intersection( - z.looseObject({ sessionUpdate: z.literal('config_option_update') }), - ConfigOptionUpdateSchema - ), - z.intersection( - z.looseObject({ sessionUpdate: z.literal('session_info_update') }), - SessionInfoUpdateSchema - ), - z.intersection(z.looseObject({ sessionUpdate: z.literal('usage_update') }), UsageUpdateSchema), - z.intersection( - z.looseObject({ sessionUpdate: z.literal('compaction_update') }), - CompactionUpdateSchema - ), - z.intersection( - z.looseObject({ sessionUpdate: z.literal('compaction_summary_chunk') }), - CompactionSummaryChunkSchema - ) -]) -export type SessionUpdate = z.infer - -export const SessionNotificationSchema = z.looseObject({ - sessionId: SessionIdSchema, - update: SessionUpdateSchema, - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type SessionNotification = z.infer - -export const PermissionOptionIdSchema = z.string() -export type PermissionOptionId = z.infer - -export const PermissionOptionKindSchema = z.union([ - z.literal('allow_once'), - z.literal('allow_always'), - z.literal('reject_once'), - z.literal('reject_always') -]) -export type PermissionOptionKind = z.infer - -export const PermissionOptionSchema = z.looseObject({ - optionId: PermissionOptionIdSchema, - name: z.string(), - kind: PermissionOptionKindSchema, - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type PermissionOption = z.infer - -export const RequestPermissionRequestSchema = z.looseObject({ - sessionId: SessionIdSchema, - toolCall: ToolCallUpdateSchema, - options: z.array(PermissionOptionSchema), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type RequestPermissionRequest = z.infer - -export const SelectedPermissionOutcomeSchema = z.looseObject({ - optionId: PermissionOptionIdSchema, - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type SelectedPermissionOutcome = z.infer - -export const RequestPermissionOutcomeSchema = z.union([ - z.looseObject({ outcome: z.literal('cancelled') }), - z.intersection(z.looseObject({ outcome: z.literal('selected') }), SelectedPermissionOutcomeSchema) -]) -export type RequestPermissionOutcome = z.infer - -export const RequestPermissionResponseSchema = z.looseObject({ - outcome: RequestPermissionOutcomeSchema, - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type RequestPermissionResponse = z.infer - -export const SetSessionModeRequestSchema = z.looseObject({ - sessionId: SessionIdSchema, - modeId: SessionModeIdSchema, - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type SetSessionModeRequest = z.infer - -export const SetSessionModeResponseSchema = z.looseObject({ - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type SetSessionModeResponse = z.infer - -export const ModelIdSchema = z.string() -export type ModelId = z.infer diff --git a/src/main/acp/generated/set-session-model-request.gen.ts b/src/main/acp/generated/set-session-model-request.gen.ts deleted file mode 100644 index c25c1f98808e..000000000000 --- a/src/main/acp/generated/set-session-model-request.gen.ts +++ /dev/null @@ -1,121 +0,0 @@ -// Generated by config/scripts/acp/generate-protocol.mjs; do not edit. -// ACP schema-v1.21.0, legacy model API v0.11.6; SPDX-License-Identifier: Apache-2.0. -import { z } from 'zod' -import { ModelIdSchema } from './session-update.gen' -import { - SessionIdSchema, - SessionConfigIdSchema, - SessionConfigValueIdSchema, - EnvVariableSchema -} from './mcp-server-acp.gen' -import { SessionConfigOptionSchema } from './session-config-option.gen' -import { TerminalIdSchema } from './diff.gen' -export const SetSessionModelRequestSchema = z.looseObject({ - _meta: z.union([z.looseObject({}), z.null()]).optional(), - modelId: ModelIdSchema, - sessionId: SessionIdSchema -}) -export type SetSessionModelRequest = z.infer - -export const SetSessionModelResponseSchema = z.looseObject({ - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type SetSessionModelResponse = z.infer - -export const SetSessionConfigOptionRequestSchema = z.intersection( - z.looseObject({ - sessionId: SessionIdSchema, - configId: SessionConfigIdSchema, - _meta: z.union([z.looseObject({}), z.null()]).optional() - }), - z.union([ - z.looseObject({ value: z.boolean(), type: z.literal('boolean') }), - z.looseObject({ value: SessionConfigValueIdSchema }) - ]) -) -export type SetSessionConfigOptionRequest = z.infer - -export const SetSessionConfigOptionResponseSchema = z.looseObject({ - configOptions: z.array(SessionConfigOptionSchema), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type SetSessionConfigOptionResponse = z.infer - -export const ReadTextFileRequestSchema = z.looseObject({ - sessionId: SessionIdSchema, - path: z.string(), - line: z.union([z.number().int().min(0), z.null()]).optional(), - limit: z.union([z.number().int().min(0), z.null()]).optional(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type ReadTextFileRequest = z.infer - -export const ReadTextFileResponseSchema = z.looseObject({ - content: z.string(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type ReadTextFileResponse = z.infer - -export const WriteTextFileRequestSchema = z.looseObject({ - sessionId: SessionIdSchema, - path: z.string(), - content: z.string(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type WriteTextFileRequest = z.infer - -export const WriteTextFileResponseSchema = z.looseObject({ - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type WriteTextFileResponse = z.infer - -export const CreateTerminalRequestSchema = z.looseObject({ - sessionId: SessionIdSchema, - command: z.string(), - args: z.array(z.string()).optional(), - env: z.array(EnvVariableSchema).optional(), - cwd: z.union([z.string(), z.null()]).optional(), - outputByteLimit: z.union([z.number().int().min(0), z.null()]).optional(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type CreateTerminalRequest = z.infer - -export const CreateTerminalResponseSchema = z.looseObject({ - terminalId: TerminalIdSchema, - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type CreateTerminalResponse = z.infer - -export const TerminalOutputRequestSchema = z.looseObject({ - sessionId: SessionIdSchema, - terminalId: TerminalIdSchema, - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type TerminalOutputRequest = z.infer - -export const TerminalExitStatusSchema = z.looseObject({ - exitCode: z.union([z.number().int().min(0), z.null()]).optional(), - signal: z.union([z.string(), z.null()]).optional(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type TerminalExitStatus = z.infer - -export const TerminalOutputResponseSchema = z.looseObject({ - output: z.string(), - truncated: z.boolean(), - exitStatus: z.union([TerminalExitStatusSchema, z.null()]).optional(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type TerminalOutputResponse = z.infer - -export const ReleaseTerminalRequestSchema = z.looseObject({ - sessionId: SessionIdSchema, - terminalId: TerminalIdSchema, - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type ReleaseTerminalRequest = z.infer - -export const ReleaseTerminalResponseSchema = z.looseObject({ - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type ReleaseTerminalResponse = z.infer diff --git a/src/main/acp/generated/text-resource-contents.gen.ts b/src/main/acp/generated/text-resource-contents.gen.ts deleted file mode 100644 index c69e01fb6fb1..000000000000 --- a/src/main/acp/generated/text-resource-contents.gen.ts +++ /dev/null @@ -1,129 +0,0 @@ -// Generated by config/scripts/acp/generate-protocol.mjs; do not edit. -// ACP schema-v1.21.0, legacy model API v0.11.6; SPDX-License-Identifier: Apache-2.0. -import { z } from 'zod' -import { - AnnotationsSchema, - TextContentSchema, - ImageContentSchema, - AudioContentSchema, - ResourceLinkSchema -} from './session-config-option.gen' -import { SessionIdSchema } from './mcp-server-acp.gen' -export const TextResourceContentsSchema = z.looseObject({ - mimeType: z.union([z.string(), z.null()]).optional(), - text: z.string(), - uri: z.string(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type TextResourceContents = z.infer - -export const BlobResourceContentsSchema = z.looseObject({ - blob: z.string(), - mimeType: z.union([z.string(), z.null()]).optional(), - uri: z.string(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type BlobResourceContents = z.infer - -export const EmbeddedResourceResourceSchema = z.union([ - TextResourceContentsSchema, - BlobResourceContentsSchema -]) -export type EmbeddedResourceResource = z.infer - -export const EmbeddedResourceSchema = z.looseObject({ - annotations: z.union([AnnotationsSchema, z.null()]).optional(), - resource: EmbeddedResourceResourceSchema, - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type EmbeddedResource = z.infer - -export const ContentBlockSchema = z.union([ - z.intersection(z.looseObject({ type: z.literal('text') }), TextContentSchema), - z.intersection(z.looseObject({ type: z.literal('image') }), ImageContentSchema), - z.intersection(z.looseObject({ type: z.literal('audio') }), AudioContentSchema), - z.intersection(z.looseObject({ type: z.literal('resource_link') }), ResourceLinkSchema), - z.intersection(z.looseObject({ type: z.literal('resource') }), EmbeddedResourceSchema) -]) -export type ContentBlock = z.infer - -export const PromptRequestSchema = z.looseObject({ - sessionId: SessionIdSchema, - prompt: z.array(ContentBlockSchema), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type PromptRequest = z.infer - -export const StopReasonSchema = z.union([ - z.literal('end_turn'), - z.literal('max_tokens'), - z.literal('max_turn_requests'), - z.literal('refusal'), - z.literal('cancelled') -]) -export type StopReason = z.infer - -export const UsageSchema = z.looseObject({ - totalTokens: z.number().int().min(0), - inputTokens: z.number().int().min(0), - outputTokens: z.number().int().min(0), - thoughtTokens: z.union([z.number().int().min(0), z.null()]).optional(), - cachedReadTokens: z.union([z.number().int().min(0), z.null()]).optional(), - cachedWriteTokens: z.union([z.number().int().min(0), z.null()]).optional(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type Usage = z.infer - -export const PromptResponseSchema = z.looseObject({ - stopReason: StopReasonSchema, - usage: z.union([UsageSchema, z.null()]).optional(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type PromptResponse = z.infer - -export const CancelNotificationSchema = z.looseObject({ - sessionId: SessionIdSchema, - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type CancelNotification = z.infer - -export const MessageIdSchema = z.string() -export type MessageId = z.infer - -export const ContentChunkSchema = z.looseObject({ - content: ContentBlockSchema, - messageId: z.union([MessageIdSchema, z.null()]).optional(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type ContentChunk = z.infer - -export const ToolCallIdSchema = z.string() -export type ToolCallId = z.infer - -export const ToolKindSchema = z.union([ - z.literal('read'), - z.literal('edit'), - z.literal('delete'), - z.literal('move'), - z.literal('search'), - z.literal('execute'), - z.literal('think'), - z.literal('fetch'), - z.literal('switch_mode'), - z.literal('other') -]) -export type ToolKind = z.infer - -export const ToolCallStatusSchema = z.union([ - z.literal('pending'), - z.literal('in_progress'), - z.literal('completed'), - z.literal('failed') -]) -export type ToolCallStatus = z.infer - -export const ContentSchema = z.looseObject({ - content: ContentBlockSchema, - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type Content = z.infer diff --git a/src/main/acp/generated/wait-for-terminal-exit-request.gen.ts b/src/main/acp/generated/wait-for-terminal-exit-request.gen.ts deleted file mode 100644 index 1f5fc01db7cc..000000000000 --- a/src/main/acp/generated/wait-for-terminal-exit-request.gen.ts +++ /dev/null @@ -1,30 +0,0 @@ -// Generated by config/scripts/acp/generate-protocol.mjs; do not edit. -// ACP schema-v1.21.0, legacy model API v0.11.6; SPDX-License-Identifier: Apache-2.0. -import { z } from 'zod' -import { SessionIdSchema } from './mcp-server-acp.gen' -import { TerminalIdSchema } from './diff.gen' -export const WaitForTerminalExitRequestSchema = z.looseObject({ - sessionId: SessionIdSchema, - terminalId: TerminalIdSchema, - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type WaitForTerminalExitRequest = z.infer - -export const WaitForTerminalExitResponseSchema = z.looseObject({ - exitCode: z.union([z.number().int().min(0), z.null()]).optional(), - signal: z.union([z.string(), z.null()]).optional(), - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type WaitForTerminalExitResponse = z.infer - -export const KillTerminalRequestSchema = z.looseObject({ - sessionId: SessionIdSchema, - terminalId: TerminalIdSchema, - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type KillTerminalRequest = z.infer - -export const KillTerminalResponseSchema = z.looseObject({ - _meta: z.union([z.looseObject({}), z.null()]).optional() -}) -export type KillTerminalResponse = z.infer From e8d37ec371a5d95e43fcc1c13f75d044339bc3c4 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 13:16:11 -0700 Subject: [PATCH 07/62] Preserve open ACP decisions through prompt completion --- src/main/acp/acp-session-lifecycle.test.ts | 44 +++++++++++++++++++ .../acp/acp-session-notifications.test.ts | 21 +++++++++ src/main/acp/acp-session-runtime.ts | 29 ++++++++---- 3 files changed, 86 insertions(+), 8 deletions(-) diff --git a/src/main/acp/acp-session-lifecycle.test.ts b/src/main/acp/acp-session-lifecycle.test.ts index ec79badfbb5f..43f2815697da 100644 --- a/src/main/acp/acp-session-lifecycle.test.ts +++ b/src/main/acp/acp-session-lifecycle.test.ts @@ -278,4 +278,48 @@ describe('ACP caller-owned waits', () => { await rejected expect(agent.stdout.readableEnded).toBe(false) }) + it.each(['answer', 'cancel'] as const)( + 'keeps a permission pending after prompt completion until caller %s', + async (action) => { + const decision = deferred() + const entered = deferred() + const { agent, runtime } = fixture({ + onPermission: (_request, context) => { + entered.resolve(context.signal) + return decision.promise + } + }) + agent.on('session/prompt', () => {}) + await runtime.start(startOptions) + const pending = runtime.prompt([...prompt]) + const response = agent.request('permission', 'session/request_permission', permission) + const signal = await entered.promise + const frame = agent.frames.find((candidate) => candidate.method === 'session/prompt') + if (frame) { + agent.reply(frame, { stopReason: 'end_turn' }) + } + await pending + expect(signal.aborted).toBe(false) + if (action === 'answer') { + decision.resolve({ outcome: { outcome: 'selected', optionId: 'allow' } }) + expect(await response).toMatchObject({ + result: { outcome: { outcome: 'selected', optionId: 'allow' } } + }) + } else { + await runtime.cancel() + expect(await response).toMatchObject({ result: { outcome: { outcome: 'cancelled' } } }) + } + } + ) + + it('confirms cancellation when the agent settles the prompt with an error', async () => { + const { agent, runtime } = fixture() + agent.on('session/prompt', (frame) => { + agent.on('session/cancel', () => agent.fail(frame, -32800, 'Request cancelled')) + }) + await runtime.start(startOptions) + const rejected = expect(runtime.prompt([...prompt])).rejects.toMatchObject({ code: -32800 }) + await expect(runtime.cancel()).resolves.toBeUndefined() + await rejected + }) }) diff --git a/src/main/acp/acp-session-notifications.test.ts b/src/main/acp/acp-session-notifications.test.ts index 181a7cb8dedc..b13796d2ea06 100644 --- a/src/main/acp/acp-session-notifications.test.ts +++ b/src/main/acp/acp-session-notifications.test.ts @@ -77,4 +77,25 @@ describe('ACP session update compatibility', () => { expect(started.response.models?.availableModels[0].modelId).toBe('model-1') expect(started.response.vendor).toBe(true) }) + it('delivers updates even if diagnostic and earlier event callbacks throw', async () => { + const agent = new AcpScriptedAgent() + const runtime = new AcpSessionRuntime(agent.stdout, agent.stdin, { + onDiagnostic: () => { + throw new Error('Diagnostic failed') + } + }) + opened.push(agent, runtime) + runtime.subscribe(() => { + throw new Error('Listener failed') + }) + const received = vi.fn() + runtime.subscribe(received) + const notification = { sessionId: 'session-1', update: { sessionUpdate: 'vendor' } } + agent.notify('session/update', notification) + expect(received).toHaveBeenCalledWith({ + kind: 'unrecognized', + sessionId: 'session-1', + raw: notification + }) + }) }) diff --git a/src/main/acp/acp-session-runtime.ts b/src/main/acp/acp-session-runtime.ts index bfd0e02226d7..77e317362d13 100644 --- a/src/main/acp/acp-session-runtime.ts +++ b/src/main/acp/acp-session-runtime.ts @@ -10,8 +10,8 @@ import { type AcpSessionStartOptions } from './acp-session-setup' export type { AcpSessionStarted, AcpSessionStartOptions } from './acp-session-setup' -import { ACP_PROTOCOL_VERSION } from './generated/acp-protocol.generated' import { + ACP_PROTOCOL_VERSION, InitializeResponseSchema, AuthenticateResponseSchema, PromptResponseSchema, @@ -159,7 +159,6 @@ export class AcpSessionRuntime { } this.activePrompt = active active.response = active.response.finally(() => { - this.permissions.cancel(sessionId) if (this.activePrompt === active) { this.activePrompt = undefined } @@ -172,13 +171,13 @@ export class AcpSessionRuntime { return Promise.reject(new Error('ACP session has not started')) } const sessionId = this.started.sessionId + this.permissions.cancel(sessionId) + this.peer.cancelIncomingRequests('session/request_permission') const active = this.activePrompt if (!active) { return Promise.resolve() } active.cancelling = true - this.permissions.cancel(sessionId) - this.peer.cancelIncomingRequests('session/request_permission') active.cancelPromise ??= this.cancelActive(sessionId, active) return active.cancelPromise } @@ -188,7 +187,13 @@ export class AcpSessionRuntime { let timer: ReturnType | undefined try { await Promise.race([ - this.peer.notify('session/cancel', { sessionId }).then(() => active.response), + this.peer.notify('session/cancel', { sessionId }).then(() => + active.response.catch((error) => { + if (this.peer.closed) { + throw error + } + }) + ), new Promise((_resolve, reject) => { timer = setTimeout(() => { const error = new AcpRequestTimeoutError('session/cancel') @@ -276,13 +281,21 @@ export class AcpSessionRuntime { return this.permissions.handle(parsed.data, context, this.options.onPermission) } + private diagnose(message: string): void { + try { + this.options.onDiagnostic?.(message) + } catch { + /* Diagnostics cannot prevent event delivery. */ + } + } + private handleNotification(method: string, params: unknown): void { if (method !== 'session/update') { return } const envelope = updateEnvelopeSchema.safeParse(params) if (!envelope.success) { - this.options.onDiagnostic?.('Ignored invalid ACP session update envelope') + this.diagnose('Ignored invalid ACP session update envelope') return } const parsed = SessionNotificationSchema.safeParse(params) @@ -291,13 +304,13 @@ export class AcpSessionRuntime { : { kind: 'unrecognized', sessionId: envelope.data.sessionId, raw: envelope.data } if (!parsed.success && !this.reportedUpdateAnomaly) { this.reportedUpdateAnomaly = true - this.options.onDiagnostic?.('Forwarded unrecognized ACP session update') + this.diagnose('Forwarded unrecognized ACP session update') } for (const listener of this.listeners) { try { listener(event) } catch (error) { - this.options.onDiagnostic?.(`ACP event listener failed: ${String(error)}`) + this.diagnose(`ACP event listener failed: ${String(error)}`) } } } From 23b927e859e93523ca523c654eefcd63f0daceee Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 21:49:30 -0700 Subject: [PATCH 08/62] Move the turn message ordinals and the turn-row revision to the neutral timeline folder Pure moves so a shared timeline assembler can use them: Codex's message ordinal counter becomes ProviderTurnMessageOrdinals and Claude's turn-row revision becomes the provider-neutral agent-journal turn-row revision. Only names and import paths change. --- config/scripts/ci-shard-timings.json | 2 +- src/main/claude/claude-context-facts.ts | 11 +- src/main/claude/claude-open-turn.ts | 4 +- .../codex-structured-item-translation.test.ts | 28 ++--- .../codex-structured-item-translation.ts | 8 +- .../codex/codex-structured-journal-items.ts | 4 +- .../codex-structured-journal-settlement.ts | 4 +- ...-structured-journal-translation-restore.ts | 7 +- ...ctured-journal-translation-streams.test.ts | 4 +- ...ex-structured-journal-translation-turns.ts | 2 +- src/main/codex/codex-thread-item-identity.ts | 8 +- .../agent-journal-turn-row-revision.ts} | 106 +++++++++++------- .../provider-turn-message-ordinals.test.ts} | 25 +++-- .../provider-turn-message-ordinals.ts} | 19 +--- 14 files changed, 130 insertions(+), 102 deletions(-) rename src/main/{claude/claude-turn-row-revision.ts => native-chat/agent-session-timeline/agent-journal-turn-row-revision.ts} (67%) rename src/main/{codex/codex-turn-ordinals.test.ts => native-chat/agent-session-timeline/provider-turn-message-ordinals.test.ts} (64%) rename src/main/{codex/codex-turn-ordinals.ts => native-chat/agent-session-timeline/provider-turn-message-ordinals.ts} (88%) diff --git a/config/scripts/ci-shard-timings.json b/config/scripts/ci-shard-timings.json index 73d0226943b8..fb242a4788c8 100644 --- a/config/scripts/ci-shard-timings.json +++ b/config/scripts/ci-shard-timings.json @@ -1307,7 +1307,7 @@ "src/main/codex/codex-trust-grant-ledger.test.ts": 54, "src/main/codex/codex-trust-grant-main-thread-boundary.test.ts": 43, "src/main/codex/codex-trust-grant-telemetry.test.ts": 70, - "src/main/codex/codex-turn-ordinals.test.ts": 96, + "src/main/native-chat/agent-session-timeline/provider-turn-message-ordinals.test.ts": 96, "src/main/codex/codex-user-hook-trust-rebase-client.test.ts": 104, "src/main/codex/codex-user-hook-trust-moves.test.ts": 127, "src/main/codex/codex-wsl-hook-install-plan.test.ts": 51, diff --git a/src/main/claude/claude-context-facts.ts b/src/main/claude/claude-context-facts.ts index 358fd134c87a..3a1326e09363 100644 --- a/src/main/claude/claude-context-facts.ts +++ b/src/main/claude/claude-context-facts.ts @@ -25,7 +25,10 @@ import type { ClaudeOpenTurn } from './claude-open-turn' import { claudeRecord, claudeText } from './claude-structured-item-translation' import type { ClaudeTurnEnd } from './claude-turn-lifecycle-item' import { isRootClaudeFrame } from './claude-turn-opening' -import { writeClaudeTurnRow, type ClaudeTurnRowTarget } from './claude-turn-row-revision' +import { + writeAgentJournalTurnRow, + type AgentJournalTurnRowTarget +} from '../native-chat/agent-session-timeline/agent-journal-turn-row-revision' const CONVERSATION_FRAME_TYPES = new Set(['assistant', 'user', 'stream_event']) @@ -188,7 +191,7 @@ export class ClaudeContextFacts { this.windowHint = report.windowTokens const contextUsage: AgentSessionContextUsage = part === 'report' ? { used: { kind: 'report', ...report }, window } : { window } - writeClaudeTurnRow( + writeAgentJournalTurnRow( this.sink, target === null ? { newest: true } : { identity: target }, { contextUsage }, @@ -211,9 +214,9 @@ export class ClaudeContextFacts { windowIfNoneHeld?: AgentSessionContextWindow ): void { const identity = this.turn.identity - const target: ClaudeTurnRowTarget = identity ? { identity } : { newest: true } + const target: AgentJournalTurnRowTarget = identity ? { identity } : { newest: true } // A context fact often lands with no later frame to publish it, so it publishes itself. - writeClaudeTurnRow( + writeAgentJournalTurnRow( this.sink, target, { contextUsage, ...(windowIfNoneHeld ? { windowIfNoneHeld } : {}) }, diff --git a/src/main/claude/claude-open-turn.ts b/src/main/claude/claude-open-turn.ts index ddc1bd553907..4e7fe7ff9530 100644 --- a/src/main/claude/claude-open-turn.ts +++ b/src/main/claude/claude-open-turn.ts @@ -18,7 +18,7 @@ import { type ClaudeCurrentTurn, type ClaudeTurnEnd } from './claude-turn-lifecycle-item' -import { writeClaudeTurnRow } from './claude-turn-row-revision' +import { writeAgentJournalTurnRow } from '../native-chat/agent-session-timeline/agent-journal-turn-row-revision' import type { ClaudeCommandTurn } from './claude-command-turn' import { createClaudeTurnOpener, type ClaudeTurnSource } from './claude-turn-opening' @@ -203,7 +203,7 @@ export class ClaudeOpenTurn { contextUsage?: AgentSessionContextUsage ): void { const item = claudeTurnLifecycleItem(turn, end) - writeClaudeTurnRow( + writeAgentJournalTurnRow( this.deps.sink, { identity: item.identity }, { lifecycle: item.body, ...(contextUsage ? { contextUsage } : {}) }, diff --git a/src/main/codex/codex-structured-item-translation.test.ts b/src/main/codex/codex-structured-item-translation.test.ts index ad63d624f58b..29562552a02c 100644 --- a/src/main/codex/codex-structured-item-translation.test.ts +++ b/src/main/codex/codex-structured-item-translation.test.ts @@ -11,9 +11,9 @@ import { codexJournalItem, codexMessageBlocks, codexStreamingJournalItem, - CodexTurnOrdinals, - MAX_CODEX_TURN_ORDINAL_BYTES, - MAX_CODEX_TURN_ORDINAL_ENTRIES, + ProviderTurnMessageOrdinals, + MAX_PROVIDER_TURN_ORDINAL_BYTES, + MAX_PROVIDER_TURN_ORDINAL_ENTRIES, isCodexMessageItemType, readCodexThreadItem, type CodexThreadItem @@ -59,7 +59,7 @@ const RESUMED_TURN: CodexThreadItem[] = [ ] function keysFor(items: CodexThreadItem[]): string[] { - const ordinals = new CodexTurnOrdinals() + const ordinals = new ProviderTurnMessageOrdinals() return items .filter((item) => isCodexMessageItemType(item.type)) .map((item) => @@ -71,15 +71,15 @@ function keysFor(items: CodexThreadItem[]): string[] { describe('codex turn ordinals', () => { it('bounds forgotten turn tombstones while retaining the recent window', () => { - const ordinals = new CodexTurnOrdinals() - const total = MAX_CODEX_TURN_ORDINAL_ENTRIES + 12 + const ordinals = new ProviderTurnMessageOrdinals() + const total = MAX_PROVIDER_TURN_ORDINAL_ENTRIES + 12 for (let index = 0; index < total; index += 1) { const turnId = `turn-${index}` expect(ordinals.ordinalFor('thread-many', turnId, 'item-0')).toBe(0) ordinals.forgetTurn('thread-many', turnId) } - expect(ordinals.forgottenTurnCount).toBe(MAX_CODEX_TURN_ORDINAL_ENTRIES) + expect(ordinals.forgottenTurnCount).toBe(MAX_PROVIDER_TURN_ORDINAL_ENTRIES) // The newest completed turn still keeps its counter for a late frame. expect(ordinals.ordinalFor('thread-many', `turn-${total - 1}`, 'item-late')).toBe(1) // The oldest turn was deterministically evicted and starts a fresh key. @@ -87,7 +87,7 @@ describe('codex turn ordinals', () => { }) it('releases a forgotten turn without ever reusing an ordinal it assigned', () => { - const ordinals = new CodexTurnOrdinals() + const ordinals = new ProviderTurnMessageOrdinals() expect(ordinals.ordinalFor('thread-1', 'turn-1', 'item-1')).toBe(0) expect(ordinals.ordinalFor('thread-1', 'turn-1', 'item-2')).toBe(1) @@ -102,12 +102,12 @@ describe('codex turn ordinals', () => { }) it('bounds aggregate provider identifier bytes retained by one active turn', () => { - const ordinals = new CodexTurnOrdinals() + const ordinals = new ProviderTurnMessageOrdinals() for (let index = 0; index < 3_000; index += 1) { ordinals.ordinalFor('thread', 'turn', `${index}:${'x'.repeat(512)}`) } - expect(ordinals.bytes).toBeLessThanOrEqual(MAX_CODEX_TURN_ORDINAL_BYTES) + expect(ordinals.bytes).toBeLessThanOrEqual(MAX_PROVIDER_TURN_ORDINAL_BYTES) }) }) @@ -117,7 +117,7 @@ describe('codex item identity', () => { }) it('numbers messages 0,1,2 on both sides — the projection skips the dropped command', () => { - const ordinals = new CodexTurnOrdinals() + const ordinals = new ProviderTurnMessageOrdinals() const live = LIVE_TURN.map((item) => codexItemIdentity({ threadId: THREAD_ID, turnId: TURN_ID, item, ordinals }) ) @@ -136,7 +136,7 @@ describe('codex item identity', () => { }) it('assigns an ordinal once and reuses it, so a delta and its completion upsert one row', () => { - const ordinals = new CodexTurnOrdinals() + const ordinals = new ProviderTurnMessageOrdinals() ordinals.ordinalFor(THREAD_ID, TURN_ID, 'item-0') expect(ordinals.ordinalFor(THREAD_ID, TURN_ID, 'item-1')).toBe(1) @@ -144,14 +144,14 @@ describe('codex item identity', () => { }) it('restarts numbering per turn', () => { - const ordinals = new CodexTurnOrdinals() + const ordinals = new ProviderTurnMessageOrdinals() ordinals.ordinalFor(THREAD_ID, TURN_ID, 'item-0') expect(ordinals.ordinalFor(THREAD_ID, 'turn-2', 'item-1')).toBe(0) }) it('keys a non-message item and a turnless message in the orca namespace', () => { - const ordinals = new CodexTurnOrdinals() + const ordinals = new ProviderTurnMessageOrdinals() const command = codexItemIdentity({ threadId: THREAD_ID, turnId: TURN_ID, diff --git a/src/main/codex/codex-structured-item-translation.ts b/src/main/codex/codex-structured-item-translation.ts index 25b821d90d9f..3f4229cb6e54 100644 --- a/src/main/codex/codex-structured-item-translation.ts +++ b/src/main/codex/codex-structured-item-translation.ts @@ -28,10 +28,10 @@ export { type CodexThreadItem } from './codex-thread-item-identity' export { - CodexTurnOrdinals, - MAX_CODEX_TURN_ORDINAL_BYTES, - MAX_CODEX_TURN_ORDINAL_ENTRIES -} from './codex-turn-ordinals' + ProviderTurnMessageOrdinals, + MAX_PROVIDER_TURN_ORDINAL_BYTES, + MAX_PROVIDER_TURN_ORDINAL_ENTRIES +} from '../native-chat/agent-session-timeline/provider-turn-message-ordinals' // Codex thread items → journal item bodies. diff --git a/src/main/codex/codex-structured-journal-items.ts b/src/main/codex/codex-structured-journal-items.ts index ec6ac67df5d4..22bb16c8ef08 100644 --- a/src/main/codex/codex-structured-journal-items.ts +++ b/src/main/codex/codex-structured-journal-items.ts @@ -7,7 +7,7 @@ import { requiresTerminalSettlement } from '../native-chat/agent-session-journal import { codexItemIdentity, codexJournalItem, - CodexTurnOrdinals, + ProviderTurnMessageOrdinals, readCodexThreadItem, type CodexThreadItem } from './codex-structured-item-translation' @@ -35,7 +35,7 @@ import { readCodexDispatchEcho } from './codex-structured-dispatch-echo' import type { CodexRowAttribution } from './codex-subagent-linkage' export class CodexJournalItems { - readonly ordinals = new CodexTurnOrdinals() + readonly ordinals = new ProviderTurnMessageOrdinals() readonly activeItems = new Map() readonly streams private readonly identities = new Map() diff --git a/src/main/codex/codex-structured-journal-settlement.ts b/src/main/codex/codex-structured-journal-settlement.ts index 131de9824aae..e7d0a376eca8 100644 --- a/src/main/codex/codex-structured-journal-settlement.ts +++ b/src/main/codex/codex-structured-journal-settlement.ts @@ -17,7 +17,7 @@ import { codexJournalItem, codexStreamingJournalItem, type CodexThreadItem, - type CodexTurnOrdinals + type ProviderTurnMessageOrdinals } from './codex-structured-item-translation' import type { CodexStructuredItemStreams } from './codex-structured-item-streams' import type { CodexHelperName } from './codex-collab-agent-item-translation' @@ -56,7 +56,7 @@ export function settleCodexJournalSession(input: { pendingPrompts: ReadonlyMap currentTurnIds: ReadonlyMap> primaryThreadId: string | null - ordinals: CodexTurnOrdinals + ordinals: ProviderTurnMessageOrdinals /** Terminal lifecycle for a turn the provider left running when it ended; null for a turn a * conversation command claimed, whose record the host settles. */ settledTurnLifecycle: (threadId: string, turnId: string) => AgentJournalTurnLifecycle | null diff --git a/src/main/codex/codex-structured-journal-translation-restore.ts b/src/main/codex/codex-structured-journal-translation-restore.ts index 9f7efc15ae4c..a3b4652f366e 100644 --- a/src/main/codex/codex-structured-journal-translation-restore.ts +++ b/src/main/codex/codex-structured-journal-translation-restore.ts @@ -1,5 +1,8 @@ import type { AgentJournalTurnLifecycle } from '../../shared/agent-session-journal-types' -import { readCodexThreadItem, type CodexTurnOrdinals } from './codex-structured-item-translation' +import { + readCodexThreadItem, + type ProviderTurnMessageOrdinals +} from './codex-structured-item-translation' import type { CodexJournalCompactions } from './codex-structured-journal-compactions' import type { CodexJournalItems } from './codex-structured-journal-items' import { @@ -55,7 +58,7 @@ export function restoreCodexJournalThread(input: { threadId: string thread: Record currentTurnIds: Map> - ordinals: CodexTurnOrdinals + ordinals: ProviderTurnMessageOrdinals handleItem: (event: CodexHistoryItemEvent) => CodexJournalTranslationAdmission /** Absent when the caller has no session identity to key lifecycle rows by. */ restoreTurnLifecycle?: ( diff --git a/src/main/codex/codex-structured-journal-translation-streams.test.ts b/src/main/codex/codex-structured-journal-translation-streams.test.ts index 54a2c017aa05..7bb88edc3a6f 100644 --- a/src/main/codex/codex-structured-journal-translation-streams.test.ts +++ b/src/main/codex/codex-structured-journal-translation-streams.test.ts @@ -6,7 +6,7 @@ import type { import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' import { projectStructuredItemsToNativeChat } from '../../shared/structured-agent-session-projection' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' -import { CodexTurnOrdinals } from './codex-structured-item-translation' +import { ProviderTurnMessageOrdinals } from './codex-structured-item-translation' import { createCodexJournalTranslator, MAX_CODEX_GENERIC_BOOKKEEPING_ENTRIES, @@ -164,7 +164,7 @@ describe('codex journal translation', () => { }) it('releases a turn ordinal map when the turn completes', () => { - const spy = vi.spyOn(CodexTurnOrdinals.prototype, 'forgetTurn') + const spy = vi.spyOn(ProviderTurnMessageOrdinals.prototype, 'forgetTurn') try { const { translator } = translatorWith() translator.handle(TURN_STARTED) diff --git a/src/main/codex/codex-structured-journal-translation-turns.ts b/src/main/codex/codex-structured-journal-translation-turns.ts index 33f138c6578d..dc955f70385c 100644 --- a/src/main/codex/codex-structured-journal-translation-turns.ts +++ b/src/main/codex/codex-structured-journal-translation-turns.ts @@ -8,7 +8,7 @@ import { } from '../../shared/agent-session-journal-types' import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' import { agentJournalTurnBody } from '../../shared/agent-session-turn-record' -import { CODEX_USER_MESSAGE_ORDINAL } from './codex-turn-ordinals' +import { CODEX_USER_MESSAGE_ORDINAL } from './codex-thread-item-identity' import type { StructuredAgentSessionEventSink, StructuredAgentSessionSinkAdmission diff --git a/src/main/codex/codex-thread-item-identity.ts b/src/main/codex/codex-thread-item-identity.ts index 0488e5c00e98..663b497f7731 100644 --- a/src/main/codex/codex-thread-item-identity.ts +++ b/src/main/codex/codex-thread-item-identity.ts @@ -1,5 +1,5 @@ import type { AgentJournalItemIdentity } from '../../shared/agent-session-journal-types' -import type { CodexTurnOrdinals } from './codex-turn-ordinals' +import type { ProviderTurnMessageOrdinals } from '../native-chat/agent-session-timeline/provider-turn-message-ordinals' // Codex thread items → durable journal identities. // @@ -15,6 +15,10 @@ import type { CodexTurnOrdinals } from './codex-turn-ordinals' // including ones this build does not model — is skipped identically on both // sides, which is what makes the key survive a Codex release that adds one. +/** Codex records the user message first in a turn, so a restored submission is + * ordinal 0 of `(threadId, turnId)`. */ +export const CODEX_USER_MESSAGE_ORDINAL = 0 + /** Only these carry a durable `(threadId, turnId, ordinal)` identity. */ const CODEX_MESSAGE_ITEM_TYPES = new Set(['userMessage', 'agentMessage']) @@ -50,7 +54,7 @@ export function codexItemIdentity(input: { threadId: string turnId: string | null item: CodexThreadItem - ordinals: CodexTurnOrdinals + ordinals: ProviderTurnMessageOrdinals }): AgentJournalItemIdentity { const { item, turnId } = input if (turnId && isCodexMessageItemType(item.type)) { diff --git a/src/main/claude/claude-turn-row-revision.ts b/src/main/native-chat/agent-session-timeline/agent-journal-turn-row-revision.ts similarity index 67% rename from src/main/claude/claude-turn-row-revision.ts rename to src/main/native-chat/agent-session-timeline/agent-journal-turn-row-revision.ts index 1b684ba89aaa..69356c406784 100644 --- a/src/main/claude/claude-turn-row-revision.ts +++ b/src/main/native-chat/agent-session-timeline/agent-journal-turn-row-revision.ts @@ -1,4 +1,4 @@ -// Every write to a Claude turn row is a revision of the row as the journal holds +// Every write to a turn row is a revision of the row as the journal holds // it at execution: a writer overrides only the fields it owns and keeps the // rest, whoever wrote them. Nothing about ended turns is kept in memory, so a // restart or reattach revises the same rows a live translator would. @@ -9,34 +9,37 @@ import { MAX_CONTEXT_MODEL_ID_CHARS, type AgentSessionContextUsage, type AgentSessionContextWindow -} from '../../shared/agent-session-context-usage' +} from '../../../shared/agent-session-context-usage' import { agentJournalItemKey, parseAgentJournalItemKey -} from '../../shared/agent-session-journal-item-key' +} from '../../../shared/agent-session-journal-item-key' import { AGENT_JOURNAL_THREAD_SCOPE, type AgentJournalItemIdentity, type AgentJournalTurnItem -} from '../../shared/agent-session-journal-types' -import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' -import { estimateStructuredAgentSessionItemBytes } from '../native-chat/agent-session-wire/structured-agent-session-event-sink-estimate' +} from '../../../shared/agent-session-journal-types' +import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' +import { estimateStructuredAgentSessionItemBytes } from '../agent-session-wire/structured-agent-session-event-sink-estimate' import type { StructuredAgentSessionEventSink, StructuredAgentSessionRevisionJournal, - StructuredAgentSessionRevisionOptions -} from '../native-chat/agent-session-wire/structured-agent-session-event-sink' + StructuredAgentSessionRevisionOptions, + StructuredAgentSessionSinkAdmission +} from '../agent-session-wire/structured-agent-session-event-sink' /** The row a write revises: a known one, or the newest turn in the journal. */ -export type ClaudeTurnRowTarget = { identity: AgentJournalItemIdentity } | { newest: true } +export type AgentJournalTurnRowTarget = { identity: AgentJournalItemIdentity } | { newest: true } -export type ClaudeTurnRowWrite = { +export type AgentJournalTurnRowWrite = { /** The lifecycle fields this turn has now; the lifecycle writer owns all of them. */ lifecycle?: AgentJournalTurnItem /** Context parts, each replacing its namesake on the row. */ contextUsage?: AgentSessionContextUsage /** A window written only while no row in the journal holds one. */ windowIfNoneHeld?: AgentSessionContextWindow + /** Lands only while the row is absent or still running, so an ended turn is never rewritten. */ + onlyWhileRunning?: true } function jsonBytes(value: unknown): number { @@ -84,7 +87,7 @@ const TURN_ROW_BYTES_WITHOUT_CONTEXT = 8 * 1024 /** `lifecycle` over the row's current body, keeping every field it does not own. */ function reviseTurnBody( current: AgentJournalTurnItem | null, - write: ClaudeTurnRowWrite + write: AgentJournalTurnRowWrite ): AgentJournalTurnItem | null { const { lifecycle, contextUsage } = write const base = @@ -113,8 +116,8 @@ function withoutLifecycle(turn: AgentJournalTurnItem) { /** The write with its fallback window resolved against every row, since any of them may hold the newest. */ function withFallbackWindow( journal: StructuredAgentSessionRevisionJournal, - { windowIfNoneHeld, ...write }: ClaudeTurnRowWrite -): ClaudeTurnRowWrite { + { windowIfNoneHeld, ...write }: AgentJournalTurnRowWrite +): AgentJournalTurnRowWrite { if (!windowIfNoneHeld || write.contextUsage?.window) { return write } @@ -129,7 +132,7 @@ function withFallbackWindow( function findTurnRow( journal: StructuredAgentSessionRevisionJournal, - target: ClaudeTurnRowTarget + target: AgentJournalTurnRowTarget ): { itemId: string; body: AgentJournalTurnItem } | null { if ('identity' in target) { const itemId = agentJournalItemKey(target.identity) @@ -147,23 +150,60 @@ function findTurnRow( } /** How a turn-row write reaches live subscribers. */ -export type ClaudeTurnRowDelivery = { +export type AgentJournalTurnRowDelivery = { /** False only for a writer that publishes each write itself right after queueing it. */ publish: boolean options?: Omit } +/** Bytes a turn-row write may resolve to. */ +export function agentJournalTurnRowReservedBytes( + target: AgentJournalTurnRowTarget, + write: AgentJournalTurnRowWrite +): number { + const known = 'identity' in target ? target.identity : null + return ( + (known && write.lifecycle + ? estimateStructuredAgentSessionItemBytes(known, write.lifecycle) + : TURN_ROW_BYTES_WITHOUT_CONTEXT) + contextBytesBound(write.contextUsage) + ) +} + +/** The row a turn-row write lands as, read from the journal at execution; null writes nothing. */ +export function resolveAgentJournalTurnRowWrite( + journal: StructuredAgentSessionRevisionJournal, + target: AgentJournalTurnRowTarget, + write: AgentJournalTurnRowWrite, + reservedBytes: number +): { identity: AgentJournalItemIdentity; body: AgentJournalTurnItem } | null { + const known = 'identity' in target ? target.identity : null + const row = findTurnRow(journal, target) + if (write.onlyWhileRunning && row && row.body.state !== 'running') { + return null + } + const identity = known ?? (row ? parseAgentJournalItemKey(row.itemId) : null) + const body = reviseTurnBody(row?.body ?? null, withFallbackWindow(journal, write)) + if (!identity || !body) { + return null + } + if (estimateStructuredAgentSessionItemBytes(identity, body) <= reservedBytes) { + return { identity, body } + } + // Only a row grown by fields this build does not know gets here; the lifecycle still lands. + return write.lifecycle ? { identity, body: write.lifecycle } : null +} + /** - * Queue one revision of a Claude turn row. A lifecycle write creates the row + * Queue one revision of a turn row. A lifecycle write creates the row * when it is absent; a context write only ever revises one that exists. * Without a journal-reading sink only the lifecycle is written, as it was built. */ -export function writeClaudeTurnRow( +export function writeAgentJournalTurnRow( sink: StructuredAgentSessionEventSink, - target: ClaudeTurnRowTarget, - write: ClaudeTurnRowWrite, - { publish, options: delivery = {} }: ClaudeTurnRowDelivery -): void { + target: AgentJournalTurnRowTarget, + write: AgentJournalTurnRowWrite, + { publish, options: delivery = {} }: AgentJournalTurnRowDelivery +): StructuredAgentSessionSinkAdmission { // A turn record belongs to no turn. const options = { ...delivery, turnScope: AGENT_JOURNAL_THREAD_SCOPE } const revise = publish ? sink.tryReviseResolvedItemAndPublish : sink.tryReviseResolvedItem @@ -174,29 +214,13 @@ export function writeClaudeTurnRow( sink.publish() } } - return + return { accepted: true } } - const known = 'identity' in target ? target.identity : null - const reservedBytes = - (known && write.lifecycle - ? estimateStructuredAgentSessionItemBytes(known, write.lifecycle) - : TURN_ROW_BYTES_WITHOUT_CONTEXT) + contextBytesBound(write.contextUsage) - revise.call( + const reservedBytes = agentJournalTurnRowReservedBytes(target, write) + return revise.call( sink, reservedBytes, - (journal) => { - const row = findTurnRow(journal, target) - const identity = known ?? (row ? parseAgentJournalItemKey(row.itemId) : null) - const body = reviseTurnBody(row?.body ?? null, withFallbackWindow(journal, write)) - if (!identity || !body) { - return null - } - if (estimateStructuredAgentSessionItemBytes(identity, body) <= reservedBytes) { - return { identity, body } - } - // Only a row grown by fields this build does not know gets here; the lifecycle still lands. - return write.lifecycle ? { identity, body: write.lifecycle } : null - }, + (journal) => resolveAgentJournalTurnRowWrite(journal, target, write, reservedBytes), options ) } diff --git a/src/main/codex/codex-turn-ordinals.test.ts b/src/main/native-chat/agent-session-timeline/provider-turn-message-ordinals.test.ts similarity index 64% rename from src/main/codex/codex-turn-ordinals.test.ts rename to src/main/native-chat/agent-session-timeline/provider-turn-message-ordinals.test.ts index 5ae12011a855..b070b449f264 100644 --- a/src/main/codex/codex-turn-ordinals.test.ts +++ b/src/main/native-chat/agent-session-timeline/provider-turn-message-ordinals.test.ts @@ -1,16 +1,17 @@ import { expect, it } from 'vitest' import { - CodexTurnOrdinals, - MAX_CODEX_TURN_ORDINAL_BYTES, - MAX_CODEX_TURN_ORDINAL_ENTRIES -} from './codex-turn-ordinals' + ProviderTurnMessageOrdinals, + MAX_PROVIDER_TURN_ORDINAL_BYTES, + MAX_PROVIDER_TURN_ORDINAL_ENTRIES +} from './provider-turn-message-ordinals' it('does not rescan the forgotten window for each new streamed item', () => { - const ordinals = new CodexTurnOrdinals() - for (let index = 0; index < MAX_CODEX_TURN_ORDINAL_ENTRIES; index += 1) { + const ordinals = new ProviderTurnMessageOrdinals() + for (let index = 0; index < MAX_PROVIDER_TURN_ORDINAL_ENTRIES; index += 1) { ordinals.ordinalFor('thread', String(index), 'item') ordinals.forgetTurn('thread', String(index)) } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this test counts reads of the class's private turn map, whose entries carry `active`. const turns = (ordinals as unknown as { turns: Map }).turns let reads = 0 for (const turn of turns.values()) { @@ -29,30 +30,30 @@ it('does not rescan the forgotten window for each new streamed item', () => { expect(ordinals.ordinalFor('thread', 'live', String(index))).toBe(index) } expect(reads).toBe(0) - expect(ordinals.forgottenTurnCount).toBe(MAX_CODEX_TURN_ORDINAL_ENTRIES) + expect(ordinals.forgottenTurnCount).toBe(MAX_PROVIDER_TURN_ORDINAL_ENTRIES) }) it('retains ordinal continuity on late reactivation and evicts oldest forgotten turns', () => { - const ordinals = new CodexTurnOrdinals() + const ordinals = new ProviderTurnMessageOrdinals() expect(ordinals.ordinalFor('t', 'first', 'a')).toBe(0) ordinals.forgetTurn('t', 'first') expect(ordinals.ordinalFor('t', 'first', 'b')).toBe(1) expect(ordinals.forgottenTurnCount).toBe(0) ordinals.forgetTurn('t', 'first') - for (let index = 0; index < MAX_CODEX_TURN_ORDINAL_ENTRIES; index += 1) { + for (let index = 0; index < MAX_PROVIDER_TURN_ORDINAL_ENTRIES; index += 1) { ordinals.ordinalFor('t', String(index), 'a') ordinals.forgetTurn('t', String(index)) } - expect(ordinals.forgottenTurnCount).toBe(MAX_CODEX_TURN_ORDINAL_ENTRIES) + expect(ordinals.forgottenTurnCount).toBe(MAX_PROVIDER_TURN_ORDINAL_ENTRIES) expect(ordinals.ordinalFor('t', 'first', 'c')).toBe(0) }) it('keeps byte eviction bounded for active and forgotten turns', () => { - const ordinals = new CodexTurnOrdinals() + const ordinals = new ProviderTurnMessageOrdinals() for (let index = 0; index < 4000; index += 1) { ordinals.ordinalFor('thread', 'live', `${index}-${'x'.repeat(240)}`) } - expect(ordinals.bytes).toBeLessThanOrEqual(MAX_CODEX_TURN_ORDINAL_BYTES) + expect(ordinals.bytes).toBeLessThanOrEqual(MAX_PROVIDER_TURN_ORDINAL_BYTES) ordinals.forgetTurn('thread', 'live') expect(ordinals.bytes).toBeLessThan(100) expect(ordinals.forgottenTurnCount).toBe(1) diff --git a/src/main/codex/codex-turn-ordinals.ts b/src/main/native-chat/agent-session-timeline/provider-turn-message-ordinals.ts similarity index 88% rename from src/main/codex/codex-turn-ordinals.ts rename to src/main/native-chat/agent-session-timeline/provider-turn-message-ordinals.ts index 7e21b900d965..9405ff9281ef 100644 --- a/src/main/codex/codex-turn-ordinals.ts +++ b/src/main/native-chat/agent-session-timeline/provider-turn-message-ordinals.ts @@ -1,18 +1,11 @@ -import { - boundPayload, - digestPayload -} from '../native-chat/agent-session-journal/journal-payload-bounds' - -/** Codex records the user message first in a turn, so a restored submission is - * ordinal 0 of `(threadId, turnId)`. */ -export const CODEX_USER_MESSAGE_ORDINAL = 0 +import { boundPayload, digestPayload } from '../agent-session-journal/journal-payload-bounds' /** Maximum forgotten turn keys retained for late-frame reconciliation. */ -export const MAX_CODEX_TURN_ORDINAL_ENTRIES = 256 -export const MAX_CODEX_TURN_ORDINAL_BYTES = 512 * 1024 +export const MAX_PROVIDER_TURN_ORDINAL_ENTRIES = 256 +export const MAX_PROVIDER_TURN_ORDINAL_BYTES = 512 * 1024 /** Assigns stable message ordinals while retaining a bounded late-frame window. */ -export class CodexTurnOrdinals { +export class ProviderTurnMessageOrdinals { private readonly turns = new Map< string, { assigned: Map; next: number; active: boolean } @@ -46,7 +39,7 @@ export class CodexTurnOrdinals { } private trimForgotten(): void { - while (this.forgottenTurnCount > MAX_CODEX_TURN_ORDINAL_ENTRIES) { + while (this.forgottenTurnCount > MAX_PROVIDER_TURN_ORDINAL_ENTRIES) { const oldest = this.forgottenTurns.values().next().value if (!oldest) { break @@ -67,7 +60,7 @@ export class CodexTurnOrdinals { private trimBytes(currentTurnKey: string): void { this.trimForgotten() - while (this.retainedBytes > MAX_CODEX_TURN_ORDINAL_BYTES) { + while (this.retainedBytes > MAX_PROVIDER_TURN_ORDINAL_BYTES) { const forgotten = this.forgottenTurns.values().next().value const oldest = forgotten ?? this.turns.keys().next().value if (typeof oldest !== 'string') { From 8a42c335c541df45eee0b42c7419d9d2b13680dc Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 21:52:04 -0700 Subject: [PATCH 09/62] Admit one provider event's writes as one transition, and let rows be found again after a restart - A sink transition is admitted whole or not at all; its steps run back to back at their turn in the journal's write queue, and each resolver reads the fold with every earlier write landed. A resolver may also say where the row belongs (turn scope, provider reference), and the writer always hears how the transition landed. A resolved lifecycle batch chooses its settlement mutations from the fold at execution. - New optional row field providerItemRef: the provider's own reference for the item a row is, written only where the row's identity cannot spell it (Codex keys messages by their place in the turn and renumbers its item ids on resume). Set by the creating write, kept by revisions, indexed by the journal fold, never read by clients. A downgrade test shows an older host and client render such rows unchanged. - Provider timeline identity schemes (shared legacy arm, Codex) and the join index that resolves a provider item to its row from memory or the fold: ordinals and request incarnations are read back from the rows, so a restart or an evicted entry finds the original row instead of placing a new one. --- .../codex/codex-provider-timeline-identity.ts | 43 +++ .../journal-item-appender.ts | 9 +- .../journal-item-fold.ts | 10 +- .../journal-lifecycle-batch-appender.ts | 30 +- .../journal-provider-item-ref.test.ts | 75 ++++ .../agent-session-journal/journal-reducer.ts | 3 + .../journal-render-item.ts | 1 + .../journal-row-builders.ts | 5 +- .../journal-row-schema.ts | 12 +- .../journal-row-writer.ts | 55 +-- .../journal-store-collaborators.ts | 32 +- .../journal-store-contracts.ts | 10 +- .../agent-session-journal/journal-store.ts | 11 + .../provider-timeline-identity.ts | 133 +++++++ .../provider-timeline-joins.test.ts | 176 ++++++++++ .../provider-timeline-joins.ts | 330 ++++++++++++++++++ .../agent-session-delta-coalescer.ts | 25 ++ ...ructured-agent-session-event-sink-queue.ts | 3 + .../structured-agent-session-event-sink.ts | 9 +- ...ed-agent-session-journal-append-options.ts | 1 + ...tructured-agent-session-transition.test.ts | 265 ++++++++++++++ .../structured-agent-session-transition.ts | 156 +++++++++ src/shared/agent-session-journal-types.ts | 9 + ...n-provider-item-ref-downgrade.unit.test.ts | 127 +++++++ 24 files changed, 1486 insertions(+), 44 deletions(-) create mode 100644 src/main/codex/codex-provider-timeline-identity.ts create mode 100644 src/main/native-chat/agent-session-journal/journal-provider-item-ref.test.ts create mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-identity.ts create mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-joins.test.ts create mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-joins.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-transition.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-transition.ts create mode 100644 tests/e2e/cross-version-wire/agent-session-provider-item-ref-downgrade.unit.test.ts diff --git a/src/main/codex/codex-provider-timeline-identity.ts b/src/main/codex/codex-provider-timeline-identity.ts new file mode 100644 index 000000000000..029c13fe56e2 --- /dev/null +++ b/src/main/codex/codex-provider-timeline-identity.ts @@ -0,0 +1,43 @@ +// Codex's persisted identities, spelled for the shared timeline assembler. +// +// The same rows Codex writes today (`codexItemIdentity`, `codexTurnLifecycleIdentity`): a user or +// assistant message inside a turn keys by (thread, turn, message ordinal), which survives a resume +// because the ordinal counts messages only; anything else keys by its Codex item id; a turn keys by +// its Codex turn id. Codex ids name one conversation for good, so the namespace spells nothing. + +import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import type { ProviderTimelineIdentityScheme } from '../native-chat/agent-session-timeline/provider-timeline-identity' +import { + codexTurnLifecycleIdentity, + codexTurnUserItemId +} from './codex-structured-journal-translation-turns' + +export function createCodexProviderTimelineIdentityScheme(input: { + sessionId: string + primaryThreadId: () => string | null +}): ProviderTimelineIdentityScheme { + const turn: ProviderTimelineIdentityScheme['turn'] = (address) => + codexTurnLifecycleIdentity(input.sessionId, address.key.value) + return { + ordinalMessages: true, + turn, + turnId: (address) => address.key.value, + turnOpener: (address) => { + const thread = input.primaryThreadId() + return thread === null + ? agentJournalItemKey(turn(address)) + : codexTurnUserItemId(thread, address.key.value) + }, + item: (address) => { + const threadId = address.thread ?? input.primaryThreadId() ?? '' + return address.itemClass === 'message' && address.turn && address.messageOrdinal !== null + ? { + provider: 'codex', + threadId, + turnId: address.turn.value, + ordinal: address.messageOrdinal + } + : { provider: 'orca', clientMessageId: `codex-item:${threadId}:${address.key.value}` } + } + } +} diff --git a/src/main/native-chat/agent-session-journal/journal-item-appender.ts b/src/main/native-chat/agent-session-journal/journal-item-appender.ts index 146078d830c9..479f0bc04a9d 100644 --- a/src/main/native-chat/agent-session-journal/journal-item-appender.ts +++ b/src/main/native-chat/agent-session-journal/journal-item-appender.ts @@ -10,7 +10,12 @@ import type { JournalRow } from './journal-row-schema' const NOTHING_RESOLVED = new Error('journal_item_resolved_to_nothing') -export type JournalResolvedItem = { identity: AgentJournalItemIdentity; body: AgentJournalItemBody } +export type JournalResolvedItem = { + identity: AgentJournalItemIdentity + body: AgentJournalItemBody + /** Replaces the call's options when only the fold could say where the row belongs. */ + options?: JournalItemAppendOptions +} export class JournalItemAppender { constructor( @@ -48,7 +53,7 @@ export class JournalItemAppender { this.deps.state, resolved.identity, resolved.body, - options + resolved.options ?? options )(seq, ts) }) .then( diff --git a/src/main/native-chat/agent-session-journal/journal-item-fold.ts b/src/main/native-chat/agent-session-journal/journal-item-fold.ts index 10aee4a99c41..aa1a02e4a70a 100644 --- a/src/main/native-chat/agent-session-journal/journal-item-fold.ts +++ b/src/main/native-chat/agent-session-journal/journal-item-fold.ts @@ -39,6 +39,9 @@ export function upsertJournalItem( return } state.derivedTurnScope.observe(itemId, isRootAgentJournalItem(next), existing?.body, next.body) + if (next.providerItemRef !== undefined && existing?.providerItemRef === undefined) { + state.providerItemRefs.set(next.providerItemRef, itemId) + } if (!existing) { state.items.set(itemId, next) state.itemFences.set(itemId, fence) @@ -65,7 +68,9 @@ export function upsertJournalItem( sequence: existing.sequence, ...(existing.sequenceIndex !== undefined ? { sequenceIndex: existing.sequenceIndex } : {}), observedAt: existing.observedAt, - turnScope: existing.turnScope ?? next.turnScope + turnScope: existing.turnScope ?? next.turnScope, + // The creating write's, as the scope is: a revision never re-points the row at another item. + ...(existing.providerItemRef === undefined ? {} : { providerItemRef: existing.providerItemRef }) }) state.tombstones.delete(itemId) } @@ -92,6 +97,9 @@ export function removeJournalItem( ) } state.tombstones.set(itemId, revision) + if (existing?.providerItemRef !== undefined) { + state.providerItemRefs.delete(existing.providerItemRef) + } state.items.delete(itemId) state.itemFences.delete(itemId) } diff --git a/src/main/native-chat/agent-session-journal/journal-lifecycle-batch-appender.ts b/src/main/native-chat/agent-session-journal/journal-lifecycle-batch-appender.ts index c05d22a87457..682f1b3c1d5f 100644 --- a/src/main/native-chat/agent-session-journal/journal-lifecycle-batch-appender.ts +++ b/src/main/native-chat/agent-session-journal/journal-lifecycle-batch-appender.ts @@ -1,8 +1,13 @@ import type { AgentJournalCursor } from '../../../shared/agent-session-journal-types' import type { JournalReducerState } from './journal-reducer' +import { partitionJournalLifecycleMutations } from './journal-lifecycle-batch-partition' import { journalLifecycleBatchRowBuilder } from './journal-row-builders' -import type { JournalLifecycleBatchInput } from './journal-store-contracts' +import type { + JournalLifecycleBatchInput, + JournalResolvedLifecycleBatchInput +} from './journal-store-contracts' import type { JournalRow } from './journal-row-schema' +import type { JournalRowWriter } from './journal-row-writer' const SETTLEMENT_ALREADY_APPLIED = new Error('journal_settlement_already_applied') @@ -12,6 +17,7 @@ export class JournalLifecycleBatchAppender { state: () => JournalReducerState cursor: () => AgentJournalCursor enqueue: (build: (seq: number, ts: number) => JournalRow) => Promise + enqueueEach: JournalRowWriter['enqueueEach'] } ) {} @@ -41,6 +47,28 @@ export class JournalLifecycleBatchAppender { }) } + /** Chooses the mutations at its turn in the queue; one too large for a row becomes consecutive + * rows that nothing else lands between, in the order resolved. */ + appendResolved(input: JournalResolvedLifecycleBatchInput): Promise { + return this.deps + .enqueueEach(() => + partitionJournalLifecycleMutations(input.settlementId, input.resolve()) + .filter((chunk) => !this.wasApplied(chunk.settlementId)) + .map((chunk) => + journalLifecycleBatchRowBuilder( + this.deps.state, + chunk.settlementId, + chunk.mutations, + input + ) + ) + ) + .then((rows) => { + const last = rows.at(-1) + return last ? { epoch: last.epoch, sequence: last.seq } : null + }) + } + private wasApplied(settlementId: string): boolean { return this.deps.state().appliedSettlementIds.has(settlementId) } diff --git a/src/main/native-chat/agent-session-journal/journal-provider-item-ref.test.ts b/src/main/native-chat/agent-session-journal/journal-provider-item-ref.test.ts new file mode 100644 index 000000000000..d80d5a539965 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-provider-item-ref.test.ts @@ -0,0 +1,75 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import type { AgentJournalItemIdentity } from '../../../shared/agent-session-journal-types' +import { createTrackedJournalOpener } from './journal-host-database-test-support' + +const journals = createTrackedJournalOpener() +const roots: string[] = [] + +afterEach(async () => { + await journals.closeAll() + for (const root of roots.splice(0)) { + await rm(root, { recursive: true, force: true }) + } +}) + +async function open(root?: string) { + const directory = root ?? (await mkdtemp(join(tmpdir(), 'orca-provider-item-ref-'))) + if (!root) { + roots.push(directory) + } + const journal = await journals.open({ + identity: { + sessionId: 'session-ref', + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: 'root' } + }, + stateDirectory: directory, + now: () => 1_000 + }) + return { journal, directory } +} + +const row: AgentJournalItemIdentity = { + provider: 'codex', + threadId: 'root', + turnId: 't1', + ordinal: 0 +} +const text = (value: string) => ({ + kind: 'message' as const, + role: 'assistant' as const, + blocks: [{ type: 'text' as const, text: value }] +}) +const scope = { kind: 'turn', turnItemId: 'turn:t1' } as const + +describe('a row’s provider item reference', () => { + it('is the creating write’s, kept by revisions and indexed for its producer', async () => { + const { journal } = await open() + await journal.appendItem(row, text('a'), { fence: 1, turnScope: scope, providerItemRef: 'm0' }) + await journal.appendItem(row, text('ab'), { fence: 1, turnScope: scope, providerItemRef: 'm9' }) + await journal.appendItem(row, text('abc'), { fence: 1, turnScope: scope }) + + expect(journal.item('codex:root:t1:0')).toMatchObject({ + providerItemRef: 'm0', + body: text('abc') + }) + expect(journal.itemIdForProviderItemRef('m0')).toBe('codex:root:t1:0') + expect(journal.itemIdForProviderItemRef('m9')).toBeNull() + }) + + it('survives a reopen, and leaves the index with its row', async () => { + const { journal, directory } = await open() + await journal.appendItem(row, text('a'), { fence: 1, turnScope: scope, providerItemRef: 'm0' }) + await journals.closeAll() + + const { journal: reopened } = await open(directory) + expect(reopened.itemIdForProviderItemRef('m0')).toBe('codex:root:t1:0') + await reopened.appendTombstone(row, { fence: 1 }) + expect(reopened.itemIdForProviderItemRef('m0')).toBeNull() + }) +}) diff --git a/src/main/native-chat/agent-session-journal/journal-reducer.ts b/src/main/native-chat/agent-session-journal/journal-reducer.ts index a7d6f150e70c..d26b52b144c4 100644 --- a/src/main/native-chat/agent-session-journal/journal-reducer.ts +++ b/src/main/native-chat/agent-session-journal/journal-reducer.ts @@ -56,6 +56,8 @@ export type JournalReducerState = { /** Provider item id → the submission slot that adopted it. Stops an accepted * echo from appending a second copy of the user's own message. */ aliases: Map + /** `providerItemRef` → the row that carries it, so a producer finds that row again in O(1). */ + providerItemRefs: Map appliedSettlementIds: Set /** Scope for rows stored without one; rebuilt by replay, never persisted. */ derivedTurnScope: JournalDerivedTurnScope @@ -80,6 +82,7 @@ export function createJournalReducerState(sessionId: string, epoch: string): Jou submissions: new Map(), receipts: new Map(), aliases: new Map(), + providerItemRefs: new Map(), appliedSettlementIds: new Set(), derivedTurnScope: new JournalDerivedTurnScope(), latestPersonTurnSequence: 0, diff --git a/src/main/native-chat/agent-session-journal/journal-render-item.ts b/src/main/native-chat/agent-session-journal/journal-render-item.ts index fd58b2c25e4e..76a7abf054ec 100644 --- a/src/main/native-chat/agent-session-journal/journal-render-item.ts +++ b/src/main/native-chat/agent-session-journal/journal-render-item.ts @@ -35,6 +35,7 @@ export function journalRenderItem( ...(row.recovered ? { recoveredAt: row.ts } : {}), ...(row.recovered ? { recovered: row.recovered } : {}), turnScope, + ...(row.providerItemRef === undefined ? {} : { providerItemRef: row.providerItemRef }), ...agentJournalLinkageFields(producer) } } diff --git a/src/main/native-chat/agent-session-journal/journal-row-builders.ts b/src/main/native-chat/agent-session-journal/journal-row-builders.ts index 0865df3a41fa..16bf1960e400 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-builders.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-builders.ts @@ -49,7 +49,8 @@ export function journalItemRowBuilder( ts: options.observedAt ?? ts, recovered: options.recovered, linkage: options, - turnScope: options.turnScope + turnScope: options.turnScope, + ...(options.providerItemRef === undefined ? {} : { providerItemRef: options.providerItemRef }) }) } @@ -249,6 +250,7 @@ export function buildJournalItemRow(input: { recovered?: true linkage?: AgentJournalProducerLinkage turnScope: AgentJournalTurnScope + providerItemRef?: string }): JournalItemRow { const itemId = agentJournalItemKey(input.identity) const resolved = input.state.aliases.get(itemId) ?? itemId @@ -268,6 +270,7 @@ export function buildJournalItemRow(input: { ...journalRowBase(input.state.epoch, input.seq, input.fence, input.ts, [body]), ...(input.recovered ? { recovered: input.recovered } : {}), turnScope: input.turnScope, + ...(input.providerItemRef === undefined ? {} : { providerItemRef: input.providerItemRef }), ...agentJournalLinkageFields(input.linkage) } } diff --git a/src/main/native-chat/agent-session-journal/journal-row-schema.ts b/src/main/native-chat/agent-session-journal/journal-row-schema.ts index 9276b1a8c05f..e8d1746ceb8d 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-schema.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-schema.ts @@ -20,6 +20,7 @@ import { type AgentJournalItemBody, type AgentJournalMessageItem, type AgentJournalProducerLinkage, + type AgentJournalProviderItemRef, type AgentJournalTurnScope, type AgentSessionProviderHandle } from '../../../shared/agent-session-journal-types' @@ -51,6 +52,9 @@ type JournalRowBase = AgentJournalProducerLinkage & { * for the reason linkage does. Absent on rows from hosts that predate it: the reducer * derives one for them on read. */ turnScope?: AgentJournalTurnScope + /** On an item row: the provider's reference for the item it creates. Rides the base for the + * reason linkage does. */ + providerItemRef?: AgentJournalProviderItemRef } /** First row of every epoch: binds the epoch to a provider handle and records why it opened. */ @@ -249,7 +253,13 @@ export function parseJournalRow(line: string): JournalRowParse { * survives is a real one: the reader scopes on PRESENCE, so `''` or a * non-string left in place would hide the row from its own author for good. */ function dropUnusableProducerLinkage(record: Record): void { - for (const field of ['agentId', 'parentAgentId', 'providerParentRef', 'producerKind']) { + for (const field of [ + 'agentId', + 'parentAgentId', + 'providerParentRef', + 'producerKind', + 'providerItemRef' + ]) { const value = record[field] if (value !== undefined && (typeof value !== 'string' || value.length === 0)) { delete record[field] diff --git a/src/main/native-chat/agent-session-journal/journal-row-writer.ts b/src/main/native-chat/agent-session-journal/journal-row-writer.ts index b07fda9459bc..17a7f551370d 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-writer.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-writer.ts @@ -37,27 +37,40 @@ export class JournalRowWriter { build: (seq: number, ts: number) => JournalRow, hook?: JournalRowTransactionHook ): Promise { - return this.deps.serialize(() => { - assertJournalWritable(this.deps.readOnly(), this.deps.sessionId) - const row = build(this.deps.nextSequence(), this.deps.now()) - assertJournalFence(row.fence, this.deps.highestFence()) - try { - // One INSERT: the chat's epoch pointer moves only when the epoch does. - this.deps.database().transaction((db) => { - insertJournalRow(db, this.deps.sessionId, row) - hook?.(db, row) - this.runBookkeeping(db, row) - }) - } catch (error) { - this.deps.rolledBack?.() - throw error - } - // COMMIT landed, so the row is durable: adopt it before anything that can - // fail. Rejecting here instead would leave the next append reusing a - // sequence the table already holds. - this.deps.commit(row) - return row - }) + return this.deps.serialize(() => this.write(build, hook)) + } + + /** Rows chosen at this write's turn in the queue and written back to back, so no other write + * lands between them; each is built after the one before it is adopted. */ + enqueueEach( + resolve: () => readonly ((seq: number, ts: number) => JournalRow)[] + ): Promise { + return this.deps.serialize(() => resolve().map((build) => this.write(build))) + } + + private write( + build: (seq: number, ts: number) => JournalRow, + hook?: JournalRowTransactionHook + ): JournalRow { + assertJournalWritable(this.deps.readOnly(), this.deps.sessionId) + const row = build(this.deps.nextSequence(), this.deps.now()) + assertJournalFence(row.fence, this.deps.highestFence()) + try { + // One INSERT: the chat's epoch pointer moves only when the epoch does. + this.deps.database().transaction((db) => { + insertJournalRow(db, this.deps.sessionId, row) + hook?.(db, row) + this.runBookkeeping(db, row) + }) + } catch (error) { + this.deps.rolledBack?.() + throw error + } + // COMMIT landed, so the row is durable: adopt it before anything that can + // fail. Rejecting here instead would leave the next append reusing a + // sequence the table already holds. + this.deps.commit(row) + return row } /** Assign the next sequence, make the row durable, and fold it through the SAME reducer diff --git a/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts b/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts index 65ea665879b5..c00de57f6a66 100644 --- a/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts +++ b/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts @@ -92,6 +92,20 @@ export function createJournalStoreCollaborators(host: JournalStoreHost): Journal wroteBeforeOpen: (sequence) => host.journal().wroteBeforeOpen(sequence), committed: host.notifyCommitted }) + const rowWriter = new JournalRowWriter({ + sessionId: host.identity.sessionId, + now: host.now, + serialize: host.serialize, + database: host.database, + readOnly: host.readOnly, + highestFence: () => host.state().highestFence, + nextSequence: () => host.state().lastSequence + 1, + commit: host.commit, + // Every rejection is a dispatch row through this one writer; the draft + // returned-transition rides it so no path can bypass the hook. + inTransaction: (db, row) => queuedMessages.onRowInTransaction(db, row), + rolledBack: () => queuedMessages.invalidate() + }) return { epochController, queuedMessages, @@ -102,20 +116,7 @@ export function createJournalStoreCollaborators(host: JournalStoreHost): Journal restoreJournalStore(host, { epochController }).then(() => queuedMessages.repairAndPruneAtOpen() ), - rowWriter: new JournalRowWriter({ - sessionId: host.identity.sessionId, - now: host.now, - serialize: host.serialize, - database: host.database, - readOnly: host.readOnly, - highestFence: () => host.state().highestFence, - nextSequence: () => host.state().lastSequence + 1, - commit: host.commit, - // Every rejection is a dispatch row through this one writer; the draft - // returned-transition rides it so no path can bypass the hook. - inTransaction: (db, row) => queuedMessages.onRowInTransaction(db, row), - rolledBack: () => queuedMessages.invalidate() - }), + rowWriter, itemAppender: new JournalItemAppender({ state: host.state, enqueue: host.enqueue @@ -123,7 +124,8 @@ export function createJournalStoreCollaborators(host: JournalStoreHost): Journal lifecycleBatchAppender: new JournalLifecycleBatchAppender({ state: host.state, cursor: host.cursor, - enqueue: host.enqueue + enqueue: host.enqueue, + enqueueEach: (resolve) => rowWriter.enqueueEach(resolve) }) } } diff --git a/src/main/native-chat/agent-session-journal/journal-store-contracts.ts b/src/main/native-chat/agent-session-journal/journal-store-contracts.ts index 85e6b99f9029..173e0c062e97 100644 --- a/src/main/native-chat/agent-session-journal/journal-store-contracts.ts +++ b/src/main/native-chat/agent-session-journal/journal-store-contracts.ts @@ -62,7 +62,10 @@ export type JournalItemLinkageVisitor = ( itemId: string, sequence: number, body: AgentJournalItemBody, - attribution: AgentJournalProducerLinkage & { turnScope?: AgentJournalTurnScope } + attribution: AgentJournalProducerLinkage & { + turnScope?: AgentJournalTurnScope + providerItemRef?: string + } ) => void export type JournalLifecycleBatchInput = { @@ -72,6 +75,11 @@ export type JournalLifecycleBatchInput = { recovered?: true } +export type JournalResolvedLifecycleBatchInput = Omit & { + /** Read from the fold with every earlier write landed; may return none. */ + resolve: () => readonly JournalLifecycleMutationInput[] +} + export type JournalSubmissionInput = { clientMessageId: string payloadFingerprint: string diff --git a/src/main/native-chat/agent-session-journal/journal-store.ts b/src/main/native-chat/agent-session-journal/journal-store.ts index 5a4880155dec..53929d836e77 100644 --- a/src/main/native-chat/agent-session-journal/journal-store.ts +++ b/src/main/native-chat/agent-session-journal/journal-store.ts @@ -8,6 +8,7 @@ import type { AgentJournalCursor, AgentJournalItemBody, AgentJournalItemIdentity, + AgentJournalRenderItem, AgentJournalSnapshot, AgentJournalSubmission, AgentJournalThreadGoal, @@ -227,6 +228,11 @@ export class AgentSessionJournal { itemBody = (itemId: string): AgentJournalItemBody | null => this.state.items.get(itemId)?.body ?? null + /** One reduced item with its attribution, for a writer that needs the turn a row joined. */ + item = (itemId: string): AgentJournalRenderItem | null => this.state.items.get(itemId) ?? null + /** The row that carries this `providerItemRef`, if any. */ + itemIdForProviderItemRef = (ref: string) => this.state.providerItemRefs.get(ref) ?? null + /** Visits reduced items with the producer that wrote each, for a producer re-deriving what an * earlier run of this session left. */ visitItemsWithLinkage = (visit: JournalItemLinkageVisitor): void => { @@ -331,6 +337,11 @@ export class AgentSessionJournal { return this.lifecycleBatchAppender.append(input) } + /** A lifecycle batch whose mutations are chosen from the fold at its own turn in the queue; + * null when none are. */ + appendResolvedLifecycleBatch: JournalLifecycleBatchAppender['appendResolved'] = (input) => + this.lifecycleBatchAppender.appendResolved(input) + /** * Write-ahead submission row. It is durable before the caller dispatches * anything, and it doubles as the optimistic user bubble so an accepted echo diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-identity.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-identity.ts new file mode 100644 index 000000000000..d364b4d31acf --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-identity.ts @@ -0,0 +1,133 @@ +// How the assembler's addresses become persisted journal identities. +// +// The assembler decides WHICH rows are the same row: it resolves every provider join to an +// address — the provider session's namespace, the key, the thread and turn the item belongs to, +// the item's class, and its place among its turn's messages — or mints a key when the provider +// names nothing. A scheme only spells an address as an `AgentJournalItemIdentity`. It must be pure, +// and it is the one place a provider's persisted identity shape lives, so moving a provider to a +// new identity arm is a change here. + +import type { + AgentJournalItemBody, + AgentJournalItemIdentity, + AgentType +} from '../../../shared/agent-session-journal-types' +import { boundPayload, digestPayload } from '../agent-session-journal/journal-payload-bounds' + +/** A key the provider vouched for, or one the assembler minted because it named nothing. + * A minted value is unique per acquisition, so it never needs a namespace. */ +export type ProviderTimelineKey = { source: 'provider' | 'minted'; value: string } + +export type ProviderTimelineTurnAddress = { + /** The provider session whose ids the key belongs to. */ + namespace: string + key: ProviderTimelineKey +} + +/** Which family an item belongs to; families never share a key space. Streamed text and full + * item snapshots are one family, so a provider-named message is one row however it arrives. */ +export type ProviderTimelineItemFamily = 'item' | 'request' | 'frame' + +/** What kind of thing an item is, derived from what it carries: `message` is user or assistant + * prose; every other class is its row kind (`reasoning` text included). */ +export type ProviderTimelineItemClass = + | 'message' + | 'reasoning' + | Exclude + | 'frame' + +export type ProviderTimelineItemAddress = { + namespace: string + family: ProviderTimelineItemFamily + key: ProviderTimelineKey + /** The provider thread it came from; null when the provider named none. */ + thread: string | null + /** The provider turn it belongs to; null for a row outside any turn. */ + turn: ProviderTimelineKey | null + itemClass: ProviderTimelineItemClass + /** Its place among its (thread, turn)'s `message` items, by first write; null for any other, and + * for every item of a scheme without `ordinalMessages`. */ + messageOrdinal: number | null + /** 1 for the first request under a key; a key reused after it settled is the next one. */ + incarnation: number +} + +export type ProviderTimelineIdentityScheme = { + /** Messages inside a turn are keyed by their place among the turn's messages, not by their key. + * Such an identity must spell only (thread, turn, ordinal), and its row carries the key as + * `providerItemRef`, since nothing else could find it again. */ + ordinalMessages: boolean + turn(address: ProviderTimelineTurnAddress): AgentJournalItemIdentity + /** The id the turn row carries and a client's Stop names. */ + turnId(address: ProviderTimelineTurnAddress): string + /** The user item a turn names when no send of Orca's opened it; absent ⇒ the turn row itself. */ + turnOpener?(address: ProviderTimelineTurnAddress): string + /** With a null `messageOrdinal` it must spell only (namespace, family, key, thread, incarnation), + * so a row is found again from its key alone. */ + item(address: ProviderTimelineItemAddress): AgentJournalItemIdentity +} + +export function providerTimelineItemClass(body: AgentJournalItemBody): ProviderTimelineItemClass { + if (body.kind !== 'message') { + return body.kind + } + return body.role === 'user' || body.role === 'assistant' ? 'message' : 'reasoning' +} + +const MAX_KEY_PART_BYTES = 256 + +/** A provider id as a bounded identity part: escaped so `:` cannot forge another part, and + * digest-suffixed when long so two long ids never share a prefix-only spelling. */ +export function providerTimelineKeyPart(value: string): string { + const encoded = encodeURIComponent(value) + if (Buffer.byteLength(encoded, 'utf8') <= MAX_KEY_PART_BYTES) { + return encoded + } + const suffix = `#${digestPayload(value).slice(0, 24)}` + const head = boundPayload(encoded, { + inlineHeadBytes: MAX_KEY_PART_BYTES - Buffer.byteLength(suffix, 'utf8') + }).head + return `${head}${suffix}` +} + +/** A key as one bounded string inside its namespace (and thread, for a per-thread key). */ +export function spellProviderTimelineKey( + namespace: string, + key: ProviderTimelineKey, + thread: string | null = null +): string { + // A provider's item ids are its own per thread, so a subagent thread's ids are spelled apart. + const scope = thread === null ? '' : `${providerTimelineKeyPart(thread)}/` + return key.source === 'provider' + ? `p:${providerTimelineKeyPart(namespace)}:${scope}${providerTimelineKeyPart(key.value)}` + : `m:${providerTimelineKeyPart(key.value)}` +} + +/** + * The scheme for a provider with no identity arm of its own: the existing `legacy` arm, so no row + * shape changes. Turn rows keep the `turn-lifecycle:` record prefix the other lanes write. + * Provider keys are spelled inside their namespace, and apart from minted ones. + */ +export function createLegacyProviderTimelineIdentityScheme(input: { + agent: AgentType + sessionId: string +}): ProviderTimelineIdentityScheme { + const identity = (recordId: string): AgentJournalItemIdentity => ({ + provider: 'legacy', + agent: input.agent, + sessionId: input.sessionId, + recordId + }) + return { + ordinalMessages: false, + turn: (address) => + identity(`turn-lifecycle:${spellProviderTimelineKey(address.namespace, address.key)}`), + turnId: (address) => spellProviderTimelineKey(address.namespace, address.key), + item: (address) => + identity( + `${address.family}:${spellProviderTimelineKey(address.namespace, address.key, address.family === 'request' ? null : address.thread)}${ + address.incarnation > 1 ? `#${address.incarnation}` : '' + }` + ) + } +} diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-joins.test.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-joins.test.ts new file mode 100644 index 000000000000..b132802369f0 --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-joins.test.ts @@ -0,0 +1,176 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalApprovalItem, + type AgentJournalItemIdentity +} from '../../../shared/agent-session-journal-types' +import { createCodexProviderTimelineIdentityScheme } from '../../codex/codex-provider-timeline-identity' +import { createTrackedJournalOpener } from '../agent-session-journal/journal-host-database-test-support' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { createLegacyProviderTimelineIdentityScheme } from './provider-timeline-identity' +import { ProviderTimelineJoins, type ProviderTimelineItemJoin } from './provider-timeline-joins' + +const journals = createTrackedJournalOpener() +const roots: string[] = [] + +afterEach(async () => { + await journals.closeAll() + for (const root of roots.splice(0)) { + await rm(root, { recursive: true, force: true }) + } +}) + +async function openJournal(): Promise { + const root = await mkdtemp(join(tmpdir(), 'orca-provider-timeline-joins-')) + roots.push(root) + return journals.open({ + identity: { + sessionId: 'session-joins', + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: 'root' } + }, + stateDirectory: root, + now: () => 1_000 + }) +} + +const codexJoins = () => + new ProviderTimelineJoins({ + scheme: createCodexProviderTimelineIdentityScheme({ + sessionId: 'session-joins', + primaryThreadId: () => 'root' + }), + generation: 'gen-1', + namespace: 'ns' + }) + +const message = (id: string): ProviderTimelineItemJoin => ({ + family: 'item', + key: { source: 'provider', value: id }, + thread: 'root' +}) + +const inTurn = (turn: string) => ({ + thread: 'root', + turn: { source: 'provider', value: turn } as const, + scope: { kind: 'turn', turnItemId: `turn:${turn}` } as const +}) + +async function write( + journal: AgentSessionJournal, + row: { identity: AgentJournalItemIdentity; ref?: string }, + text: string +) { + await journal.appendItem( + row.identity, + { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text }] }, + { + fence: 1, + turnScope: { kind: 'turn', turnItemId: 'turn:t1' }, + ...(row.ref === undefined ? {} : { providerItemRef: row.ref }) + } + ) +} + +describe('provider timeline joins', () => { + it('finds an ordinal message again from the reference its row carries, after a restart', async () => { + const journal = await openJournal() + const before = codexJoins() + const m0 = before.place(message('m0'), 'message', inTurn('t1'), journal) + await write(journal, m0, 'first') + const m1 = before.place(message('m1'), 'message', inTurn('t1'), journal) + await write(journal, m1, 'second') + expect([m0.itemId, m1.itemId]).toEqual(['codex:root:t1:0', 'codex:root:t1:1']) + + const after = codexJoins() + expect(after.find(message('m1'), journal)).toMatchObject({ + itemId: 'codex:root:t1:1', + scope: { kind: 'turn', turnItemId: 'turn:t1' } + }) + // A new message takes the next free place, past every row the turn holds. + expect(after.place(message('m2'), 'message', inTurn('t1'), journal).itemId).toBe( + 'codex:root:t1:2' + ) + }) + + it('skips a place an echoed send holds through its submission alias', async () => { + const journal = await openJournal() + await journal.appendSubmission({ + clientMessageId: 'send-1', + payloadFingerprint: 'fp', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hi' }] }, + fence: 1 + }) + await journal.resolveDispatch({ + clientMessageId: 'send-1', + state: 'accepted', + providerIdentity: { provider: 'codex', threadId: 'root', turnId: 't1', ordinal: 0 }, + fence: 1 + }) + expect(codexJoins().place(message('reply'), 'message', inTurn('t1'), journal).itemId).toBe( + 'codex:root:t1:1' + ) + }) + + it('finds a row whose identity spells its key without any reference', async () => { + const journal = await openJournal() + const scheme = createLegacyProviderTimelineIdentityScheme({ + agent: 'grok', + sessionId: 'session-joins' + }) + const joins = () => new ProviderTimelineJoins({ scheme, generation: 'g', namespace: 'ns' }) + const tool = joins().place(message('tool'), 'tool-call', inTurn('t1'), journal) + expect(tool.ref).toBeUndefined() + await write(journal, tool, 'x') + expect(joins().find(message('tool'), journal)?.itemId).toBe(tool.itemId) + expect(joins().find({ ...message('tool'), thread: 'other' }, journal)).toBeNull() + }) + + it('reads the current request incarnation back from the rows', async () => { + const journal = await openJournal() + const scheme = createLegacyProviderTimelineIdentityScheme({ + agent: 'grok', + sessionId: 'session-joins' + }) + const joins = () => new ProviderTimelineJoins({ scheme, generation: 'g', namespace: 'ns' }) + const key = { source: 'provider', value: 'p1' } as const + const thread = { thread: null, turn: null, scope: AGENT_JOURNAL_THREAD_SCOPE } + const approval: AgentJournalApprovalItem = { + kind: 'approval', + title: 'Run?', + detail: null, + options: [], + resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } + } + const before = joins() + for (let incarnation = 1; incarnation <= 2; incarnation += 1) { + const row = before.nextRequest(key, 'approval', thread, journal) + expect(row.incarnation).toBe(incarnation) + await journal.appendItem(row.identity, approval, { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + } + expect(joins().request(key, journal)).toMatchObject({ + incarnation: 2, + itemId: agentJournalItemKey( + scheme.item({ + namespace: 'ns', + family: 'request', + key, + thread: null, + turn: null, + itemClass: 'approval', + messageOrdinal: null, + incarnation: 2 + }) + ) + }) + }) +}) diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-joins.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-joins.ts new file mode 100644 index 000000000000..1b85f32a0aa1 --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-joins.ts @@ -0,0 +1,330 @@ +// Provider joins → journal rows: the assembler's canonical join index. +// +// Every row the assembler writes is found again through here, and every entry is a cache of a +// fact the journal holds. A row whose identity spells its provider key is found by recomputing +// that identity; a row whose identity does not (a message keyed by its place among its turn's +// messages) carries the key as `providerItemRef`, which the journal indexes. So a miss — after a +// restart or an eviction — recovers the original row; it never re-places it. Message ordinals and +// request incarnations are read back from the rows that hold them. Only transition resolvers call +// the allocating members, so allocation happens in journal order and a refused event allocates +// nothing. + +import { + agentJournalItemKey, + parseAgentJournalItemKey +} from '../../../shared/agent-session-journal-item-key' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalItemIdentity, + type AgentJournalRenderItem, + type AgentJournalTurnScope +} from '../../../shared/agent-session-journal-types' +import { BoundedMap } from '../../../shared/bounded-map' +import type { StructuredAgentSessionTransitionJournal } from '../agent-session-wire/structured-agent-session-transition' +import { + providerTimelineKeyPart, + spellProviderTimelineKey, + type ProviderTimelineIdentityScheme, + type ProviderTimelineItemAddress, + type ProviderTimelineItemClass, + type ProviderTimelineItemFamily, + type ProviderTimelineKey, + type ProviderTimelineTurnAddress +} from './provider-timeline-identity' + +const MAX_JOINED_ROWS = 1_024 +const MAX_JOINED_ROW_BYTES = 1024 * 1024 +const MAX_TURN_ORDINALS = 256 +/** Echoed sends whose slot no row holds yet; a lane records each on its send soon after. */ +const MAX_RESERVED_ECHOES = 64 + +/** One provider item as the provider names it. Item keys are the provider's per thread. */ +export type ProviderTimelineItemJoin = { + family: ProviderTimelineItemFamily + key: ProviderTimelineKey + thread: string | null +} + +export type ProviderTimelineRow = { + identity: AgentJournalItemIdentity + itemId: string + scope: AgentJournalTurnScope + /** The join's reference, on a row whose identity does not spell its key. */ + ref?: string + /** Which request under its key, 1 for the first. */ + incarnation: number +} + +export type ProviderTimelineTurnRef = { + address: ProviderTimelineTurnAddress + identity: AgentJournalItemIdentity + itemId: string + turnId: string +} + +/** Where a new row goes: the provider thread and turn, and the journal turn scope. */ +export type ProviderTimelinePlacement = { + thread: string | null + turn: ProviderTimelineKey | null + scope: AgentJournalTurnScope +} + +type Journal = StructuredAgentSessionTransitionJournal | null + +function bytes(value: string): number { + return Buffer.byteLength(value, 'utf8') +} + +export class ProviderTimelineJoins { + private readonly rows = new BoundedMap({ + maxEntries: MAX_JOINED_ROWS, + maxBytes: MAX_JOINED_ROW_BYTES, + sizeOf: (row, key) => bytes(key) + 2 * bytes(row.itemId) + 64 + }) + private readonly ordinals = new BoundedMap({ + maxEntries: MAX_TURN_ORDINALS, + maxBytes: MAX_TURN_ORDINALS * 1024, + sizeOf: (_next, place) => bytes(place) + 8 + }) + private readonly echoes = new BoundedMap({ maxEntries: MAX_RESERVED_ECHOES }) + private serial = 0 + + constructor( + private readonly deps: { + scheme: ProviderTimelineIdentityScheme + generation: string + namespace: string + } + ) {} + + get namespace(): string { + return this.deps.namespace + } + + /** A new provider session: its ids name new rows. Minted keys stay unique as they are. */ + reset(namespace: string): void { + this.deps.namespace = namespace + this.rows.clear() + this.ordinals.clear() + } + + /** A key unique to this acquisition, for something the provider names nothing. */ + mint(kind: string): ProviderTimelineKey { + this.serial += 1 + return { source: 'minted', value: `${this.deps.generation}:${kind}${this.serial}` } + } + + /** A turn's row. `namespace` defaults to the current one; a planner names the one it expects. */ + turn(key: ProviderTimelineKey, namespace = this.deps.namespace): ProviderTimelineTurnRef { + const address = { namespace, key } + const identity = this.deps.scheme.turn(address) + return { + address, + identity, + itemId: agentJournalItemKey(identity), + turnId: this.deps.scheme.turnId(address) + } + } + + /** The user item a turn names when no send of Orca's opened it. */ + turnOpener(turn: ProviderTimelineTurnRef): string { + return this.deps.scheme.turnOpener?.(turn.address) ?? turn.itemId + } + + /** The join as one bounded string: what rows carry as `providerItemRef` and caches key by. */ + reference(join: ProviderTimelineItemJoin, namespace = this.deps.namespace): string { + const thread = join.family === 'request' ? null : join.thread + return `${join.family}:${spellProviderTimelineKey(namespace, join.key, thread)}` + } + + /** The row the join already names, from memory or the fold; null when the journal holds none. */ + find( + join: ProviderTimelineItemJoin, + journal: Journal, + namespace = this.deps.namespace + ): ProviderTimelineRow | null { + const ref = this.reference(join, namespace) + const cached = this.rows.get(ref) + // A planner ahead of a reset asks about a namespace whose rows this index does not spell yet. + if (cached || !journal || namespace !== this.deps.namespace) { + return cached ?? null + } + const itemId = + journal.itemIdForProviderItemRef(ref) ?? agentJournalItemKey(this.spelled(join, 1)) + const row = rowOf(journal.item(itemId), 1) + if (row) { + this.rows.set(ref, row) + } + return row + } + + /** A new row for a join no row names yet. Allocates its message ordinal: resolvers only. */ + place( + join: ProviderTimelineItemJoin, + itemClass: ProviderTimelineItemClass, + placement: ProviderTimelinePlacement, + journal: Journal + ): ProviderTimelineRow { + const ordinal = this.ordinalFor(itemClass, placement, journal) + const identity = this.deps.scheme.item({ + ...this.address(join, 1), + turn: placement.turn, + itemClass, + messageOrdinal: ordinal + }) + const ref = this.reference(join) + const row: ProviderTimelineRow = { + identity, + itemId: agentJournalItemKey(identity), + scope: placement.scope, + ...(ordinal === null ? {} : { ref }), + incarnation: 1 + } + this.rows.set(ref, row) + return row + } + + /** An echoed send takes its message's place without a row (its send is the bubble). */ + reserveEcho( + join: ProviderTimelineItemJoin, + placement: ProviderTimelinePlacement, + journal: Journal + ) { + if (!this.deps.scheme.ordinalMessages || !placement.turn || this.find(join, journal)) { + return + } + const row = this.place(join, 'message', placement, journal) + this.echoes.set(row.itemId, true) + } + + /** The request under `key` the journal holds last: the highest incarnation with a row. */ + request( + key: ProviderTimelineKey, + journal: Journal, + namespace = this.deps.namespace + ): ProviderTimelineRow | null { + const join = { family: 'request' as const, key, thread: null } + const ref = this.reference(join, namespace) + const cached = this.rows.get(ref) + if (cached || !journal || namespace !== this.deps.namespace) { + return cached ?? null + } + let current: ProviderTimelineRow | null = null + for (let incarnation = 1; ; incarnation += 1) { + const itemId = agentJournalItemKey(this.spelled(join, incarnation)) + // A scheme that spells no incarnation names one row for every request under the key. + const row = itemId === current?.itemId ? null : rowOf(journal.item(itemId), incarnation) + if (!row) { + break + } + current = row + } + if (current) { + this.rows.set(ref, current) + } + return current + } + + /** The next request under `key`: resolvers only. */ + nextRequest( + key: ProviderTimelineKey, + itemClass: ProviderTimelineItemClass, + placement: ProviderTimelinePlacement, + journal: Journal + ): ProviderTimelineRow { + const join = { family: 'request' as const, key, thread: null } + const incarnation = (this.request(key, journal)?.incarnation ?? 0) + 1 + const identity = this.deps.scheme.item({ + ...this.address(join, incarnation), + thread: placement.thread, + turn: placement.turn, + itemClass + }) + const row = { + identity, + itemId: agentJournalItemKey(identity), + scope: placement.scope, + incarnation + } + this.rows.set(this.reference(join), row) + return row + } + + private address( + join: ProviderTimelineItemJoin, + incarnation: number + ): ProviderTimelineItemAddress { + return { + namespace: this.deps.namespace, + family: join.family, + key: join.key, + thread: join.thread, + turn: null, + itemClass: 'status', + messageOrdinal: null, + incarnation + } + } + + private spelled(join: ProviderTimelineItemJoin, incarnation: number): AgentJournalItemIdentity { + return this.deps.scheme.item(this.address(join, incarnation)) + } + + /** The next free place among the turn's messages: past every row and echoed send holding one. */ + private ordinalFor( + itemClass: ProviderTimelineItemClass, + placement: ProviderTimelinePlacement, + journal: Journal + ): number | null { + const turn = placement.turn + if (!this.deps.scheme.ordinalMessages || itemClass !== 'message' || !turn) { + return null + } + const place = `${providerTimelineKeyPart(placement.thread ?? '')}\u001f${spellProviderTimelineKey( + this.deps.namespace, + turn + )}` + let ordinal = this.ordinals.get(place) ?? 0 + while (this.taken(this.slot(placement.thread, turn, ordinal), journal)) { + ordinal += 1 + } + this.ordinals.set(place, ordinal + 1) + return ordinal + } + + private slot(thread: string | null, turn: ProviderTimelineKey, ordinal: number): string { + return agentJournalItemKey( + this.deps.scheme.item({ + ...this.address({ family: 'item', key: turn, thread }, 1), + turn, + itemClass: 'message', + messageOrdinal: ordinal + }) + ) + } + + private taken(itemId: string, journal: Journal): boolean { + return ( + this.echoes.has(itemId) || + (journal !== null && + (journal.itemBody(itemId) !== null || journal.canonicalItemId(itemId) !== itemId)) + ) + } +} + +function rowOf( + item: AgentJournalRenderItem | null, + incarnation: number +): ProviderTimelineRow | null { + const identity = item ? parseAgentJournalItemKey(item.itemId) : null + if (!item || !identity) { + return null + } + return { + identity, + itemId: item.itemId, + scope: item.turnScope ?? AGENT_JOURNAL_THREAD_SCOPE, + ...(item.providerItemRef === undefined ? {} : { ref: item.providerItemRef }), + incarnation + } +} diff --git a/src/main/native-chat/agent-session-wire/agent-session-delta-coalescer.ts b/src/main/native-chat/agent-session-wire/agent-session-delta-coalescer.ts index b1d4c483d9bd..eff850ae8e05 100644 --- a/src/main/native-chat/agent-session-wire/agent-session-delta-coalescer.ts +++ b/src/main/native-chat/agent-session-wire/agent-session-delta-coalescer.ts @@ -56,6 +56,10 @@ export type AgentSessionDeltaCoalescer = { dispose: () => void /** Bounded last-known state for terminalizing a rejected completion. */ snapshot: (key: string) => AgentSessionDeltaSnapshot | null + /** Streams with text not yet emitted, in arrival order, for a caller that writes them itself. */ + dirty: () => { key: string; snapshot: AgentSessionDeltaSnapshot }[] + /** The caller wrote this stream's current text itself; nothing is owed until it grows. */ + markFlushed: (key: string) => void } function defaultSchedule(run: () => void, ms: number): () => void { @@ -215,6 +219,27 @@ export function createAgentSessionDeltaCoalescer( truncated: stream.truncated } : null + }, + dirty: () => + [...streams].flatMap(([key, stream]) => + stream.dirty + ? [ + { + key, + snapshot: { + text: stream.chunks.join(''), + observedBytes: stream.observedBytes, + truncated: stream.truncated + } + } + ] + : [] + ), + markFlushed: (key) => { + const stream = streams.get(key) + if (stream) { + stream.dirty = false + } } } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink-queue.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink-queue.ts index bdc07a3b3d54..bfa4b7520078 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink-queue.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink-queue.ts @@ -8,6 +8,7 @@ import type { StructuredAgentSessionSinkState, StructuredAgentSessionSinkWatermarks } from './structured-agent-session-event-sink' +import type { StructuredAgentSessionTransitionJournal } from './structured-agent-session-transition' export type StructuredAgentSessionSinkOperation = { sequence: number @@ -85,6 +86,8 @@ export class StructuredAgentSessionSinkQueue { journalLinkage = (): StructuredAgentSessionLinkageJournal | null => this.target?.journal ?? null + journalItems = (): StructuredAgentSessionTransitionJournal | null => this.target?.journal ?? null + journalStopDecidesTurn = (turnId: string, endedAt: number, openedBy?: string): boolean => this.target?.journal.stopMarks.personStopDecides(turnId, endedAt, openedBy) ?? false diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.ts index 49572abdcc00..5cdabdce713d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.ts @@ -12,6 +12,10 @@ import { estimateStructuredAgentSessionItemBytes } from './structured-agent-sess import { StructuredAgentSessionSinkQueue } from './structured-agent-session-event-sink-queue' import { structuredAgentSessionJournalAppendOptions } from './structured-agent-session-journal-append-options' import { createStructuredAgentSessionResolvedAppend } from './structured-agent-session-resolved-append' +import { + createStructuredAgentSessionTransitionMembers, + type StructuredAgentSessionTransitionSink +} from './structured-agent-session-transition' import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' export type StructuredAgentSessionSinkAdmission = @@ -47,6 +51,8 @@ export type StructuredAgentSessionPublishOptions = Pick< /** An item write states which turn its row belongs to; the write that creates the row decides. */ export type StructuredAgentSessionItemAppendOptions = StructuredAgentSessionAppendOptions & { turnScope: AgentJournalTurnScope + /** The provider's reference for the item, on the write that creates its row. */ + providerItemRef?: string } export type StructuredAgentSessionLifecycleJournal = Pick< @@ -80,7 +86,7 @@ export type StructuredAgentSessionRevisionOptions = StructuredAgentSessionItemAp /** Compatibility alias for lifecycle callers that already use this resolver. */ export type StructuredAgentSessionLifecycleIdentityResolver = StructuredAgentSessionIdentityResolver -export type StructuredAgentSessionEventSink = { +export type StructuredAgentSessionEventSink = StructuredAgentSessionTransitionSink & { appendItem( identity: AgentJournalItemIdentity, body: AgentJournalItemBody, @@ -286,6 +292,7 @@ export function createDeferredStructuredAgentSessionEventSink(deps: { }, tryAppendItem: appendItem, ...resolvedAppend, + ...createStructuredAgentSessionTransitionMembers(queue), journalEpoch: queue.journalEpoch, journalLinkage: queue.journalLinkage, journalStopDecidesTurn: queue.journalStopDecidesTurn, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-journal-append-options.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-journal-append-options.ts index e7ce667ed2b6..57bb482f7c2b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-journal-append-options.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-journal-append-options.ts @@ -16,6 +16,7 @@ export function structuredAgentSessionJournalAppendOptions( fence, ...(options.observedAt === undefined ? {} : { observedAt: options.observedAt }), turnScope: options.turnScope, + ...(options.providerItemRef === undefined ? {} : { providerItemRef: options.providerItemRef }), ...agentJournalLinkageFields(options) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-transition.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-transition.test.ts new file mode 100644 index 000000000000..1b242dd8d983 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-transition.test.ts @@ -0,0 +1,265 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalItemIdentity, + type AgentJournalToolCallItem +} from '../../../shared/agent-session-journal-types' +import { createTrackedJournalOpener } from '../agent-session-journal/journal-host-database-test-support' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { createAgentSessionDeltaCoalescer } from './agent-session-delta-coalescer' +import { + createDeferredStructuredAgentSessionEventSink, + type StructuredAgentSessionSinkWatermarks +} from './structured-agent-session-event-sink' +import { testEventSinkLogging } from './structured-agent-session-logger-test-support' +import type { + StructuredAgentSessionTransition, + StructuredAgentSessionTransitionStep +} from './structured-agent-session-transition' + +const SESSION = 'session-transition' +const journals = createTrackedJournalOpener() +const roots: string[] = [] + +afterEach(async () => { + await journals.closeAll() + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +function identity(recordId: string): AgentJournalItemIdentity { + return { provider: 'legacy', agent: 'grok', sessionId: SESSION, recordId } +} + +function tool(name: string, state: AgentJournalToolCallItem['state']): AgentJournalToolCallItem { + return { kind: 'tool-call', name, input: { name }, state } +} + +function itemStep( + resolve: Extract['resolve'] +): StructuredAgentSessionTransitionStep { + return { + kind: 'item', + reservedBytes: 4096, + resolve, + options: { turnScope: AGENT_JOURNAL_THREAD_SCOPE } + } +} + +async function rig(watermarks: Partial = {}) { + const root = await mkdtemp(join(tmpdir(), 'orca-transition-')) + roots.push(root) + const journal: AgentSessionJournal = await journals.open({ + identity: { + sessionId: SESSION, + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'grok', + providerHandle: { kind: 'opaque', agent: 'grok', value: 'provider-session-1' } + }, + stateDirectory: root, + now: () => 1_000 + }) + const publishes: number[] = [] + const deferred = createDeferredStructuredAgentSessionEventSink({ + ...testEventSinkLogging(SESSION), + watermarks + }) + const bind = () => deferred.bind({ journal, fence: 1, publish: () => publishes.push(1) }) + return { journal, deferred, sink: deferred.sink, publishes, bind } +} + +describe('structured agent-session transitions', () => { + it('lands its steps back to back, each resolved after the one before it', async () => { + const { journal, deferred, sink, publishes, bind } = await rig() + bind() + const first = identity('first') + const transition: StructuredAgentSessionTransition = { + lifecycle: false, + publish: true, + steps: [ + itemStep(() => ({ identity: first, body: tool('read', 'running') })), + // Reads the row the step before it wrote. + itemStep((view) => { + const before = view.itemBody(agentJournalItemKey(first)) + return before?.kind === 'tool-call' + ? { identity: identity('second'), body: tool(`after-${before.name}`, 'running') } + : null + }) + ] + } + expect(sink.tryAppendTransition?.(transition)).toEqual({ accepted: true }) + sink.tryAppendItem?.(identity('later'), tool('later', 'running'), { + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + await deferred.drained() + + const items = journal.snapshot().items + const start = items[0]?.sequence ?? 0 + expect(items.map((item) => [item.itemId, item.sequence - start])).toEqual([ + [agentJournalItemKey(first), 0], + [agentJournalItemKey(identity('second')), 1], + [agentJournalItemKey(identity('later')), 2] + ]) + expect(items[1]?.body).toMatchObject({ name: 'after-read' }) + expect(publishes).toHaveLength(1) + }) + + it('places a row where its resolver says, and hears every landing', async () => { + const { journal, deferred, sink, bind } = await rig() + bind() + const landed: (readonly boolean[])[] = [] + const scope = { kind: 'turn', turnItemId: 'turn-row' } as const + sink.tryAppendTransition?.({ + lifecycle: false, + publish: true, + steps: [ + itemStep(() => ({ + identity: identity('placed'), + body: tool('read', 'running'), + options: { turnScope: scope, providerItemRef: 'item:read' } + })), + itemStep(() => null) + ], + landed: (wrote) => landed.push(wrote) + }) + await deferred.drained() + + expect(journal.item(agentJournalItemKey(identity('placed')))).toMatchObject({ + turnScope: scope, + providerItemRef: 'item:read' + }) + expect(landed).toEqual([[true, false]]) + }) + + it('refuses a transition whole, so none of its steps ever lands', async () => { + const { journal, deferred, sink, bind } = await rig({ maxQueuedOperations: 1 }) + const step = (recordId: string) => + itemStep(() => ({ identity: identity(recordId), body: tool(recordId, 'running') })) + const admitted = { lifecycle: false, publish: false, steps: [step('a')] } + const refused = { lifecycle: false, publish: false, steps: [step('b'), step('c')] } + + expect(sink.tryAppendTransition?.(admitted)).toEqual({ accepted: true }) + expect(sink.tryAppendTransition?.(refused)).toEqual({ + accepted: false, + reason: 'backpressure' + }) + bind() + await deferred.drained() + + expect(journal.snapshot().items.map((item) => item.itemId)).toEqual([ + agentJournalItemKey(identity('a')) + ]) + }) + + it('settles from the rows as they stand, in consecutive rows when one cannot hold them', async () => { + const { journal, deferred, sink, publishes, bind } = await rig() + bind() + const running = Array.from({ length: 250 }, (_, index) => identity(`tool-${index}`)) + for (const id of running) { + sink.tryAppendItem?.(id, tool('read', 'running'), { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) + } + sink.tryAppendItem?.(identity('done'), tool('read', 'completed'), { + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + let wrote: readonly boolean[] = [] + expect( + sink.tryAppendTransition?.({ + lifecycle: true, + publish: true, + landed: (result) => { + wrote = result + }, + steps: [ + { + kind: 'settlement', + settlementId: 'settle-all', + reservedBytes: 1, + // The settled row is a candidate too; the fold, not the caller, rules it out. + resolve: (view) => + [...running, identity('done')].flatMap((id) => { + const body = view.itemBody(agentJournalItemKey(id)) + return body?.kind === 'tool-call' && body.state === 'running' + ? [ + { + kind: 'item' as const, + identity: id, + body: { ...body, state: 'failed' as const }, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + } + ] + : [] + }) + } + ] + }) + ).toEqual({ accepted: true }) + sink.tryAppendItem?.(identity('after'), tool('after', 'running'), { + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + await deferred.drained() + + const items = journal.snapshot().items + const sequenceOf = (recordId: string) => + items.find((item) => item.itemId === agentJournalItemKey(identity(recordId)))?.sequence ?? 0 + expect( + items.filter((item) => item.body.kind === 'tool-call' && item.body.state === 'failed') + ).toHaveLength(250) + expect( + items.find((item) => item.itemId === agentJournalItemKey(identity('done')))?.body + ).toMatchObject({ state: 'completed' }) + // Two batch rows, back to back, between the last append before it and the first after it. + expect(sequenceOf('after') - sequenceOf('done')).toBe(3) + expect(wrote).toEqual([true]) + expect(publishes).toHaveLength(1) + }) + + it('writes and announces nothing when a step resolves to nothing', async () => { + const { journal, deferred, sink, publishes, bind } = await rig() + bind() + let wrote: readonly boolean[] = [] + sink.tryAppendTransition?.({ + lifecycle: true, + publish: true, + landed: (result) => { + wrote = result + }, + steps: [ + itemStep(() => null), + { kind: 'settlement', settlementId: 'none', reservedBytes: 1, resolve: () => [] } + ] + }) + await deferred.drained() + + expect(journal.snapshot().items).toEqual([]) + expect(wrote).toEqual([false, false]) + expect(publishes).toEqual([]) + }) +}) + +describe('coalescer text a caller writes itself', () => { + it('reports unwritten streams and owes nothing once the caller marks them written', () => { + const emitted: string[] = [] + const coalescer = createAgentSessionDeltaCoalescer({ + emit: (_key, text) => { + emitted.push(text) + }, + schedule: () => () => {} + }) + coalescer.append('a', 'Hel') + coalescer.append('b', 'Wor') + coalescer.append('a', 'lo') + + expect(coalescer.dirty().map(({ key, snapshot }) => [key, snapshot.text])).toEqual([ + ['a', 'Hello'], + ['b', 'Wor'] + ]) + coalescer.markFlushed('a') + coalescer.flushAll() + expect(emitted).toEqual(['Wor']) + expect(coalescer.dirty()).toEqual([]) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-transition.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-transition.ts new file mode 100644 index 000000000000..4304338a148f --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-transition.ts @@ -0,0 +1,156 @@ +// One provider event's journal writes, admitted as a single sink operation. +// +// A producer that keeps state about what it wrote must change that state only for writes the sink +// took; when an event needs several rows, a refusal of the third after the first two were taken +// would leave the producer and the journal disagreeing. A transition is admitted whole or not at +// all. At execution its steps are issued in the same tick, so they sit together in the journal's +// write queue, and each step resolves against the fold with every earlier write landed — the +// steps before it included — so what a step writes is decided by the journal, not by memory. + +import type { + AgentJournalItemBody, + AgentJournalItemIdentity +} from '../../../shared/agent-session-journal-types' +import type { JournalLifecycleMutationInput } from '../agent-session-journal/journal-row-builders' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { estimateStructuredAgentSessionItemBytes } from './structured-agent-session-event-sink-estimate' +import type { + StructuredAgentSessionItemAppendOptions, + StructuredAgentSessionSinkAdmission +} from './structured-agent-session-event-sink' +import type { StructuredAgentSessionSinkQueue } from './structured-agent-session-event-sink-queue' +import { structuredAgentSessionJournalAppendOptions } from './structured-agent-session-journal-append-options' + +/** What a transition step reads: rows by key or provider reference, every row, and their turns. */ +export type StructuredAgentSessionTransitionJournal = Pick< + AgentSessionJournal, + | 'epoch' + | 'visitItems' + | 'itemBody' + | 'item' + | 'itemIdForProviderItemRef' + | 'canonicalItemId' + | 'visitItemsWithLinkage' +> + +export type StructuredAgentSessionTransitionStep = + | { + kind: 'item' + /** Bounds what `resolve` may write; a larger write fails the sink, unless `paced`. */ + reservedBytes: number + /** The size only paces the queue: the row grows by text the fold holds (a resumed message). */ + paced?: true + /** The row and its whole body; null writes nothing. Resolved `options` replace the planned + * ones, for a writer that learns the row's turn or producer only from the fold. */ + resolve: (journal: StructuredAgentSessionTransitionJournal) => { + identity: AgentJournalItemIdentity + body: AgentJournalItemBody + options?: StructuredAgentSessionItemAppendOptions + } | null + options: StructuredAgentSessionItemAppendOptions + } + | { + kind: 'settlement' + /** Unique per settlement: the journal applies one id once. */ + settlementId: string + /** Paces the queue only; the mutations are the journal's to choose. */ + reservedBytes: number + /** Read at execution; none writes nothing. */ + resolve: ( + journal: StructuredAgentSessionTransitionJournal + ) => readonly JournalLifecycleMutationInput[] + } + +export type StructuredAgentSessionTransition = { + steps: readonly StructuredAgentSessionTransitionStep[] + /** Rides the sink's lifecycle budget: it ends or settles something. */ + lifecycle: boolean + /** Announce the writes once they land, when any step wrote. */ + publish: boolean + /** Which steps wrote a row, once every step has landed or one failed (then none counts). */ + landed?: (wrote: readonly boolean[]) => void +} + +const STEP_OVERFLOW = 'structured agent-session transition step exceeded its reserved size' + +/** The sink members a transition writer uses. */ +export type StructuredAgentSessionTransitionSink = { + /** Queues one event's writes as a single admitted operation. */ + tryAppendTransition?( + transition: StructuredAgentSessionTransition + ): StructuredAgentSessionSinkAdmission + /** The bound journal's rows as they stand now; null until bound. */ + journalItems?(): StructuredAgentSessionTransitionJournal | null +} + +export function createStructuredAgentSessionTransitionMembers( + queue: StructuredAgentSessionSinkQueue +): Required { + return { tryAppendTransition: transitionAppend(queue), journalItems: queue.journalItems } +} + +function transitionAppend( + queue: StructuredAgentSessionSinkQueue +): (transition: StructuredAgentSessionTransition) => StructuredAgentSessionSinkAdmission { + return (transition) => + queue.submit({ + bytes: + transition.steps.reduce((total, step) => total + step.reservedBytes, 0) + + (transition.publish ? 1 : 0), + lifecycle: transition.lifecycle, + run: async (bound) => { + const { journal, fence } = bound + // Issued in one tick, so no write submitted after this transition lands between its steps. + const writes = transition.steps.map((step) => + step.kind === 'item' + ? journal + .appendResolvedItem( + () => { + const resolved = step.resolve(journal) + if (!resolved) { + return null + } + if ( + !step.paced && + estimateStructuredAgentSessionItemBytes(resolved.identity, resolved.body) > + step.reservedBytes + ) { + throw new Error(STEP_OVERFLOW) + } + return { + identity: resolved.identity, + body: resolved.body, + ...(resolved.options + ? { + options: structuredAgentSessionJournalAppendOptions( + fence, + resolved.options + ) + } + : {}) + } + }, + structuredAgentSessionJournalAppendOptions(fence, step.options) + ) + .then((landed) => landed !== null) + : journal + .appendResolvedLifecycleBatch({ + settlementId: step.settlementId, + resolve: () => step.resolve(journal), + fence + }) + .then((landed) => landed !== null) + ) + let wrote: boolean[] = transition.steps.map(() => false) + try { + wrote = await Promise.all(writes) + } finally { + // Heard even when a write failed, so a writer's speculation about it always ends. + transition.landed?.(wrote) + } + if (transition.publish && wrote.some(Boolean)) { + bound.publish() + } + } + }) +} diff --git a/src/shared/agent-session-journal-types.ts b/src/shared/agent-session-journal-types.ts index e5f8125a5c0f..271053b1b214 100644 --- a/src/shared/agent-session-journal-types.ts +++ b/src/shared/agent-session-journal-types.ts @@ -360,8 +360,16 @@ export const AGENT_JOURNAL_THREAD_SCOPE: AgentJournalTurnScope = { kind: 'thread /** Who produced a row and which turn it belongs to: what every item write states. */ export type AgentJournalRowAttribution = AgentJournalProducerLinkage & { turnScope: AgentJournalTurnScope + providerItemRef?: AgentJournalProviderItemRef } +/** The provider's own reference for the item a row is, as its producer joined it, on a row whose + * identity does not spell it (a Codex message keys by its turn ordinal, and Codex renumbers its + * item ids on resume). Set by the write that creates the row and kept by every revision, like the + * turn scope. Only its producer reads it back, to find the row again after a restart; no client + * depends on it. */ +export type AgentJournalProviderItemRef = string + /** Where the journal placed an item: the sequence of the row that created it, * then its place among that row's writes. The timeline's only ordering key. */ export type AgentJournalPosition = { @@ -387,6 +395,7 @@ export type AgentJournalRenderItem = AgentJournalProducerLinkage & { recoveredAt?: number /** Absent only from a host that predates it. */ turnScope?: AgentJournalTurnScope + providerItemRef?: AgentJournalProviderItemRef } // ─── Submissions ──────────────────────────────────────────────────────────── diff --git a/tests/e2e/cross-version-wire/agent-session-provider-item-ref-downgrade.unit.test.ts b/tests/e2e/cross-version-wire/agent-session-provider-item-ref-downgrade.unit.test.ts new file mode 100644 index 000000000000..ff0ecc5fed33 --- /dev/null +++ b/tests/e2e/cross-version-wire/agent-session-provider-item-ref-downgrade.unit.test.ts @@ -0,0 +1,127 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { expect, test } from 'vitest' +import type { AgentJournalItemIdentity } from '../../../src/shared/agent-session-journal-types' +import { createTrackedJournalOpener } from '../../../src/main/native-chat/agent-session-journal/journal-host-database-test-support' +import type { JournalRow } from '../../../src/main/native-chat/agent-session-journal/journal-row-schema' +import { importReleaseCheckoutModule, materializeReleaseCheckout } from './release-checkout' + +// A release that predates `providerItemRef` on journal rows and render items. +const BASELINE_REF = 'v1.4.218' + +const SESSION = 'session-provider-item-ref' + +function message(ordinal: number): AgentJournalItemIdentity { + return { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal } +} + +function releaseExport(module: Record, name: string): T { + const value = module[name] + if (value === undefined) { + throw new Error(`the pinned release exports no ${name}`) + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: an export the pinned release has; each caller names the shape it uses, and a changed one fails the test. + return value as T +} + +type OldRenderItem = { itemId: string; [field: string]: unknown } + +// Loads a real old build, including cold extraction and transforms. +test('an older host and client render a row carrying a provider item reference as if it had none', async () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-provider-item-ref-downgrade-')) + const journals = createTrackedJournalOpener() + try { + // This build: the same message twice, the first carrying the reference. + const journal = await journals.open({ + identity: { + sessionId: SESSION, + workspaceId: 'ws-1', + hostId: 'host-1', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: 'thread-1' } + }, + stateDirectory: directory, + now: () => 1_000 + }) + const body = { + kind: 'message' as const, + role: 'assistant' as const, + blocks: [{ type: 'text' as const, text: 'Hello' }] + } + const scope = { kind: 'thread' as const } + await journal.appendItem(message(0), body, { + fence: 1, + turnScope: scope, + providerItemRef: 'item:p:thread-1/item-1' + }) + await journal.appendItem(message(1), body, { fence: 1, turnScope: scope }) + const since = journal.readSince({ epoch: journal.epoch, sequence: 0 }) + if (!since.ok) { + throw new Error(`expected rows, got reset ${since.reset}`) + } + const rows: JournalRow[] = since.rows + const current = journal.snapshot().items + expect(current[0]?.providerItemRef).toBe('item:p:thread-1/item-1') + + const checkout = await materializeReleaseCheckout(BASELINE_REF) + const [reducer, schemas, projection] = await Promise.all( + [ + 'src/main/native-chat/agent-session-journal/journal-reducer.ts', + 'src/shared/agent-session-journal-schemas.ts', + 'src/shared/structured-agent-session-projection.ts' + ].map((path) => importReleaseCheckoutModule(checkout, path)) + ) + const createState = releaseExport<(sessionId: string, epoch: string) => unknown>( + reducer, + 'createJournalReducerState' + ) + const applyRow = releaseExport<(state: unknown, row: JournalRow) => void>( + reducer, + 'applyJournalRow' + ) + const render = releaseExport<(state: unknown) => { items: OldRenderItem[] }>( + reducer, + 'renderJournalState' + ) + const renderItemSchema = releaseExport<{ parse: (value: unknown) => OldRenderItem }>( + schemas, + 'AgentJournalRenderItemSchema' + ) + const project = releaseExport<(item: unknown) => unknown>( + projection, + 'projectStructuredItemToNativeChat' + ) + + // The older host, after a downgrade, folds the rows as it always did. + const state = createState(SESSION, journal.epoch) + for (const row of rows) { + applyRow(state, row) + } + const folded = render(state).items + expect(folded.map((item) => item.itemId)).toEqual([ + 'codex:thread-1:turn-1:0', + 'codex:thread-1:turn-1:1' + ]) + const [first, second] = folded + if (!first || !second) { + throw new Error('the older host folded both rows') + } + expect(first).not.toHaveProperty('providerItemRef') + const same = ({ itemId: _id, sequence: _seq, ...rest }: OldRenderItem) => rest + expect(same(first)).toEqual(same(second)) + + // The older client reads this host's render item without the field, and renders it the same. + const [withRef, withoutRef] = current.map((item) => renderItemSchema.parse(item)) + if (!withRef || !withoutRef) { + throw new Error('the older client read both items') + } + expect(withRef).not.toHaveProperty('providerItemRef') + expect(project(withRef)).toEqual( + project({ ...withoutRef, itemId: withRef.itemId, sequence: withRef.sequence }) + ) + } finally { + await journals.closeAll() + rmSync(directory, { recursive: true, force: true }) + } +}) From 7ad901be3caa50af68b5c3558142d7cb45abf2fc Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 22:51:51 -0700 Subject: [PATCH 10/62] Recover message ordinals from the journal's highest place, and forget joins read from a replaced epoch A fresh join index continued a turn's messages at the first free place, so a journal holding only a later ordinal (an imported or removed earlier row) had its sequence back-filled. The place is now one past the highest ordinal any row or echoed send holds there, read through a pure scheme reader. The join caches also drop what they read when the journal's epoch is replaced. --- .../codex/codex-provider-timeline-identity.ts | 4 + .../provider-timeline-identity.ts | 5 + .../provider-timeline-joins.test.ts | 55 ++++++++ .../provider-timeline-joins.ts | 86 +++++------- .../provider-timeline-message-places.ts | 122 ++++++++++++++++++ .../structured-agent-session-transition.ts | 4 +- 6 files changed, 220 insertions(+), 56 deletions(-) create mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-message-places.ts diff --git a/src/main/codex/codex-provider-timeline-identity.ts b/src/main/codex/codex-provider-timeline-identity.ts index 029c13fe56e2..7da2bbd33aae 100644 --- a/src/main/codex/codex-provider-timeline-identity.ts +++ b/src/main/codex/codex-provider-timeline-identity.ts @@ -20,6 +20,10 @@ export function createCodexProviderTimelineIdentityScheme(input: { codexTurnLifecycleIdentity(input.sessionId, address.key.value) return { ordinalMessages: true, + messageSlot: (identity) => + identity.provider === 'codex' + ? { place: `${identity.threadId}\u001f${identity.turnId}`, ordinal: identity.ordinal } + : null, turn, turnId: (address) => address.key.value, turnOpener: (address) => { diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-identity.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-identity.ts index d364b4d31acf..77286d029055 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-identity.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-identity.ts @@ -52,11 +52,16 @@ export type ProviderTimelineItemAddress = { incarnation: number } +export type ProviderTimelineMessageSlot = { place: string; ordinal: number } + export type ProviderTimelineIdentityScheme = { /** Messages inside a turn are keyed by their place among the turn's messages, not by their key. * Such an identity must spell only (thread, turn, ordinal), and its row carries the key as * `providerItemRef`, since nothing else could find it again. */ ordinalMessages: boolean + /** Reads an ordinal message identity back: its (thread, turn) place, spelled by the scheme, and + * its ordinal; null for any other identity. Required with `ordinalMessages`. */ + messageSlot?(identity: AgentJournalItemIdentity): ProviderTimelineMessageSlot | null turn(address: ProviderTimelineTurnAddress): AgentJournalItemIdentity /** The id the turn row carries and a client's Stop names. */ turnId(address: ProviderTimelineTurnAddress): string diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-joins.test.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-joins.test.ts index b132802369f0..8cddc98a2278 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-joins.test.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-joins.test.ts @@ -62,6 +62,13 @@ const inTurn = (turn: string) => ({ scope: { kind: 'turn', turnItemId: `turn:${turn}` } as const }) +const codexSlot = (turn: string, ordinal: number): AgentJournalItemIdentity => ({ + provider: 'codex', + threadId: 'root', + turnId: turn, + ordinal +}) + async function write( journal: AgentSessionJournal, row: { identity: AgentJournalItemIdentity; ref?: string }, @@ -118,6 +125,54 @@ describe('provider timeline joins', () => { ) }) + it('continues past the highest ordinal the journal holds, never into a gap below it', async () => { + const journal = await openJournal() + await write(journal, { identity: codexSlot('t1', 2), ref: 'item:old' }, 'old') + // Another turn and another thread keep their own places. + await write(journal, { identity: codexSlot('t2', 7) }, 'other turn') + await write(journal, { identity: { ...codexSlot('t1', 9), threadId: 'sub' } }, 'subagent') + const joins = codexJoins() + expect(joins.place(message('new'), 'message', inTurn('t1'), journal).itemId).toBe( + 'codex:root:t1:3' + ) + expect(joins.place(message('next'), 'message', inTurn('t1'), journal).itemId).toBe( + 'codex:root:t1:4' + ) + }) + + it('continues past an echoed send above the highest row', async () => { + const journal = await openJournal() + await write(journal, { identity: codexSlot('t1', 0) }, 'first') + await journal.appendSubmission({ + clientMessageId: 'send-1', + payloadFingerprint: 'fp', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hi' }] }, + fence: 1 + }) + await journal.resolveDispatch({ + clientMessageId: 'send-1', + state: 'accepted', + providerIdentity: codexSlot('t1', 4), + fence: 1 + }) + expect(codexJoins().place(message('reply'), 'message', inTurn('t1'), journal).itemId).toBe( + 'codex:root:t1:5' + ) + }) + + it('forgets what it read from an epoch the journal replaced', async () => { + const journal = await openJournal() + const joins = codexJoins() + const m0 = joins.place(message('m0'), 'message', inTurn('t1'), journal) + await write(journal, m0, 'first') + expect(joins.find(message('m0'), journal)?.itemId).toBe('codex:root:t1:0') + await journal.replaceEpochItems('handle_forked', 1, []) + expect(joins.find(message('m0'), journal)).toBeNull() + expect(joins.place(message('m1'), 'message', inTurn('t1'), journal).itemId).toBe( + 'codex:root:t1:0' + ) + }) + it('finds a row whose identity spells its key without any reference', async () => { const journal = await openJournal() const scheme = createLegacyProviderTimelineIdentityScheme({ diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-joins.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-joins.ts index 1b85f32a0aa1..7cb48073933d 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-joins.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-joins.ts @@ -22,7 +22,6 @@ import { import { BoundedMap } from '../../../shared/bounded-map' import type { StructuredAgentSessionTransitionJournal } from '../agent-session-wire/structured-agent-session-transition' import { - providerTimelineKeyPart, spellProviderTimelineKey, type ProviderTimelineIdentityScheme, type ProviderTimelineItemAddress, @@ -31,12 +30,10 @@ import { type ProviderTimelineKey, type ProviderTimelineTurnAddress } from './provider-timeline-identity' +import { ProviderTimelineMessagePlaces } from './provider-timeline-message-places' const MAX_JOINED_ROWS = 1_024 const MAX_JOINED_ROW_BYTES = 1024 * 1024 -const MAX_TURN_ORDINALS = 256 -/** Echoed sends whose slot no row holds yet; a lane records each on its send soon after. */ -const MAX_RESERVED_ECHOES = 64 /** One provider item as the provider names it. Item keys are the provider's per thread. */ export type ProviderTimelineItemJoin = { @@ -81,12 +78,9 @@ export class ProviderTimelineJoins { maxBytes: MAX_JOINED_ROW_BYTES, sizeOf: (row, key) => bytes(key) + 2 * bytes(row.itemId) + 64 }) - private readonly ordinals = new BoundedMap({ - maxEntries: MAX_TURN_ORDINALS, - maxBytes: MAX_TURN_ORDINALS * 1024, - sizeOf: (_next, place) => bytes(place) + 8 - }) - private readonly echoes = new BoundedMap({ maxEntries: MAX_RESERVED_ECHOES }) + private readonly places: ProviderTimelineMessagePlaces + /** The journal epoch every cache entry was read from; a replaced epoch holds other rows. */ + private epoch: string | null = null private serial = 0 constructor( @@ -95,7 +89,12 @@ export class ProviderTimelineJoins { generation: string namespace: string } - ) {} + ) { + this.places = new ProviderTimelineMessagePlaces({ + scheme: deps.scheme, + namespace: () => this.deps.namespace + }) + } get namespace(): string { return this.deps.namespace @@ -105,7 +104,19 @@ export class ProviderTimelineJoins { reset(namespace: string): void { this.deps.namespace = namespace this.rows.clear() - this.ordinals.clear() + this.places.clear() + } + + /** Drops every cache read from an epoch the journal has since replaced (a rewind, an import). */ + private sync(journal: Journal): void { + if (!journal || journal.epoch === this.epoch) { + return + } + if (this.epoch !== null) { + this.rows.clear() + this.places.clear() + } + this.epoch = journal.epoch } /** A key unique to this acquisition, for something the provider names nothing. */ @@ -143,6 +154,7 @@ export class ProviderTimelineJoins { journal: Journal, namespace = this.deps.namespace ): ProviderTimelineRow | null { + this.sync(journal) const ref = this.reference(join, namespace) const cached = this.rows.get(ref) // A planner ahead of a reset asks about a namespace whose rows this index does not spell yet. @@ -165,7 +177,11 @@ export class ProviderTimelineJoins { placement: ProviderTimelinePlacement, journal: Journal ): ProviderTimelineRow { - const ordinal = this.ordinalFor(itemClass, placement, journal) + this.sync(journal) + const ordinal = + this.deps.scheme.ordinalMessages && itemClass === 'message' && placement.turn + ? this.places.next(placement.thread, placement.turn, journal) + : null const identity = this.deps.scheme.item({ ...this.address(join, 1), turn: placement.turn, @@ -194,7 +210,7 @@ export class ProviderTimelineJoins { return } const row = this.place(join, 'message', placement, journal) - this.echoes.set(row.itemId, true) + this.places.reserve(row.itemId) } /** The request under `key` the journal holds last: the highest incarnation with a row. */ @@ -203,6 +219,7 @@ export class ProviderTimelineJoins { journal: Journal, namespace = this.deps.namespace ): ProviderTimelineRow | null { + this.sync(journal) const join = { family: 'request' as const, key, thread: null } const ref = this.reference(join, namespace) const cached = this.rows.get(ref) @@ -269,47 +286,6 @@ export class ProviderTimelineJoins { private spelled(join: ProviderTimelineItemJoin, incarnation: number): AgentJournalItemIdentity { return this.deps.scheme.item(this.address(join, incarnation)) } - - /** The next free place among the turn's messages: past every row and echoed send holding one. */ - private ordinalFor( - itemClass: ProviderTimelineItemClass, - placement: ProviderTimelinePlacement, - journal: Journal - ): number | null { - const turn = placement.turn - if (!this.deps.scheme.ordinalMessages || itemClass !== 'message' || !turn) { - return null - } - const place = `${providerTimelineKeyPart(placement.thread ?? '')}\u001f${spellProviderTimelineKey( - this.deps.namespace, - turn - )}` - let ordinal = this.ordinals.get(place) ?? 0 - while (this.taken(this.slot(placement.thread, turn, ordinal), journal)) { - ordinal += 1 - } - this.ordinals.set(place, ordinal + 1) - return ordinal - } - - private slot(thread: string | null, turn: ProviderTimelineKey, ordinal: number): string { - return agentJournalItemKey( - this.deps.scheme.item({ - ...this.address({ family: 'item', key: turn, thread }, 1), - turn, - itemClass: 'message', - messageOrdinal: ordinal - }) - ) - } - - private taken(itemId: string, journal: Journal): boolean { - return ( - this.echoes.has(itemId) || - (journal !== null && - (journal.itemBody(itemId) !== null || journal.canonicalItemId(itemId) !== itemId)) - ) - } } function rowOf( diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-message-places.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-message-places.ts new file mode 100644 index 000000000000..e5350c39c93a --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-message-places.ts @@ -0,0 +1,122 @@ +// Where a new message goes among its (thread, turn)'s messages, for a scheme that keys messages +// by that place. Read back from the journal on a miss, past every place a row or an echoed send +// holds, so a restart continues the sequence the journal already has. + +import { + agentJournalItemKey, + parseAgentJournalItemKey +} from '../../../shared/agent-session-journal-item-key' +import type { AgentJournalItemIdentity } from '../../../shared/agent-session-journal-types' +import { BoundedMap } from '../../../shared/bounded-map' +import type { StructuredAgentSessionTransitionJournal } from '../agent-session-wire/structured-agent-session-transition' +import { + providerTimelineKeyPart, + spellProviderTimelineKey, + type ProviderTimelineIdentityScheme, + type ProviderTimelineKey +} from './provider-timeline-identity' + +const MAX_TURN_ORDINALS = 256 +/** Echoed sends whose slot no row holds yet; a lane records each on its send soon after. */ +const MAX_RESERVED_ECHOES = 64 + +type Journal = StructuredAgentSessionTransitionJournal | null + +function bytes(value: string): number { + return Buffer.byteLength(value, 'utf8') +} + +export class ProviderTimelineMessagePlaces { + /** The next ordinal per place. */ + private readonly ordinals = new BoundedMap({ + maxEntries: MAX_TURN_ORDINALS, + maxBytes: MAX_TURN_ORDINALS * 1024, + sizeOf: (_next, place) => bytes(place) + 8 + }) + private readonly echoes = new BoundedMap({ + maxEntries: MAX_RESERVED_ECHOES, + maxBytes: MAX_RESERVED_ECHOES * 1024, + sizeOf: (_held, itemId) => bytes(itemId) + 8 + }) + + constructor( + private readonly deps: { + scheme: ProviderTimelineIdentityScheme + namespace: () => string + } + ) {} + + clear(): void { + this.ordinals.clear() + this.echoes.clear() + } + + /** Holds a place no row takes (an echoed send's). */ + reserve(itemId: string): void { + this.echoes.set(itemId, true) + } + + /** The next place in (thread, turn): allocating, so resolvers only. */ + next(thread: string | null, turn: ProviderTimelineKey, journal: Journal): number { + const place = `${providerTimelineKeyPart(thread ?? '')}\u001f${spellProviderTimelineKey( + this.deps.namespace(), + turn + )}` + let ordinal = this.ordinals.get(place) ?? this.highWater(thread, turn, journal) + // Another writer of the same turn may have taken places since this one cached its mark. + while (this.taken(agentJournalItemKey(this.slot(thread, turn, ordinal)), journal)) { + ordinal += 1 + } + this.ordinals.set(place, ordinal + 1) + return ordinal + } + + /** One past the highest ordinal the journal holds in the place, like the ordinal counter it + * continues: a gap below it (a removed or never-imported row) is never filled. */ + private highWater(thread: string | null, turn: ProviderTimelineKey, journal: Journal): number { + const read = this.deps.scheme.messageSlot + const place = read?.(this.slot(thread, turn, 0))?.place + if (!read || place === undefined || !journal) { + return 0 + } + let next = 0 + const consider = (itemId: string | null) => { + const identity = itemId === null ? null : parseAgentJournalItemKey(itemId) + const slot = identity ? read(identity) : null + if (slot?.place === place) { + next = Math.max(next, slot.ordinal + 1) + } + } + journal.visitItems((itemId) => consider(itemId)) + // An echoed send holds its place through its submission alias, with no row of its own. + for (const submission of journal.submissions()) { + consider(submission.providerItemId) + } + return next + } + + private slot( + thread: string | null, + turn: ProviderTimelineKey, + ordinal: number + ): AgentJournalItemIdentity { + return this.deps.scheme.item({ + namespace: this.deps.namespace(), + family: 'item', + key: turn, + thread, + turn, + itemClass: 'message', + messageOrdinal: ordinal, + incarnation: 1 + }) + } + + private taken(itemId: string, journal: Journal): boolean { + return ( + this.echoes.has(itemId) || + (journal !== null && + (journal.itemBody(itemId) !== null || journal.canonicalItemId(itemId) !== itemId)) + ) + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-transition.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-transition.ts index 4304338a148f..c67dab93edef 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-transition.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-transition.ts @@ -21,10 +21,12 @@ import type { import type { StructuredAgentSessionSinkQueue } from './structured-agent-session-event-sink-queue' import { structuredAgentSessionJournalAppendOptions } from './structured-agent-session-journal-append-options' -/** What a transition step reads: rows by key or provider reference, every row, and their turns. */ +/** What a transition step reads: rows by key or provider reference, every row, their turns, and + * the sends whose echo holds a provider item's place. */ export type StructuredAgentSessionTransitionJournal = Pick< AgentSessionJournal, | 'epoch' + | 'submissions' | 'visitItems' | 'itemBody' | 'item' From 620b8d7deb28d287d327de731468e2446c0d60c4 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 23:19:08 -0700 Subject: [PATCH 11/62] Spell the subagent thread's message slot without spreading an identity union --- .../agent-session-timeline/provider-timeline-joins.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-joins.test.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-joins.test.ts index 8cddc98a2278..6a3f1e8d9d3f 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-joins.test.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-joins.test.ts @@ -62,9 +62,9 @@ const inTurn = (turn: string) => ({ scope: { kind: 'turn', turnItemId: `turn:${turn}` } as const }) -const codexSlot = (turn: string, ordinal: number): AgentJournalItemIdentity => ({ +const codexSlot = (turn: string, ordinal: number, threadId = 'root'): AgentJournalItemIdentity => ({ provider: 'codex', - threadId: 'root', + threadId, turnId: turn, ordinal }) @@ -130,7 +130,7 @@ describe('provider timeline joins', () => { await write(journal, { identity: codexSlot('t1', 2), ref: 'item:old' }, 'old') // Another turn and another thread keep their own places. await write(journal, { identity: codexSlot('t2', 7) }, 'other turn') - await write(journal, { identity: { ...codexSlot('t1', 9), threadId: 'sub' } }, 'subagent') + await write(journal, { identity: codexSlot('t1', 9, 'sub') }, 'subagent') const joins = codexJoins() expect(joins.place(message('new'), 'message', inTurn('t1'), journal).itemId).toBe( 'codex:root:t1:3' From e892640d72c612fec4866c73813a39f85005b980 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 21:52:59 -0700 Subject: [PATCH 12/62] Add a provider timeline grammar and a shared assembler that decides at its turn in the journal Adapters translate their provider's dialect into a small grammar (turns, items, streamed text, requests, context facts, session end/reset); one shared assembler turns it into the journal rows every structured lane writes. Each event is planned as one sink transition. Which row a write lands on, whether a replay writes anything, and every change to what the assembler knows (its ledger) are decided by the transition's resolvers at the event's turn in the journal's write queue, against the fold as it stands then. A forecast (the ledger plus admitted events still queued) only answers apply() at once. So a refused event allocates nothing, a write the journal rejects leaves no trace in memory, and a restart or evicted cache finds the same rows again. Text and full snapshots of one provider item share one row and one lifecycle; reset always flushes text and settles the old session from the journal; the open-work budget is derived from what is actually open. Codex migration contracts compare against the existing Codex translator, including a restart mid-stream and a repeat that outlives the join cache. --- .../codex-provider-timeline-identity.test.ts | 409 ++++++++++++++++++ .../provider-timeline-apply-host.ts | 35 ++ .../provider-timeline-assembler-items.test.ts | 255 +++++++++++ .../provider-timeline-assembler-order.test.ts | 254 +++++++++++ ...ovider-timeline-assembler-recovery.test.ts | 402 +++++++++++++++++ ...rovider-timeline-assembler-refusal.test.ts | 208 +++++++++ ...provider-timeline-assembler-replay.test.ts | 295 +++++++++++++ ...rovider-timeline-assembler-session.test.ts | 196 +++++++++ ...rovider-timeline-assembler-test-support.ts | 201 +++++++++ .../provider-timeline-assembler-turns.test.ts | 183 ++++++++ .../provider-timeline-assembler.ts | 250 +++++++++++ .../provider-timeline-budget.ts | 66 +++ .../provider-timeline-context.ts | 67 +++ .../provider-timeline-decision.ts | 102 +++++ .../provider-timeline-decisions.ts | 76 ++++ .../provider-timeline-event.ts | 128 ++++++ .../provider-timeline-item-decisions.ts | 196 +++++++++ .../provider-timeline-plan.ts | 144 ++++++ .../provider-timeline-settlement.ts | 147 +++++++ .../provider-timeline-state.ts | 172 ++++++++ .../provider-timeline-text-events.ts | 105 +++++ .../provider-timeline-text-streams.ts | 360 +++++++++++++++ .../provider-timeline-transition-layout.ts | 120 +++++ .../provider-timeline-turn-decisions.ts | 235 ++++++++++ 24 files changed, 4606 insertions(+) create mode 100644 src/main/codex/codex-provider-timeline-identity.test.ts create mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-apply-host.ts create mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-assembler-items.test.ts create mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-assembler-order.test.ts create mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-assembler-recovery.test.ts create mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-assembler-refusal.test.ts create mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-assembler-replay.test.ts create mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-assembler-session.test.ts create mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-assembler-test-support.ts create mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-assembler-turns.test.ts create mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-assembler.ts create mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-budget.ts create mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-context.ts create mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-decision.ts create mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-decisions.ts create mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-event.ts create mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-item-decisions.ts create mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-plan.ts create mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-settlement.ts create mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-state.ts create mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-text-events.ts create mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-text-streams.ts create mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-transition-layout.ts create mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-turn-decisions.ts diff --git a/src/main/codex/codex-provider-timeline-identity.test.ts b/src/main/codex/codex-provider-timeline-identity.test.ts new file mode 100644 index 000000000000..84fc68f15bf3 --- /dev/null +++ b/src/main/codex/codex-provider-timeline-identity.test.ts @@ -0,0 +1,409 @@ +// The migration contract for moving Codex onto the shared timeline assembler: the assembler with +// the Codex identity scheme lands the same rows Codex's own translator lands today — the same keys +// (live messages, history restored on resume, a late item naming its own turn, interleaved +// subagent threads) and the same row contents. + +import { afterEach, describe, expect, it } from 'vitest' +import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import type { AgentJournalRenderItem } from '../../shared/agent-session-journal-types' +import type { ProviderTimelineEvent } from '../native-chat/agent-session-timeline/provider-timeline-event' +import { + closeProviderTimelineRigs, + openProviderTimelineRig, + SESSION +} from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import { ProviderTurnMessageOrdinals } from '../native-chat/agent-session-timeline/provider-turn-message-ordinals' +import { createCodexProviderTimelineIdentityScheme } from './codex-provider-timeline-identity' +import { + codexItemIdentity, + codexJournalItem, + type CodexThreadItem +} from './codex-structured-item-translation' +import { createCodexJournalTranslator } from './codex-structured-journal-translation' +import type { CodexStructuredSessionEvent } from './codex-structured-session-adapter' + +afterEach(closeProviderTimelineRigs) + +const THREAD = 'thread-abc' +const CHILD = 'thread-child' + +async function codexRig() { + return openProviderTimelineRig({ + scheme: createCodexProviderTimelineIdentityScheme({ + sessionId: SESSION, + primaryThreadId: () => THREAD + }), + ownThread: () => THREAD + }) +} + +function body(item: CodexThreadItem, started?: CodexThreadItem) { + const translated = codexJournalItem(item, undefined, started).body + if ( + !translated || + translated.kind === 'turn' || + translated.kind === 'approval' || + translated.kind === 'question' + ) { + throw new Error(`no item body for ${item.type}`) + } + return translated +} + +const keys = (rows: AgentJournalRenderItem[]) => + rows.filter((row) => row.body.kind !== 'turn').map((row) => row.itemId) + +/** What a client renders from a row; sequence, revision and receipt time are the journal's own. */ +const content = (rows: AgentJournalRenderItem[]) => + JSON.stringify( + rows.map( + ({ + itemId, + body, + turnScope, + agentId, + parentAgentId, + providerParentRef, + producerKind, + attempt + }) => ({ + itemId, + body, + turnScope, + agentId, + parentAgentId, + providerParentRef, + producerKind, + attempt + }) + ) + ) + +function liveTranslator(rig: Awaited>) { + return createCodexJournalTranslator({ + sink: rig.eventSink, + sessionId: SESSION, + primaryThreadId: () => THREAD, + now: () => 1_000, + schedule: (run) => { + run() + return () => {} + } + }) +} + +function notify(method: string, params: unknown): CodexStructuredSessionEvent { + return { type: 'notification', sessionId: SESSION, threadId: THREAD, method, params } +} + +describe('Codex identities through the shared assembler', () => { + it('keys messages by turn and message ordinal, and a message outside any turn by its item id', async () => { + const rig = await codexRig() + const message = ( + id: string, + text: string, + join?: { turn?: string } + ): ProviderTimelineEvent => ({ + type: 'item.close', + item: id, + body: body({ type: 'agentMessage', id, text }), + join: { thread: THREAD, ...join } + }) + rig.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_000 }) + rig.assembler.apply(message('item-0', 'one')) + rig.assembler.apply({ type: 'turn.end', turn: 'turn-1', at: 2_000, state: 'completed' }) + rig.assembler.apply(message('item-1', 'orphan')) + rig.assembler.apply({ type: 'turn.open', turn: 'turn-2', at: 3_000 }) + rig.assembler.apply(message('item-2', 'two')) + // A message naming another turn joins that turn, whatever is open. + rig.assembler.apply(message('item-3', 'late', { turn: 'turn-9' })) + + // The keys Codex's own translator writes for the same frames. + expect(keys(await rig.rows())).toEqual([ + 'codex:thread-abc:turn-1:0', + 'orca:codex-item%3Athread-abc%3Aitem-1', + 'codex:thread-abc:turn-2:0', + 'codex:thread-abc:turn-9:0' + ]) + }) + + it('keeps interleaved subagent threads apart while their rows join the open turn', async () => { + const rig = await codexRig() + rig.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_000 }) + rig.assembler.apply({ + type: 'item.close', + item: 'item-0', + body: body({ type: 'agentMessage', id: 'item-0', text: 'root' }), + join: { thread: THREAD } + }) + rig.assembler.apply({ + type: 'item.close', + item: 'item-0', + body: body({ type: 'agentMessage', id: 'item-0', text: 'child' }), + join: { thread: CHILD, turn: 'turn-child' } + }) + rig.assembler.apply({ + type: 'item.close', + item: 'item-1', + body: body({ type: 'agentMessage', id: 'item-1', text: 'still root' }), + join: { thread: THREAD } + }) + const rows = (await rig.rows()).filter((row) => row.body.kind !== 'turn') + expect(rows.map((row) => row.itemId)).toEqual([ + 'codex:thread-abc:turn-1:0', + 'codex:thread-child:turn-child:0', + 'codex:thread-abc:turn-1:1' + ]) + expect(new Set(rows.map((row) => JSON.stringify(row.turnScope))).size).toBe(1) + }) + + it('counts an echoed send in its turn’s message ordinals, as resume history does', async () => { + const rig = await codexRig() + const ordinals = new ProviderTurnMessageOrdinals() + const oracle = (item: CodexThreadItem) => + agentJournalItemKey(codexItemIdentity({ threadId: THREAD, turnId: 'turn-1', item, ordinals })) + const echo: CodexThreadItem = { type: 'userMessage', id: 'item-0', content: [] } + const reasoning: CodexThreadItem = { type: 'reasoning', id: 'item-1', summary: ['why'] } + const answer: CodexThreadItem = { type: 'agentMessage', id: 'item-2', text: 'answer' } + const expected = [oracle(echo), oracle(reasoning), oracle(answer)] + + rig.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_000 }) + rig.assembler.apply({ + type: 'input.accepted', + clientMessageId: 'send-1', + requestedAt: 900, + join: { thread: THREAD, turn: 'turn-1', item: 'item-0' } + }) + rig.assembler.apply({ + type: 'text.delta', + item: { id: 'item-1' }, + channel: 'reasoning', + text: 'why', + join: { thread: THREAD } + }) + rig.assembler.apply({ + type: 'item.close', + item: 'item-2', + body: body(answer), + join: { thread: THREAD } + }) + rig.assembler.apply({ type: 'turn.end', turn: 'turn-1', at: 2_000, state: 'completed' }) + + expect(keys(await rig.rows())).toEqual(expected.slice(1)) + expect(expected[2]).toBe('codex:thread-abc:turn-1:1') + }) +}) + +describe('Codex rows through the shared assembler', () => { + it('lands the same rows as the Codex translator for a live turn', async () => { + const codex = await openProviderTimelineRig() + let clock = 1_000 + const translator = createCodexJournalTranslator({ + sink: codex.eventSink, + sessionId: SESSION, + primaryThreadId: () => THREAD, + now: () => clock, + schedule: (run) => { + run() + return () => {} + } + }) + const notify = (method: string, params: unknown): CodexStructuredSessionEvent => ({ + type: 'notification', + sessionId: SESSION, + threadId: THREAD, + method, + params + }) + const started: CodexThreadItem = { + type: 'commandExecution', + id: 'exec-1', + command: 'ls', + status: 'inProgress' + } + const finished: CodexThreadItem = { + ...started, + status: 'completed', + exitCode: 0, + aggregatedOutput: 'ok' + } + const reply: CodexThreadItem = { type: 'agentMessage', id: 'item-1', text: 'Hello' } + translator.handle(notify('turn/started', { turn: { id: 'turn-1' } })) + translator.handle(notify('item/started', { item: { ...reply, text: '' } })) + translator.handle(notify('item/agentMessage/delta', { itemId: 'item-1', delta: 'Hello' })) + translator.handle(notify('item/completed', { item: reply })) + translator.handle(notify('item/started', { item: started })) + translator.handle(notify('item/completed', { item: finished })) + clock = 2_000 + translator.handle( + notify('turn/completed', { turn: { id: 'turn-1', status: 'completed', durationMs: 900 } }) + ) + + const shared = await codexRig() + const join = { thread: THREAD, turn: 'turn-1' } + shared.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_000 }) + shared.assembler.apply({ + type: 'text.delta', + item: { id: 'item-1' }, + channel: 'assistant', + text: 'Hello', + join + }) + shared.assembler.apply({ type: 'item.close', item: 'item-1', body: body(reply), join }) + shared.assembler.apply({ type: 'item.open', item: 'exec-1', body: body(started), join }) + shared.assembler.apply({ + type: 'item.close', + item: 'exec-1', + body: body(finished, started), + join + }) + shared.assembler.apply({ + type: 'turn.end', + turn: 'turn-1', + at: 2_000, + state: 'completed', + outcome: 'success', + durationMs: 900 + }) + + expect(content(await shared.rows())).toBe(content(await codex.rows())) + }) + + it('lands the same rows as the Codex translator for a turn restored from history', async () => { + const turn = { + id: 'turn-1', + status: 'completed', + startedAt: 1_700_000_000, + completedAt: 1_700_000_050, + items: [ + { type: 'userMessage', id: 'user-1', content: [{ type: 'text', text: 'ask' }] }, + { type: 'reasoning', id: 'think-1', summary: ['why'] }, + { type: 'agentMessage', id: 'interim-1', text: 'looking' }, + { type: 'commandExecution', id: 'exec-1', command: 'ls', status: 'completed', exitCode: 0 }, + { type: 'agentMessage', id: 'answer-1', text: 'answer' } + ] + } + const codex = await openProviderTimelineRig() + const translator = createCodexJournalTranslator({ + sink: codex.eventSink, + sessionId: SESSION, + primaryThreadId: () => THREAD + }) + expect(translator.restoreThread(THREAD, { turns: [turn] })).toEqual({ accepted: true }) + + const shared = await codexRig() + const join = { thread: THREAD, turn: 'turn-1' } + shared.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: turn.startedAt * 1000 }) + for (const item of turn.items) { + const translated = codexJournalItem(item).body + if ( + translated && + translated.kind !== 'turn' && + translated.kind !== 'approval' && + translated.kind !== 'question' + ) { + shared.assembler.apply({ type: 'item.close', item: item.id, body: translated, join }) + } + } + shared.assembler.apply({ + type: 'turn.end', + turn: 'turn-1', + at: turn.completedAt * 1000, + state: 'completed', + outcome: 'success' + }) + + expect(content(await shared.rows())).toBe(content(await codex.rows())) + }) + + it('lands the same rows across a restart in the middle of a streamed message', async () => { + const codex = await openProviderTimelineRig() + const translator = liveTranslator(codex) + const first: CodexThreadItem = { type: 'agentMessage', id: 'item-1', text: 'First' } + const second: CodexThreadItem = { type: 'agentMessage', id: 'item-2', text: 'Second' } + translator.handle(notify('turn/started', { turn: { id: 'turn-1' } })) + translator.handle(notify('item/completed', { item: first })) + translator.handle(notify('item/started', { item: { ...second, text: '' } })) + translator.handle(notify('item/agentMessage/delta', { itemId: 'item-2', delta: 'Sec' })) + translator.handle(notify('item/agentMessage/delta', { itemId: 'item-2', delta: 'ond' })) + translator.handle(notify('item/completed', { item: second })) + + const shared = await codexRig() + const join = { thread: THREAD, turn: 'turn-1' } + shared.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_000 }) + shared.assembler.apply({ type: 'item.close', item: 'item-1', body: body(first), join }) + shared.assembler.apply({ + type: 'text.delta', + item: { id: 'item-2' }, + channel: 'assistant', + text: 'Sec', + join + }) + await shared.rows() + // The host restarts while Codex is still streaming item-2. + const restarted = shared.restart({ generation: 'gen-2' }) + restarted.apply({ + type: 'text.delta', + item: { id: 'item-2' }, + channel: 'assistant', + text: 'ond', + join + }) + restarted.apply({ type: 'item.close', item: 'item-2', body: body(second), join }) + + const rows = await shared.rows() + expect(content(rows)).toBe(content(await codex.rows())) + // Codex message rows carry the item id their ordinal identity cannot spell. + expect( + rows.filter((row) => row.body.kind === 'message').map((row) => row.providerItemRef) + ).toEqual([expect.stringContaining('item-1'), expect.stringContaining('item-2')]) + }) + + it('lands the same rows, and no second copy, when a late repeat outlives the join cache', async () => { + const codex = await openProviderTimelineRig() + const translator = liveTranslator(codex) + const shared = await codexRig() + const old: CodexThreadItem = { type: 'agentMessage', id: 'old-1', text: 'Old' } + translator.handle(notify('turn/started', { turn: { id: 'turn-1' } })) + translator.handle(notify('item/completed', { item: old })) + translator.handle(notify('turn/completed', { turn: { id: 'turn-1', status: 'completed' } })) + translator.handle(notify('turn/started', { turn: { id: 'turn-2' } })) + shared.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_000 }) + shared.assembler.apply({ + type: 'item.close', + item: 'old-1', + body: body(old), + join: { thread: THREAD } + }) + shared.assembler.apply({ + type: 'turn.end', + turn: 'turn-1', + at: 1_000, + state: 'completed', + outcome: 'success' + }) + shared.assembler.apply({ type: 'turn.open', turn: 'turn-2', at: 1_000 }) + for (let index = 0; index < 1_025; index += 1) { + const item: CodexThreadItem = { type: 'agentMessage', id: `item-${index}`, text: 'small' } + translator.handle(notify('item/completed', { item })) + shared.assembler.apply({ + type: 'item.close', + item: item.id, + body: body(item), + join: { thread: THREAD } + }) + if (index % 32 === 31) { + await Promise.all([codex.rows(), shared.rows()]) + } + } + // Long after its join left memory, Codex repeats the old message with no turn of its own. + shared.assembler.apply({ + type: 'item.close', + item: 'old-1', + body: body(old), + join: { thread: THREAD } + }) + + expect(content(await shared.rows())).toBe(content(await codex.rows())) + }) +}) diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-apply-host.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-apply-host.ts new file mode 100644 index 000000000000..12804f3150b3 --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-apply-host.ts @@ -0,0 +1,35 @@ +// What the event appliers share with the assembler that owns them. + +import type { StructuredAgentSessionSinkAdmission } from '../agent-session-wire/structured-agent-session-event-sink' +import type { StructuredAgentSessionTransitionJournal } from '../agent-session-wire/structured-agent-session-transition' +import type { ProviderTimelineHold } from './provider-timeline-budget' +import type { ProviderTimelineContext } from './provider-timeline-context' +import type { ProviderTimelineDropRule } from './provider-timeline-decisions' +import type { ProviderTimelinePlan } from './provider-timeline-plan' +import type { ProviderTimelineState } from './provider-timeline-state' +import type { ProviderTimelineTextStreams } from './provider-timeline-text-streams' + +export type ProviderTimelineApplyResult = { + /** The sink's answer for the event's writes; refused means nothing changed. */ + admission: StructuredAgentSessionSinkAdmission + dropped?: ProviderTimelineDropRule +} + +/** An admitted transition that has not landed: what it changes in the forecast until it does. */ +export type ProviderTimelineInFlight = { + executed: boolean + commit: ((state: ProviderTimelineState) => void) | undefined +} + +export type ProviderTimelineApplyHost = { + context: ProviderTimelineContext + streams: ProviderTimelineTextStreams + /** The forecast now; it is replaced as transitions land. */ + forecast(): ProviderTimelineState + journal(): StructuredAgentSessionTransitionJournal | null + admits(hold: ProviderTimelineHold): boolean + submit( + plan: ProviderTimelinePlan, + entry: ProviderTimelineInFlight + ): StructuredAgentSessionSinkAdmission +} diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-items.test.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-items.test.ts new file mode 100644 index 000000000000..0172719fef48 --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-items.test.ts @@ -0,0 +1,255 @@ +import { afterEach, describe, expect, it } from 'vitest' +import type { + AgentJournalItemBody, + AgentJournalPlainStatusItem +} from '../../../shared/agent-session-journal-types' +import { + assistantText, + closeProviderTimelineRigs, + openProviderTimelineRig, + providerItemId, + providerTurnItemId, + runningTool +} from './provider-timeline-assembler-test-support' + +afterEach(closeProviderTimelineRigs) + +const plan = (text: string): AgentJournalPlainStatusItem => ({ + kind: 'status', + text, + presentation: 'plan-document' +}) + +function messageText(body: AgentJournalItemBody | undefined): string | undefined { + return body?.kind === 'message' && body.blocks[0]?.type === 'text' + ? body.blocks[0].text + : undefined +} + +describe('provider timeline items', () => { + it('scopes interleaved tool calls and text to the turn they ran in, in arrival order', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_000 }) + rig.assembler.apply({ + type: 'text.delta', + item: { stream: 'reply' }, + channel: 'assistant', + text: 'Let me ' + }) + rig.assembler.apply({ + type: 'text.delta', + item: { stream: 'reply' }, + channel: 'assistant', + text: 'look.' + }) + rig.assembler.apply({ type: 'item.open', item: 'call-a', body: runningTool('read') }) + rig.assembler.apply({ type: 'item.open', item: 'call-b', body: runningTool('grep') }) + rig.assembler.apply({ + type: 'item.close', + item: 'call-b', + body: { ...runningTool('grep'), state: 'completed' } + }) + rig.assembler.apply({ + type: 'item.close', + item: 'call-a', + body: { ...runningTool('read'), state: 'completed' } + }) + // The tool call ended the anonymous stream, so this text is a new message. + rig.assembler.apply({ + type: 'text.delta', + item: { stream: 'reply' }, + channel: 'assistant', + text: 'Done.' + }) + rig.assembler.apply({ type: 'turn.end', at: 2_000, state: 'completed', outcome: 'success' }) + + const rows = (await rig.rows()).filter((row) => row.body.kind !== 'turn') + expect(rows.map((row) => messageText(row.body) ?? row.itemId)).toEqual([ + 'Let me look.', + providerItemId('item', 'call-a'), + providerItemId('item', 'call-b'), + 'Done.' + ]) + const turnScope = { kind: 'turn', turnItemId: providerTurnItemId('turn-1') } + expect(rows.every((row) => JSON.stringify(row.turnScope) === JSON.stringify(turnScope))).toBe( + true + ) + expect(rows.filter((row) => row.body.kind === 'tool-call').map((row) => row.body)).toEqual([ + expect.objectContaining({ name: 'read', state: 'completed' }), + expect.objectContaining({ name: 'grep', state: 'completed' }) + ]) + }) + + it('drops a second close of a settled item and keeps the first terminal body', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_000 }) + rig.assembler.apply({ + type: 'item.close', + item: 'call-a', + body: { ...runningTool('read'), state: 'completed' } + }) + const repeat = rig.assembler.apply({ + type: 'item.close', + item: 'call-a', + body: { ...runningTool('read'), state: 'failed' } + }) + expect(repeat.dropped).toBe('item-settled') + expect((await rig.row(providerItemId('item', 'call-a')))?.body).toMatchObject({ + state: 'completed' + }) + }) + + it('reopens a settled item under the same row', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_000 }) + rig.assembler.apply({ type: 'item.close', item: 'agent-1', body: runningTool('task') }) + rig.assembler.apply({ type: 'item.open', item: 'agent-1', body: runningTool('task') }) + rig.assembler.apply({ + type: 'item.close', + item: 'agent-1', + body: { ...runningTool('task'), state: 'completed' } + }) + const rows = (await rig.rows()).filter((row) => row.body.kind === 'tool-call') + expect(rows).toHaveLength(1) + expect(rows[0]?.body).toMatchObject({ state: 'completed' }) + }) + + it('replaces a plan in place with each whole-list update', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_000 }) + rig.assembler.apply({ type: 'item.update', item: 'plan', body: plan('1. read') }) + rig.assembler.apply({ type: 'item.update', item: 'plan', body: plan('1. read ✓\n2. edit') }) + const rows = (await rig.rows()).filter((row) => row.body.kind === 'status') + expect(rows).toHaveLength(1) + expect(rows[0]?.body).toMatchObject({ text: '1. read ✓\n2. edit' }) + }) + + it('writes an item that arrives with no turn open as a thread row, opening no turn', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'item.close', item: 'call-a', body: assistantText('stray') }) + expect(rig.assembler.openTurnId).toBeNull() + const rows = await rig.rows() + expect(rows).toHaveLength(1) + expect(rows[0]?.turnScope).toEqual({ kind: 'thread' }) + }) + + it('fails a tool call its turn left running, and keeps one that outlives the turn open', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_000 }) + rig.assembler.apply({ type: 'item.open', item: 'call-a', body: runningTool('read') }) + rig.assembler.apply({ + type: 'item.open', + item: 'bg-1', + body: runningTool('background'), + outlivesTurn: true + }) + rig.assembler.apply({ + type: 'turn.end', + at: 2_000, + state: 'interrupted', + outcome: 'cancellation' + }) + expect((await rig.row(providerItemId('item', 'call-a')))?.body).toMatchObject({ + state: 'failed' + }) + expect((await rig.row(providerItemId('item', 'bg-1')))?.body).toMatchObject({ + state: 'running' + }) + + // It settles on its own close later, still scoped to the turn that started it. + rig.assembler.apply({ + type: 'item.close', + item: 'bg-1', + body: { ...runningTool('background'), state: 'completed' } + }) + const background = await rig.row(providerItemId('item', 'bg-1')) + expect(background?.body).toMatchObject({ state: 'completed' }) + expect(background?.turnScope).toEqual({ + kind: 'turn', + turnItemId: providerTurnItemId('turn-1') + }) + }) + + it('stamps the producing subagent on its rows', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_000 }) + rig.assembler.apply({ + type: 'item.open', + item: 'call-a', + body: runningTool('read'), + producer: { agentId: 'helper-1' } + }) + expect((await rig.row(providerItemId('item', 'call-a')))?.agentId).toBe('helper-1') + }) +}) + +describe('provider timeline text streams', () => { + it('coalesces a burst of deltas into one snapshot write', async () => { + const runs: (() => void)[] = [] + const rig = await openProviderTimelineRig({ + schedule: (run) => { + runs.push(run) + return () => {} + } + }) + rig.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_000 }) + for (const text of ['a', 'b', 'c']) { + rig.assembler.apply({ + type: 'text.delta', + item: { stream: 'reply' }, + channel: 'assistant', + text + }) + } + rig.assembler.apply({ + type: 'text.delta', + item: { stream: 'think' }, + channel: 'reasoning', + text: 'x' + }) + expect((await rig.rows()).filter((row) => row.body.kind === 'message')).toHaveLength(0) + runs.splice(0).forEach((run) => run()) + const [reply, single] = (await rig.rows()).filter((row) => row.body.kind === 'message') + expect(messageText(reply?.body)).toBe('abc') + // Three deltas cost the writes one delta does. + expect(reply?.revision).toBe(single?.revision) + }) + + it('settles with the provider final text, and keys a named message by its id', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_000 }) + rig.assembler.apply({ + type: 'text.delta', + item: { id: 'msg-1' }, + channel: 'assistant', + text: 'Draf' + }) + rig.assembler.apply({ type: 'text.close', item: { id: 'msg-1' }, text: 'Final answer' }) + expect(messageText((await rig.row(providerItemId('item', 'msg-1')))?.body)).toBe('Final answer') + expect(rig.assembler.apply({ type: 'text.close', item: { id: 'msg-1' } }).dropped).toBe( + 'stream-unknown' + ) + }) + + it('writes reasoning as a reasoning message, and nothing for a whitespace-only stream', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_000 }) + rig.assembler.apply({ + type: 'text.delta', + item: { stream: 'think' }, + channel: 'reasoning', + text: 'Hmm' + }) + rig.assembler.apply({ + type: 'text.delta', + item: { stream: 'reply' }, + channel: 'assistant', + text: ' \n' + }) + rig.assembler.apply({ type: 'turn.end', at: 2_000, state: 'completed' }) + const messages = (await rig.rows()).filter((row) => row.body.kind === 'message') + expect(messages.map((row) => row.body)).toEqual([ + { kind: 'message', role: 'reasoning', blocks: [{ type: 'text', text: 'Hmm' }] } + ]) + }) +}) diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-order.test.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-order.test.ts new file mode 100644 index 000000000000..3c8ee4949fdf --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-order.test.ts @@ -0,0 +1,254 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { MAX_PROVIDER_TIMELINE_OPEN_ENTRIES } from './provider-timeline-budget' +import { + assistantText, + closeProviderTimelineRigs, + messageText, + openProviderTimelineRig, + providerItemId, + providerTurnItemId, + runningTool +} from './provider-timeline-assembler-test-support' + +afterEach(closeProviderTimelineRigs) + +/** A rig whose coalescing window only fires when the test says so. */ +async function heldWindowRig() { + const runs: (() => void)[] = [] + const rig = await openProviderTimelineRig({ + schedule: (run) => { + runs.push(run) + return () => {} + } + }) + return { rig, fire: () => runs.splice(0).forEach((run) => run()) } +} + +describe('one canonical row per provider item', () => { + it('writes each named message to its own row, even on one provider stream', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ + type: 'text.delta', + item: { id: 'message-1' }, + channel: 'assistant', + text: 'First' + }) + rig.assembler.apply({ + type: 'text.delta', + item: { id: 'message-2' }, + channel: 'assistant', + text: 'Second' + }) + rig.assembler.flush() + expect(messageText((await rig.row(providerItemId('item', 'message-1')))?.body)).toBe('First') + expect(messageText((await rig.row(providerItemId('item', 'message-2')))?.body)).toBe('Second') + }) + + it('refuses a named message whose channel changes mid-stream instead of mixing them', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ + type: 'text.delta', + item: { id: 'm1' }, + channel: 'assistant', + text: 'Reply' + }) + expect( + rig.assembler.apply({ + type: 'text.delta', + item: { id: 'm1' }, + channel: 'reasoning', + text: 'x' + }).dropped + ).toBe('stream-mismatch') + rig.assembler.flush() + expect(messageText((await rig.row(providerItemId('item', 'm1')))?.body)).toBe('Reply') + }) + + it('starts the next anonymous message when its producer changes', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ + type: 'text.delta', + item: { stream: 'out' }, + channel: 'assistant', + text: 'Root' + }) + rig.assembler.apply({ + type: 'text.delta', + item: { stream: 'out' }, + channel: 'assistant', + text: 'Helper', + producer: { agentId: 'helper-1' } + }) + rig.assembler.flush() + const messages = (await rig.rows()).filter((row) => row.body.kind === 'message') + expect(messages.map((row) => [messageText(row.body), row.agentId])).toEqual([ + ['Root', undefined], + ['Helper', 'helper-1'] + ]) + }) + + it('settles a streamed message through its full snapshot as the same row', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ type: 'item.open', item: 'm1', body: assistantText('') }) + rig.assembler.apply({ + type: 'text.delta', + item: { id: 'm1' }, + channel: 'assistant', + text: 'Hel' + }) + rig.assembler.apply({ type: 'item.close', item: 'm1', body: assistantText('Hello, final') }) + rig.assembler.apply({ type: 'turn.end', at: 2_000, state: 'completed' }) + const messages = (await rig.rows()).filter((row) => row.body.kind === 'message') + expect(messages.map((row) => [row.itemId, messageText(row.body)])).toEqual([ + [providerItemId('item', 'm1'), 'Hello, final'] + ]) + }) + + it('keeps every open stream’s whole text while the budget holds them, and refuses the next', async () => { + const rig = await openProviderTimelineRig({ schedule: () => () => {} }) + for (let index = 0; index < MAX_PROVIDER_TIMELINE_OPEN_ENTRIES; index += 1) { + rig.assembler.apply({ + type: 'text.delta', + item: { id: `m${index}` }, + channel: 'assistant', + text: 'A' + }) + } + expect( + rig.assembler.apply({ + type: 'text.delta', + item: { id: 'one-more' }, + channel: 'assistant', + text: 'A' + }).admission + ).toEqual({ accepted: false, reason: 'failed' }) + rig.assembler.apply({ type: 'text.delta', item: { id: 'm0' }, channel: 'assistant', text: 'B' }) + rig.assembler.flush() + expect(messageText((await rig.row(providerItemId('item', 'm0')))?.body)).toBe('AB') + }) +}) + +describe('every other event is an ordering barrier for text', () => { + it('writes text that arrived before a tool ahead of the tool, named stream or not', async () => { + const { rig, fire } = await heldWindowRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ + type: 'text.delta', + item: { id: 'm1' }, + channel: 'assistant', + text: 'First' + }) + rig.assembler.apply({ type: 'item.open', item: 'tool', body: runningTool('read') }) + fire() + rig.assembler.flush() + const items = (await rig.rows()).filter((row) => row.body.kind !== 'turn') + expect(items.map((row) => row.itemId)).toEqual([ + providerItemId('item', 'm1'), + providerItemId('item', 'tool') + ]) + }) + + it('starts a new anonymous message after a context report, as after any row', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ + type: 'text.delta', + item: { stream: 's1' }, + channel: 'assistant', + text: 'First' + }) + rig.assembler.apply({ + type: 'context.usage', + usage: { window: { tokens: 1_000, capturedAt: 1_100 } } + }) + rig.assembler.apply({ + type: 'text.delta', + item: { stream: 's1' }, + channel: 'assistant', + text: 'Second' + }) + rig.assembler.flush() + const messages = (await rig.rows()).filter((row) => row.body.kind === 'message') + expect(messages.map((row) => messageText(row.body))).toEqual(['First', 'Second']) + }) + + it('does not split a message on an event it dropped or on the activity line', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ + type: 'text.delta', + item: { stream: 's1' }, + channel: 'assistant', + text: 'One ' + }) + expect(rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }).dropped).toBe( + 'turn-duplicate' + ) + rig.assembler.apply({ type: 'activity', text: 'Thinking' }) + rig.assembler.apply({ + type: 'text.delta', + item: { stream: 's1' }, + channel: 'assistant', + text: 'message' + }) + rig.assembler.flush() + const messages = (await rig.rows()).filter((row) => row.body.kind === 'message') + expect(messages.map((row) => messageText(row.body))).toEqual(['One message']) + }) +}) + +describe('explicit provider attribution', () => { + it('scopes a late item to the earlier turn the provider names', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ type: 'turn.end', at: 2_000, state: 'completed' }) + rig.assembler.apply({ type: 'turn.open', turn: 't2', at: 3_000 }) + rig.assembler.apply({ + type: 'item.close', + item: 'late-t1-message', + body: assistantText('Late output from t1'), + join: { turn: 't1' } + }) + expect((await rig.row(providerItemId('item', 'late-t1-message')))?.turnScope).toEqual({ + kind: 'turn', + turnItemId: providerTurnItemId('t1') + }) + }) + + it('keeps an item in the turn it opened in when its close arrives after the next turn opened', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ + type: 'item.open', + item: 'bg', + body: runningTool('task'), + outlivesTurn: true + }) + rig.assembler.apply({ type: 'turn.end', at: 2_000, state: 'completed' }) + rig.assembler.apply({ type: 'turn.open', turn: 't2', at: 3_000 }) + rig.assembler.apply({ + type: 'item.close', + item: 'bg', + body: { ...runningTool('task'), state: 'completed' } + }) + const row = await rig.row(providerItemId('item', 'bg')) + expect(row?.turnScope).toEqual({ kind: 'turn', turnItemId: providerTurnItemId('t1') }) + expect(row?.body).toMatchObject({ state: 'completed' }) + }) + + it('writes context facts onto the turn the provider names, not the open one', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ type: 'turn.end', at: 2_000, state: 'completed' }) + rig.assembler.apply({ type: 'turn.open', turn: 't2', at: 3_000 }) + rig.assembler.apply({ + type: 'context.usage', + usage: { window: { tokens: 5_000, capturedAt: 3_100 } }, + join: { turn: 't1' } + }) + expect(await rig.turn('t1')).toMatchObject({ contextUsage: { window: { tokens: 5_000 } } }) + expect(await rig.turn('t2')).not.toHaveProperty('contextUsage') + }) +}) diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-recovery.test.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-recovery.test.ts new file mode 100644 index 000000000000..d1bcda1b390c --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-recovery.test.ts @@ -0,0 +1,402 @@ +// Memory is a cache: what the assembler forgot (a restart, an evicted entry, a write the journal +// rejected, an event the sink refused) never changes which row an event lands on or what it says. + +import { afterEach, describe, expect, it } from 'vitest' +import { parseAgentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import { createCodexProviderTimelineIdentityScheme } from '../../codex/codex-provider-timeline-identity' +import { createDeferredStructuredAgentSessionEventSink } from '../agent-session-wire/structured-agent-session-event-sink' +import { testEventSinkLogging } from '../agent-session-wire/structured-agent-session-logger-test-support' +import { createProviderTimelineAssembler } from './provider-timeline-assembler' +import { + AGENT, + assistantText, + closeProviderTimelineRigs, + messageText, + NAMESPACE, + openProviderTimelineRig, + pendingApproval, + providerItemId, + providerTurnId, + providerTurnItemId, + refusingSink, + runningTool, + SESSION +} from './provider-timeline-assembler-test-support' +import { providerTimelineSink } from './provider-timeline-plan' + +afterEach(closeProviderTimelineRigs) + +const codexRig = () => + openProviderTimelineRig({ + scheme: createCodexProviderTimelineIdentityScheme({ + sessionId: SESSION, + primaryThreadId: () => 'root' + }), + ownThread: () => 'root' + }) + +const messages = async (rig: Awaited>) => + (await rig.rows()).filter((row) => row.body.kind === 'message') + +describe('a forgotten join finds its original row', () => { + it('finishes an in-flight message after a restart without overwriting an earlier one', async () => { + const rig = await codexRig() + const join = { thread: 'root', turn: 't1' } + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ type: 'item.close', item: 'm0', body: assistantText('First'), join }) + rig.assembler.apply({ type: 'item.open', item: 'm1', body: assistantText('Partial'), join }) + await rig.rows() + + const restarted = rig.restart({ generation: 'gen-2' }) + restarted.apply({ type: 'item.close', item: 'm1', body: assistantText('Second'), join }) + restarted.apply({ type: 'item.close', item: 'm2', body: assistantText('Third'), join }) + + expect((await messages(rig)).map((row) => [row.itemId, messageText(row.body)])).toEqual([ + ['codex:root:t1:0', 'First'], + ['codex:root:t1:1', 'Second'], + ['codex:root:t1:2', 'Third'] + ]) + }) + + it('resumes an in-flight streamed message after a restart from the text it already holds', async () => { + const rig = await codexRig() + const join = { thread: 'root', turn: 't1' } + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ + type: 'text.delta', + item: { id: 'm0' }, + channel: 'assistant', + text: 'Hel', + join + }) + await rig.rows() + + const restarted = rig.restart({ generation: 'gen-2' }) + restarted.apply({ + type: 'text.delta', + item: { id: 'm0' }, + channel: 'assistant', + text: 'lo', + join + }) + restarted.apply({ type: 'text.close', item: { id: 'm0' }, join }) + + expect((await messages(rig)).map((row) => [row.itemId, messageText(row.body)])).toEqual([ + ['codex:root:t1:0', 'Hello'] + ]) + }) + + it('reaches a message in its own turn after its join was evicted', async () => { + const rig = await codexRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ + type: 'item.close', + item: 'old-message', + body: assistantText('Old'), + join: { thread: 'root' } + }) + rig.assembler.apply({ type: 'turn.end', turn: 't1', at: 2_000, state: 'completed' }) + rig.assembler.apply({ type: 'turn.open', turn: 't2', at: 3_000 }) + for (let index = 0; index < 1_025; index += 1) { + rig.assembler.apply({ + type: 'item.close', + item: `m-${index}`, + body: assistantText('small'), + join: { thread: 'root' } + }) + if (index % 32 === 31) { + await rig.rows() + } + } + rig.assembler.apply({ + type: 'item.close', + item: 'old-message', + body: assistantText('Old'), + join: { thread: 'root' } + }) + + const old = (await rig.rows()).filter((row) => messageText(row.body) === 'Old') + expect(old.map((row) => row.itemId)).toEqual(['codex:root:t1:0']) + }) + + it('keeps a late tool close in the turn it opened in after its join was evicted', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ + type: 'item.open', + item: 'background-tool', + body: runningTool('background'), + outlivesTurn: true + }) + rig.assembler.apply({ type: 'turn.end', turn: 't1', at: 2_000, state: 'completed' }) + rig.assembler.apply({ type: 'turn.open', turn: 't2', at: 3_000 }) + for (let index = 0; index < 1_025; index += 1) { + rig.assembler.apply({ type: 'item.close', item: `m-${index}`, body: assistantText('small') }) + if (index % 32 === 31) { + await rig.rows() + } + } + rig.assembler.apply({ + type: 'item.close', + item: 'background-tool', + body: { ...runningTool('background'), state: 'completed' } + }) + + expect((await rig.row(providerItemId('item', 'background-tool')))?.turnScope).toEqual({ + kind: 'turn', + turnItemId: providerTurnItemId('t1') + }) + }) + + it('withdraws the request incarnation a previous run opened', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'request.open', request: 'p1', body: pendingApproval }) + rig.assembler.apply({ type: 'request.withdrawn', request: 'p1' }) + rig.assembler.apply({ type: 'request.open', request: 'p1', body: pendingApproval }) + await rig.rows() + + rig.restart({ generation: 'gen-2' }).apply({ type: 'request.withdrawn', request: 'p1' }) + + expect( + (await rig.row(providerItemId('request', 'p1', { incarnation: 2 })))?.body + ).toMatchObject({ resolution: { state: 'cancelled' } }) + }) +}) + +describe('streamed text has the same lifecycle as the item it streams into', () => { + it('keeps interleaved same-id streams on separate threads apart', async () => { + const rig = await codexRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ + type: 'text.delta', + item: { id: 'm1' }, + channel: 'assistant', + text: 'Root', + join: { thread: 'root', turn: 't1' } + }) + rig.assembler.apply({ + type: 'text.delta', + item: { id: 'm1' }, + channel: 'assistant', + text: 'Child', + join: { thread: 'child', turn: 'child-turn' } + }) + rig.assembler.apply({ type: 'turn.end', turn: 't1', at: 2_000, state: 'completed' }) + + expect((await messages(rig)).map((row) => [row.itemId, messageText(row.body)])).toEqual([ + ['codex:root:t1:0', 'Root'], + ['codex:child:child-turn:0', 'Child'] + ]) + }) + + it('never lets replayed text replace a message its settled turn completed', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ type: 'item.close', item: 'm1', body: assistantText('Full final text') }) + rig.assembler.apply({ type: 'turn.end', turn: 't1', at: 2_000, state: 'completed' }) + await rig.rows() + + const restarted = rig.restart({ generation: 'gen-2' }) + expect( + restarted.apply({ + type: 'text.delta', + item: { id: 'm1' }, + channel: 'assistant', + text: 'Full', + join: { turn: 't1' } + }).dropped + ).toBe('item-settled') + restarted.flush() + + expect(messageText((await rig.row(providerItemId('item', 'm1')))?.body)).toBe('Full final text') + }) + + it('settles a message on text.close, so a late delta cannot erase what it said', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ + type: 'text.delta', + item: { id: 'm1' }, + channel: 'assistant', + text: 'Prefix' + }) + rig.assembler.apply({ type: 'text.close', item: { id: 'm1' } }) + expect( + rig.assembler.apply({ + type: 'text.delta', + item: { id: 'm1' }, + channel: 'assistant', + text: 'Suffix' + }).dropped + ).toBe('item-settled') + rig.assembler.flush() + + expect(messageText((await rig.row(providerItemId('item', 'm1')))?.body)).toBe('Prefix') + }) +}) + +describe('a write the journal rejects changes nothing the assembler knows', () => { + it('keeps a running tool open when a replayed supersede it planned before bind lands as nothing', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 'old', at: 1_000 }) + rig.assembler.apply({ type: 'turn.end', turn: 'old', at: 2_000, state: 'completed' }) + rig.assembler.apply({ type: 'turn.open', turn: 'live', at: 3_000 }) + await rig.rows() + + const deferred = createDeferredStructuredAgentSessionEventSink(testEventSinkLogging()) + const sink = providerTimelineSink(deferred.sink) + if (!sink) { + throw new Error('the deferred sink offers transitions') + } + const assembler = createProviderTimelineAssembler({ + sink, + sessionId: SESSION, + agent: AGENT, + generation: 'gen-2', + namespace: NAMESPACE + }) + // Planned before bind: nothing tells the planner the journal already holds both turns. + assembler.apply({ type: 'turn.open', turn: 'live', at: 3_000 }) + assembler.apply({ type: 'item.open', item: 'tool', body: runningTool('read') }) + assembler.apply({ type: 'turn.open', turn: 'old', at: 1_000 }) + deferred.bind({ journal: rig.journal, fence: 1, publish: () => {} }) + await deferred.drained() + expect(await rig.turn('live')).toMatchObject({ state: 'running' }) + expect(assembler.openTurnId).toBe(providerTurnId('live')) + + const closed = assembler.apply({ + type: 'item.close', + item: 'tool', + body: { ...runningTool('read'), state: 'completed' } + }) + expect(closed.dropped).toBeUndefined() + await deferred.drained() + expect((await rig.row(providerItemId('item', 'tool')))?.body).toMatchObject({ + state: 'completed' + }) + deferred.close() + assembler.dispose() + }) + + it('takes no message ordinal for an event the sink refused', async () => { + const rig = await codexRig() + let refusing = false + const assembler = rig.restart({ sink: refusingSink(rig.sink, () => refusing) }) + const join = { thread: 'root', turn: 't1' } + assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + refusing = true + expect( + assembler.apply({ type: 'item.close', item: 'refused', body: assistantText('No'), join }) + .admission + ).toEqual({ accepted: false, reason: 'backpressure' }) + refusing = false + assembler.apply({ type: 'item.close', item: 'accepted', body: assistantText('Yes'), join }) + + expect((await messages(rig)).map((row) => row.itemId)).toEqual(['codex:root:t1:0']) + }) +}) + +describe('reset settles the old session whatever the assembler remembers', () => { + it('writes text a reset interrupts, though nothing else was open', async () => { + const rig = await openProviderTimelineRig({ schedule: () => () => {} }) + rig.assembler.apply({ + type: 'text.delta', + item: { stream: 'reply' }, + channel: 'assistant', + text: 'Before reset' + }) + rig.assembler.apply({ type: 'session.reset', namespace: 'provider-session-2' }) + + expect((await messages(rig)).map((row) => messageText(row.body))).toEqual(['Before reset']) + }) + + it('settles the journal work of a previous run as lost', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ type: 'item.open', item: 'tool', body: runningTool('read') }) + await rig.rows() + + rig.restart({ generation: 'gen-2' }).apply({ + type: 'session.reset', + namespace: 'provider-session-2' + }) + + expect(await rig.turn('t1')).toMatchObject({ state: 'unverifiable' }) + expect((await rig.row(providerItemId('item', 'tool')))?.body).toMatchObject({ + state: 'failed' + }) + }) +}) + +describe('the budget counts the work actually open', () => { + it('frees an answered request incarnation without waiting for its turn to end', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + for (let incarnation = 1; incarnation <= 129; incarnation += 1) { + expect( + rig.assembler.apply({ type: 'request.open', request: 'approval', body: pendingApproval }) + .admission + ).toEqual({ accepted: true }) + await rig.rows() + const identity = parseAgentJournalItemKey( + providerItemId('request', 'approval', { incarnation }) + ) + if (!identity) { + throw new Error('the request has a journal key') + } + // A client answers it, through the journal. + await rig.journal.appendItem( + identity, + { + ...pendingApproval, + resolution: { + state: 'resolved', + selectedOptionId: 'allow', + resolvedBy: 'phone', + resolvedAt: 1_100 + } + }, + { fence: 1, turnScope: { kind: 'turn', turnItemId: providerTurnItemId('t1') } } + ) + } + }) + + it('keeps a streamed message counted while its stream is open, whatever its snapshots say', async () => { + const rig = await openProviderTimelineRig({ schedule: () => () => {} }) + for (let index = 0; index < 128; index += 1) { + rig.assembler.apply({ + type: 'text.delta', + item: { id: `stream-${index}` }, + channel: 'assistant', + text: 'x' + }) + rig.assembler.apply({ + type: 'item.update', + item: `stream-${index}`, + body: assistantText('x') + }) + if (index % 32 === 31) { + await rig.rows() + } + } + expect( + rig.assembler.apply({ + type: 'text.delta', + item: { id: 'overflow' }, + channel: 'assistant', + text: 'x' + }).admission + ).toEqual({ accepted: false, reason: 'failed' }) + }) + + it('refuses a provider key larger than the whole budget', () => + openProviderTimelineRig({ schedule: () => () => {} }).then((rig) => { + expect( + rig.assembler.apply({ + type: 'text.delta', + item: { id: 'x'.repeat(1024 * 1024 + 1) }, + channel: 'assistant', + text: 'tiny' + }).admission + ).toEqual({ accepted: false, reason: 'failed' }) + })) +}) diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-refusal.test.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-refusal.test.ts new file mode 100644 index 000000000000..4551013a3c64 --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-refusal.test.ts @@ -0,0 +1,208 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { MAX_PROVIDER_TIMELINE_OPEN_ENTRIES } from './provider-timeline-budget' +import { + closeProviderTimelineRigs, + messageText, + openProviderTimelineRig, + pendingApproval, + providerItemId, + refusingSink, + runningTool +} from './provider-timeline-assembler-test-support' + +afterEach(closeProviderTimelineRigs) + +const BACKPRESSURE = { accepted: false, reason: 'backpressure' } + +describe('a refused event changes nothing and its retry lands it once', () => { + it('settles the tool a refused turn end owed when the end is re-applied', async () => { + const rig = await openProviderTimelineRig() + let refusing = false + const assembler = rig.restart({ sink: refusingSink(rig.sink, () => refusing) }) + assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + assembler.apply({ type: 'item.open', item: 'tool', body: runningTool('read') }) + refusing = true + const end = { type: 'turn.end', turn: 't1', at: 2_000, state: 'completed' } as const + expect(assembler.apply(end).admission).toEqual(BACKPRESSURE) + expect(assembler.openTurnId).not.toBeNull() + + refusing = false + expect(assembler.apply(end)).toEqual({ admission: { accepted: true } }) + expect((await rig.row(providerItemId('item', 'tool')))?.body).toMatchObject({ state: 'failed' }) + expect(await rig.turn('t1')).toMatchObject({ state: 'completed', completedAt: 2_000 }) + expect(assembler.apply(end).dropped).toBe('turn-unknown') + }) + + it('keeps observed text when the close that would write it is refused', async () => { + const rig = await openProviderTimelineRig() + let refusing = false + const assembler = rig.restart({ + sink: refusingSink(rig.sink, () => refusing), + schedule: () => () => {} + }) + assembler.apply({ + type: 'text.delta', + item: { id: 'm1' }, + channel: 'assistant', + text: 'Observed text' + }) + refusing = true + expect(assembler.apply({ type: 'text.close', item: { id: 'm1' } }).admission).toEqual( + BACKPRESSURE + ) + refusing = false + assembler.flush() + expect(messageText((await rig.row(providerItemId('item', 'm1')))?.body)).toBe('Observed text') + }) + + it('keeps window text while the sink is full and retries it, and lets it go once the sink is gone', async () => { + const runs: (() => void)[] = [] + const rig = await openProviderTimelineRig() + let refusal: 'backpressure' | 'failed' | null = null + const assembler = rig.restart({ + sink: { + ...rig.sink, + tryAppendTransition: (transition) => + refusal ? { accepted: false, reason: refusal } : rig.sink.tryAppendTransition(transition) + }, + schedule: (run) => { + runs.push(run) + return () => {} + } + }) + assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + refusal = 'backpressure' + assembler.apply({ type: 'text.delta', item: { id: 'm1' }, channel: 'assistant', text: 'Kept' }) + runs.splice(0).forEach((run) => run()) + // Refused under backpressure: still owed, and the window is scheduled again. + expect(runs).toHaveLength(1) + refusal = null + runs.splice(0).forEach((run) => run()) + expect(messageText((await rig.row(providerItemId('item', 'm1')))?.body)).toBe('Kept') + + assembler.apply({ type: 'text.delta', item: { id: 'm2' }, channel: 'assistant', text: 'Lost' }) + refusal = 'failed' + runs.splice(0).forEach((run) => run()) + // A failed sink can never take it: no retry is scheduled. + expect(runs).toHaveLength(0) + }) + + it('does not latch a refused session end, so its retry still settles the session', async () => { + const rig = await openProviderTimelineRig() + let refusing = false + const assembler = rig.restart({ sink: refusingSink(rig.sink, () => refusing) }) + assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + assembler.apply({ type: 'request.open', request: 'p1', body: pendingApproval }) + refusing = true + const ended = { + type: 'session.ended', + verdict: { state: 'interrupted', completedAt: 3_000 } + } as const + expect(assembler.apply(ended).admission).toEqual(BACKPRESSURE) + expect(assembler.apply({ type: 'activity', text: 'still live' }).dropped).toBeUndefined() + + refusing = false + assembler.apply(ended) + expect(await rig.turn('t1')).toMatchObject({ state: 'interrupted', completedAt: 3_000 }) + expect((await rig.row(providerItemId('request', 'p1')))?.body).toMatchObject({ + resolution: { state: 'cancelled' } + }) + expect(assembler.apply({ type: 'turn.open', turn: 't2', at: 4_000 }).dropped).toBe( + 'session-ended' + ) + }) + + it('leaves the open turn in place when a superseding open is refused', async () => { + const rig = await openProviderTimelineRig() + let refusing = false + const assembler = rig.restart({ sink: refusingSink(rig.sink, () => refusing) }) + assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + const first = assembler.openTurnId + refusing = true + expect(assembler.apply({ type: 'turn.open', turn: 't2', at: 2_000 }).admission).toEqual( + BACKPRESSURE + ) + expect(assembler.openTurnId).toBe(first) + refusing = false + assembler.apply({ type: 'turn.open', turn: 't2', at: 2_000 }) + expect(await rig.turn('t1')).toMatchObject({ state: 'interrupted', outcome: 'superseded' }) + expect(await rig.turn('t2')).toMatchObject({ state: 'running' }) + }) + + it('keeps the old session open when the reset that would settle it is refused', async () => { + const rig = await openProviderTimelineRig() + let refusing = false + const assembler = rig.restart({ sink: refusingSink(rig.sink, () => refusing) }) + assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + refusing = true + const reset = { type: 'session.reset', namespace: 'provider-session-2' } as const + expect(assembler.apply(reset).admission).toEqual(BACKPRESSURE) + expect(assembler.openTurnId).not.toBeNull() + refusing = false + assembler.apply(reset) + expect(assembler.openTurnId).toBeNull() + expect(await rig.turn('t1')).toMatchObject({ state: 'unverifiable' }) + }) +}) + +describe('what the assembler holds open is bounded', () => { + it('refuses, as failed, work past its open budget instead of forgetting it', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + for (let index = 0; index < MAX_PROVIDER_TIMELINE_OPEN_ENTRIES / 2; index += 1) { + expect( + rig.assembler.apply({ type: 'item.open', item: `item-${index}`, body: runningTool('read') }) + .admission + ).toEqual({ accepted: true }) + expect( + rig.assembler.apply({ + type: 'request.open', + request: `request-${index}`, + body: pendingApproval + }).admission + ).toEqual({ accepted: true }) + } + const refused = { accepted: false, reason: 'failed' } + expect( + rig.assembler.apply({ type: 'item.open', item: 'one-more', body: runningTool('read') }) + .admission + ).toEqual(refused) + expect( + rig.assembler.apply({ type: 'request.open', request: 'one-more', body: pendingApproval }) + .admission + ).toEqual(refused) + expect( + rig.assembler.apply({ + type: 'text.delta', + item: { stream: 'more' }, + channel: 'assistant', + text: 'x' + }).admission + ).toEqual(refused) + expect(await rig.row(providerItemId('item', 'one-more'))).toBeUndefined() + + // Settling frees the budget again. + rig.assembler.apply({ type: 'turn.end', at: 2_000, state: 'completed' }) + rig.assembler.apply({ type: 'turn.open', turn: 't2', at: 3_000 }) + expect( + rig.assembler.apply({ type: 'item.open', item: 'one-more', body: runningTool('read') }) + .admission + ).toEqual({ accepted: true }) + }) + + it('refuses a stream past the budget, even though the coalescer would hold it', async () => { + const rig = await openProviderTimelineRig({ schedule: () => () => {} }) + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + let admitted = 0 + for (let index = 0; index < 1_100; index += 1) { + const { admission } = rig.assembler.apply({ + type: 'text.delta', + item: { id: `m${index}` }, + channel: 'assistant', + text: 'x' + }) + admitted += admission.accepted ? 1 : 0 + } + expect(admitted).toBe(MAX_PROVIDER_TIMELINE_OPEN_ENTRIES) + }) +}) diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-replay.test.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-replay.test.ts new file mode 100644 index 000000000000..bb0eee27f429 --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-replay.test.ts @@ -0,0 +1,295 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { parseAgentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import { + closeProviderTimelineRigs, + openProviderTimelineRig, + pendingApproval, + providerItemId, + providerTurnItemId, + runningTool +} from './provider-timeline-assembler-test-support' + +afterEach(closeProviderTimelineRigs) + +const answered = { + ...pendingApproval, + resolution: { + state: 'resolved' as const, + selectedOptionId: 'allow', + resolvedBy: 'phone', + resolvedAt: 1_500 + } +} + +describe('a replay never changes what the journal already holds', () => { + it('keeps an answered prompt answered when a restarted host replays its open and its turn', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ type: 'request.open', request: 'p1', body: pendingApproval }) + const itemId = providerItemId('request', 'p1') + const identity = parseAgentJournalItemKey(itemId) + if (!identity) { + throw new Error('request key did not parse') + } + // The answer path's compare-and-set lands first, with no word to the assembler. + await rig.journal.appendItem(identity, answered, { + fence: 1, + turnScope: { kind: 'turn', turnItemId: providerTurnItemId('t1') } + }) + rig.assembler.apply({ type: 'turn.end', at: 2_000, state: 'completed', outcome: 'success' }) + expect((await rig.row(itemId))?.body).toMatchObject({ resolution: { state: 'resolved' } }) + + const restarted = rig.restart({ generation: 'gen-2' }) + expect(restarted.apply({ type: 'turn.open', turn: 't1', at: 3_000 }).dropped).toBe( + 'turn-replayed' + ) + expect( + restarted.apply({ type: 'request.open', request: 'p1', body: pendingApproval }).dropped + ).toBe('request-replayed') + restarted.apply({ type: 'turn.end', at: 4_000, state: 'completed' }) + expect((await rig.row(itemId))?.body).toMatchObject({ + resolution: { state: 'resolved', selectedOptionId: 'allow' } + }) + }) + + it('does not let a replayed open of a settled turn supersede the live one', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 'old', at: 1_000 }) + rig.assembler.apply({ type: 'turn.end', at: 2_000, state: 'completed' }) + await rig.rows() + const restarted = rig.restart({ generation: 'gen-2' }) + restarted.apply({ type: 'turn.open', turn: 'live', at: 3_000 }) + restarted.apply({ type: 'item.open', item: 'live-tool', body: runningTool('read') }) + const live = restarted.openTurnId + + expect(restarted.apply({ type: 'turn.open', turn: 'old', at: 1_000 }).dropped).toBe( + 'turn-replayed' + ) + expect(restarted.openTurnId).toBe(live) + expect(await rig.turn('live')).toMatchObject({ state: 'running' }) + expect((await rig.row(providerItemId('item', 'live-tool')))?.body).toMatchObject({ + state: 'running' + }) + }) + + it('settles a turn the journal holds running even when the assembler that opened it is gone', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ type: 'item.open', item: 'tool', body: runningTool('read') }) + await rig.rows() + const restarted = rig.restart({ generation: 'gen-2' }) + restarted.apply({ type: 'turn.end', turn: 't1', at: 2_000, state: 'completed' }) + expect(await rig.turn('t1')).toMatchObject({ state: 'completed', completedAt: 2_000 }) + expect((await rig.row(providerItemId('item', 'tool')))?.body).toMatchObject({ state: 'failed' }) + }) + + it('takes its open turn back from the journal when a planned open found the row already there', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 'live', at: 1_000 }) + await rig.rows() + const live = rig.assembler.openTurnId + // The plan reads no journal yet (as before it binds); the write still asks it when it runs. + let lagging = true + const stale = rig.restart({ + sink: { ...rig.sink, journalItems: () => (lagging ? null : rig.sink.journalItems()) }, + generation: 'gen-2' + }) + expect(stale.apply({ type: 'turn.open', turn: 'live', at: 1_000 }).dropped).toBeUndefined() + lagging = false + await rig.rows() + await Promise.resolve() + expect(stale.openTurnId).toBe(live) + expect(await rig.turn('live')).toMatchObject({ state: 'running', startedAt: 1_000 }) + }) + + it('never resurrects a settled tool from a running snapshot', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ + type: 'item.close', + item: 'tool', + body: { ...runningTool('read'), state: 'completed' } + }) + expect( + rig.assembler.apply({ type: 'item.update', item: 'tool', body: runningTool('read') }).dropped + ).toBe('item-settled') + rig.assembler.apply({ + type: 'session.ended', + verdict: { state: 'interrupted', completedAt: 2_000 } + }) + expect((await rig.row(providerItemId('item', 'tool')))?.body).toMatchObject({ + state: 'completed' + }) + }) + + it('does not reopen a settled tool a restarted host replays', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ type: 'item.open', item: 'tool', body: runningTool('read') }) + rig.assembler.apply({ + type: 'item.close', + item: 'tool', + body: { ...runningTool('read'), state: 'completed' } + }) + await rig.rows() + const restarted = rig.restart({ generation: 'gen-2' }) + expect( + restarted.apply({ type: 'item.open', item: 'tool', body: runningTool('read') }).dropped + ).toBe('item-replayed') + expect( + restarted.apply({ type: 'item.update', item: 'tool', body: runningTool('read') }).dropped + ).toBe('item-settled') + expect((await rig.row(providerItemId('item', 'tool')))?.body).toMatchObject({ + state: 'completed' + }) + }) +}) + +describe('requests', () => { + it('opens a request reused after it settled as a new one, and withdraws each on its own', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ type: 'request.open', request: 'p1', body: pendingApproval }) + rig.assembler.apply({ type: 'request.withdrawn', request: 'p1' }) + await rig.rows() + rig.assembler.apply({ type: 'request.open', request: 'p1', body: pendingApproval }) + rig.assembler.apply({ type: 'request.withdrawn', request: 'p1' }) + + const prompts = (await rig.rows()).filter((row) => row.body.kind === 'approval') + expect(prompts.map((row) => row.itemId)).toEqual([ + providerItemId('request', 'p1'), + providerItemId('request', 'p1', { incarnation: 2 }) + ]) + expect(prompts.map((row) => row.body)).toEqual([ + expect.objectContaining({ resolution: expect.objectContaining({ state: 'cancelled' }) }), + expect.objectContaining({ resolution: expect.objectContaining({ state: 'cancelled' }) }) + ]) + }) + + it('opens a new request when the pending one under its key was answered', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ type: 'request.open', request: 'p1', body: pendingApproval }) + const identity = parseAgentJournalItemKey(providerItemId('request', 'p1')) + if (!identity) { + throw new Error('request key did not parse') + } + await rig.journal.appendItem(identity, answered, { + fence: 1, + turnScope: { kind: 'turn', turnItemId: providerTurnItemId('t1') } + }) + expect( + rig.assembler.apply({ type: 'request.open', request: 'p1', body: pendingApproval }).dropped + ).toBeUndefined() + // The answered prompt keeps its answer; withdrawing reaches only the new one. + rig.assembler.apply({ type: 'request.withdrawn', request: 'p1' }) + expect((await rig.row(providerItemId('request', 'p1')))?.body).toMatchObject({ + resolution: { state: 'resolved' } + }) + expect( + (await rig.row(providerItemId('request', 'p1', { incarnation: 2 })))?.body + ).toMatchObject({ + resolution: { state: 'cancelled' } + }) + }) + + it('leaves an answered request answered when its withdrawal lands after the answer', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ type: 'request.open', request: 'p1', body: pendingApproval }) + const identity = parseAgentJournalItemKey(providerItemId('request', 'p1')) + if (!identity) { + throw new Error('request key did not parse') + } + // Memory still holds it pending; only the journal knows a client answered. + await rig.journal.appendItem(identity, answered, { + fence: 1, + turnScope: { kind: 'turn', turnItemId: providerTurnItemId('t1') } + }) + rig.assembler.apply({ type: 'request.withdrawn', request: 'p1' }) + expect((await rig.row(providerItemId('request', 'p1')))?.body).toMatchObject({ + resolution: { state: 'resolved', selectedOptionId: 'allow' } + }) + }) + + it('never overwrites a request row it could not see when it planned the open', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ type: 'request.open', request: 'p1', body: pendingApproval }) + const identity = parseAgentJournalItemKey(providerItemId('request', 'p1')) + if (!identity) { + throw new Error('request key did not parse') + } + await rig.journal.appendItem(identity, answered, { + fence: 1, + turnScope: { kind: 'turn', turnItemId: providerTurnItemId('t1') } + }) + const blind = rig.restart({ + sink: { ...rig.sink, journalItems: () => null }, + generation: 'gen-2' + }) + blind.apply({ type: 'request.open', request: 'p1', body: pendingApproval }) + expect((await rig.row(providerItemId('request', 'p1')))?.body).toMatchObject({ + resolution: { state: 'resolved', selectedOptionId: 'allow' } + }) + }) +}) + +describe('a new provider session is a new id space', () => { + it('gives a reused provider turn id its own row after a reset', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: '1', at: 1_000 }) + rig.assembler.apply({ type: 'turn.end', at: 2_000, state: 'completed' }) + rig.assembler.apply({ + type: 'session.ended', + verdict: { state: 'interrupted', completedAt: 2_000 } + }) + rig.assembler.apply({ type: 'session.reset', namespace: 'provider-session-2' }) + rig.assembler.apply({ type: 'turn.open', turn: '1', at: 3_000 }) + + expect((await rig.turns()).map((turn) => turn.state)).toEqual(['completed', 'running']) + expect(await rig.turn('1', 'provider-session-2')).toMatchObject({ startedAt: 3_000 }) + }) + + it('does not overwrite an earlier session’s item when the provider reuses its id', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ + type: 'item.close', + item: 'tool-1', + body: { ...runningTool('first'), state: 'completed' } + }) + rig.assembler.apply({ + type: 'session.ended', + verdict: { state: 'interrupted', completedAt: 2_000 } + }) + rig.assembler.apply({ type: 'session.reset', namespace: 'provider-session-2' }) + rig.assembler.apply({ + type: 'item.close', + item: 'tool-1', + body: { ...runningTool('second'), state: 'completed' } + }) + const tools = (await rig.rows()).filter((row) => row.body.kind === 'tool-call') + expect(tools.map((row) => row.body)).toEqual([ + expect.objectContaining({ name: 'first' }), + expect.objectContaining({ name: 'second' }) + ]) + }) + + it('settles the open work of a session reset before anything ended it, as lost', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ type: 'item.open', item: 'tool', body: runningTool('read') }) + rig.assembler.apply({ + type: 'text.delta', + item: { stream: 'reply' }, + channel: 'assistant', + text: 'Half' + }) + rig.assembler.apply({ type: 'session.reset', namespace: 'provider-session-2' }) + rig.assembler.apply({ type: 'session.ended', verdict: { state: 'unverifiable' } }) + + expect((await rig.row(providerItemId('item', 'tool')))?.body).toMatchObject({ state: 'failed' }) + expect(await rig.turn('t1')).toMatchObject({ state: 'unverifiable' }) + expect((await rig.rows()).filter((row) => row.body.kind === 'message')).toHaveLength(1) + }) +}) diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-session.test.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-session.test.ts new file mode 100644 index 000000000000..3bc3aeb3caa5 --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-session.test.ts @@ -0,0 +1,196 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { parseAgentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import type { AgentJournalApprovalItem } from '../../../shared/agent-session-journal-types' +import { + closeProviderTimelineRigs, + openProviderTimelineRig, + providerItemId, + providerTurnItemId, + runningTool +} from './provider-timeline-assembler-test-support' + +afterEach(closeProviderTimelineRigs) + +const approval: AgentJournalApprovalItem = { + kind: 'approval', + title: 'Run npm test?', + detail: null, + options: [ + { id: 'allow', label: 'Allow' }, + { id: 'reject', label: 'Reject' } + ], + resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } +} + +describe('provider timeline session end', () => { + it('ends the open turn interrupted with no verdict when the child exit was observed', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_000 }) + rig.assembler.apply({ type: 'item.open', item: 'call-a', body: runningTool('read') }) + rig.assembler.apply({ + type: 'text.delta', + item: { stream: 'reply' }, + channel: 'assistant', + text: 'Half' + }) + rig.assembler.apply({ type: 'request.open', request: 'perm-1', body: approval }) + rig.assembler.apply({ + type: 'session.ended', + verdict: { state: 'interrupted', completedAt: 4_000 } + }) + + const turn = await rig.turn('turn-1') + expect(turn).toMatchObject({ state: 'interrupted', completedAt: 4_000, startedAt: 1_000 }) + expect(turn).not.toHaveProperty('outcome') + expect((await rig.row(providerItemId('item', 'call-a')))?.body).toMatchObject({ + state: 'failed' + }) + expect((await rig.row(providerItemId('request', 'perm-1')))?.body).toMatchObject({ + resolution: { state: 'cancelled', selectedOptionId: null } + }) + // A stream cut off keeps the text it received. + const messages = (await rig.rows()).filter((row) => row.body.kind === 'message') + expect(messages.map((row) => row.body)).toEqual([ + { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'Half' }] } + ]) + }) + + it('marks the open turn unverifiable with no end when the host lost the child', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'input.accepted', clientMessageId: 'send-1', requestedAt: 900 }) + rig.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_000 }) + rig.assembler.apply({ type: 'session.ended', verdict: { state: 'unverifiable' } }) + const turn = await rig.turn('turn-1') + expect(turn).toMatchObject({ state: 'unverifiable', startedAt: 1_000, requestedAt: 900 }) + expect(turn).not.toHaveProperty('completedAt') + expect(turn).not.toHaveProperty('durationMs') + expect(turn).not.toHaveProperty('outcome') + }) + + it('drops everything after the session ended until a reset starts a fresh namespace', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', at: 1_000 }) + const first = rig.assembler.openTurnId + rig.assembler.apply({ + type: 'session.ended', + verdict: { state: 'interrupted', completedAt: 2_000 } + }) + // A straggler from the dead child must not open a turn nothing would close. + expect(rig.assembler.apply({ type: 'turn.open', at: 2_500 }).dropped).toBe('session-ended') + expect( + rig.assembler.apply({ type: 'item.open', item: 'call-a', body: runningTool('read') }).dropped + ).toBe('session-ended') + expect(rig.assembler.openTurnId).toBeNull() + + rig.assembler.apply({ type: 'session.reset', namespace: 'provider-session-2' }) + rig.assembler.apply({ type: 'turn.open', at: 3_000 }) + const second = rig.assembler.openTurnId + expect(second).not.toBeNull() + expect(second).not.toBe(first) + const turns = (await rig.rows()).filter((row) => row.body.kind === 'turn') + expect(turns.map((row) => row.body)).toEqual([ + expect.objectContaining({ turnId: first, state: 'interrupted' }), + expect.objectContaining({ turnId: second, state: 'running' }) + ]) + }) +}) + +describe('provider timeline requests', () => { + it('cancels a request its turn left pending', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_000 }) + rig.assembler.apply({ type: 'request.open', request: 'perm-1', body: approval }) + expect( + rig.assembler.apply({ type: 'request.open', request: 'perm-1', body: approval }).dropped + ).toBe('request-duplicate') + rig.assembler.apply({ + type: 'turn.end', + at: 2_000, + state: 'interrupted', + outcome: 'cancellation' + }) + const row = await rig.row(providerItemId('request', 'perm-1')) + expect(row?.body).toMatchObject({ resolution: { state: 'cancelled' } }) + expect(row?.turnScope).toEqual({ kind: 'turn', turnItemId: providerTurnItemId('turn-1') }) + }) + + it('never cancels a request a client already answered', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_000 }) + rig.assembler.apply({ type: 'request.open', request: 'perm-1', body: approval }) + const itemId = providerItemId('request', 'perm-1') + const identity = parseAgentJournalItemKey(itemId) + if (!identity) { + throw new Error('request key did not parse') + } + // The answer path's compare-and-set wins on another device and writes the row resolved. + rig.journal.appendItem( + identity, + { + ...approval, + resolution: { + state: 'resolved', + selectedOptionId: 'allow', + resolvedBy: 'phone', + resolvedAt: 1_500 + } + }, + { fence: 1, turnScope: { kind: 'turn', turnItemId: providerTurnItemId('turn-1') } } + ) + rig.assembler.apply({ type: 'turn.end', at: 2_000, state: 'completed', outcome: 'success' }) + expect((await rig.row(itemId))?.body).toMatchObject({ + resolution: { state: 'resolved', selectedOptionId: 'allow', resolvedBy: 'phone' } + }) + }) + + it('cancels a request the provider withdrew, once', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_000 }) + rig.assembler.apply({ type: 'request.open', request: 'perm-1', body: approval }) + rig.assembler.apply({ type: 'request.withdrawn', request: 'perm-1' }) + expect(rig.assembler.apply({ type: 'request.withdrawn', request: 'perm-1' }).dropped).toBe( + 'request-unknown' + ) + expect((await rig.row(providerItemId('request', 'perm-1')))?.body).toMatchObject({ + resolution: { state: 'cancelled' } + }) + }) +}) + +describe('provider timeline turn facts', () => { + it('writes context usage onto the turn that just ended, keeping its end', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_000 }) + rig.assembler.apply({ type: 'turn.end', at: 2_000, state: 'completed', outcome: 'success' }) + rig.assembler.apply({ + type: 'context.usage', + usage: { window: { tokens: 200_000, capturedAt: 2_100 } } + }) + expect(await rig.turn('turn-1')).toMatchObject({ + state: 'completed', + outcome: 'success', + completedAt: 2_000, + contextUsage: { window: { tokens: 200_000, capturedAt: 2_100 } } + }) + }) + + it('sets the live activity line only while a turn is open', async () => { + const rig = await openProviderTimelineRig() + expect(rig.assembler.apply({ type: 'activity', text: 'Reading' }).dropped).toBe('no-turn') + rig.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_000 }) + expect(rig.assembler.apply({ type: 'activity', text: 'Reading' }).dropped).toBeUndefined() + }) + + it('journals provider traffic no event covers as the shared fallback row', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_000 }) + rig.assembler.apply({ + type: 'provider.frame', + frameKind: 'session/mystery_update', + payload: { message: 'Something new happened' } + }) + const frame = (await rig.rows()).find((row) => row.body.kind === 'status') + expect(frame?.body).toMatchObject({ kind: 'status' }) + expect(frame?.turnScope).toEqual({ kind: 'turn', turnItemId: providerTurnItemId('turn-1') }) + }) +}) diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-test-support.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-test-support.ts new file mode 100644 index 000000000000..0b1d20746c58 --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-test-support.ts @@ -0,0 +1,201 @@ +// A real assembled lane for tests: grammar events → assembler → deferred sink queue → on-disk +// journal. Assertions read the journal back, so they check what a client sees. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import type { + AgentJournalApprovalItem, + AgentJournalItemBody, + AgentJournalMessageItem, + AgentJournalRenderItem, + AgentJournalToolCallItem, + AgentJournalTurnLifecycle +} from '../../../shared/agent-session-journal-types' +import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' +import { createTrackedJournalOpener } from '../agent-session-journal/journal-host-database-test-support' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { + createDeferredStructuredAgentSessionEventSink, + type StructuredAgentSessionEventSink +} from '../agent-session-wire/structured-agent-session-event-sink' +import { testEventSinkLogging } from '../agent-session-wire/structured-agent-session-logger-test-support' +import { + createProviderTimelineAssembler, + type ProviderTimelineAssembler, + type ProviderTimelineAssemblerDeps +} from './provider-timeline-assembler' +import { + createLegacyProviderTimelineIdentityScheme, + type ProviderTimelineItemFamily +} from './provider-timeline-identity' +import { providerTimelineSink, type ProviderTimelineSink } from './provider-timeline-plan' + +export const SESSION = 'session-timeline' +export const AGENT = 'grok' +export const GENERATION = 'gen-1' +export const NAMESPACE = 'provider-session-1' + +const scheme = createLegacyProviderTimelineIdentityScheme({ agent: AGENT, sessionId: SESSION }) + +/** The journal key the assembler gives a provider-keyed item. */ +export function providerItemId( + family: ProviderTimelineItemFamily, + key: string, + options: { namespace?: string; incarnation?: number } = {} +): string { + return agentJournalItemKey( + scheme.item({ + namespace: options.namespace ?? NAMESPACE, + family, + key: { source: 'provider', value: key }, + thread: null, + turn: null, + itemClass: 'message', + messageOrdinal: null, + incarnation: options.incarnation ?? 1 + }) + ) +} + +/** The journal key of a provider-keyed turn's row. */ +export function providerTurnItemId(turnKey: string, namespace = NAMESPACE): string { + return agentJournalItemKey( + scheme.turn({ namespace, key: { source: 'provider', value: turnKey } }) + ) +} + +/** The turn id a provider-keyed turn's row carries. */ +export function providerTurnId(turnKey: string, namespace = NAMESPACE): string { + return scheme.turnId({ namespace, key: { source: 'provider', value: turnKey } }) +} + +export function runningTool(name: string): AgentJournalToolCallItem { + return { kind: 'tool-call', name, input: { name }, state: 'running' } +} + +export function assistantText(text: string): AgentJournalMessageItem { + return { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text }] } +} + +export const pendingApproval: AgentJournalApprovalItem = { + kind: 'approval', + title: 'Run?', + detail: null, + options: [{ id: 'allow', label: 'Allow' }], + resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } +} + +export function messageText(body: AgentJournalItemBody | undefined): string | undefined { + return body?.kind === 'message' && body.blocks[0]?.type === 'text' + ? body.blocks[0].text + : undefined +} + +const journals = createTrackedJournalOpener() +const cleanups: (() => Promise)[] = [] + +/** Call from `afterEach`. */ +export async function closeProviderTimelineRigs(): Promise { + for (const cleanup of cleanups.splice(0)) { + await cleanup() + } + await journals.closeAll() +} + +export type ProviderTimelineRig = { + journal: AgentSessionJournal + assembler: ProviderTimelineAssembler + sink: ProviderTimelineSink + /** The same journal's event sink, for a lane that writes it directly. */ + eventSink: StructuredAgentSessionEventSink + /** A fresh assembler on the same journal, as a restarted host builds. */ + restart(overrides?: Partial): ProviderTimelineAssembler + rows(): Promise + row(itemId: string): Promise + /** The turn row of provider turn `turnKey`, or the row whose turn id is `turnKey`. */ + turn(turnKey: string, namespace?: string): Promise + turns(): Promise +} + +/** The rig's sink with its transitions refused while `refusing()` holds. */ +export function refusingSink( + sink: ProviderTimelineSink, + refusing: () => boolean, + reason: 'backpressure' | 'failed' = 'backpressure' +): ProviderTimelineSink { + return { + ...sink, + tryAppendTransition: (transition) => + refusing() ? { accepted: false, reason } : sink.tryAppendTransition(transition) + } +} + +export async function openProviderTimelineRig( + overrides: Partial = {} +): Promise { + const root = await mkdtemp(join(tmpdir(), 'orca-provider-timeline-')) + const journal = await journals.open({ + identity: { + sessionId: SESSION, + workspaceId: 'workspace-1', + hostId: 'local', + agent: AGENT, + providerHandle: { kind: 'opaque', agent: AGENT, value: 'provider-session-1' } + }, + stateDirectory: root, + now: () => 1_000 + }) + const deferred = createDeferredStructuredAgentSessionEventSink(testEventSinkLogging()) + deferred.bind({ journal, fence: 1, publish: () => {} }) + cleanups.push(async () => { + deferred.close() + await rm(root, { recursive: true, force: true }) + }) + const sink = providerTimelineSink(deferred.sink) + if (!sink) { + throw new Error('the deferred sink offers transitions') + } + const build = (more: Partial = {}) => + createProviderTimelineAssembler({ + sink, + sessionId: SESSION, + agent: AGENT, + generation: GENERATION, + namespace: NAMESPACE, + // Every delta writes at once unless a test drives the window itself. + schedule: (run) => { + run() + return () => {} + }, + ...overrides, + ...more + }) + const rows = async () => { + await deferred.drained() + return journal.snapshot().items + } + const turns = async () => + (await rows()).flatMap((item) => { + const turn = readAgentJournalTurn(item.body) + return turn ? [turn] : [] + }) + return { + journal, + assembler: build(), + sink, + eventSink: deferred.sink, + restart: build, + rows, + row: async (itemId) => (await rows()).find((item) => item.itemId === itemId), + turns, + turn: async (turnKey, namespace) => { + const all = await turns() + return ( + all.find((turn) => turn.turnId === providerTurnId(turnKey, namespace)) ?? + all.find((turn) => turn.turnId === turnKey) + ) + } + } +} diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-turns.test.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-turns.test.ts new file mode 100644 index 000000000000..69fa01b3f9d1 --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-turns.test.ts @@ -0,0 +1,183 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' +import { + closeProviderTimelineRigs, + openProviderTimelineRig, + providerTurnId, + providerTurnItemId +} from './provider-timeline-assembler-test-support' + +afterEach(closeProviderTimelineRigs) + +describe('provider timeline turns', () => { + it('opens a running turn and settles it with the provider verdict and duration', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'input.accepted', clientMessageId: 'send-1', requestedAt: 900 }) + rig.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_000 }) + expect(rig.assembler.openTurnId).toBe(providerTurnId('turn-1')) + expect(await rig.turn('turn-1')).toEqual({ + turnId: providerTurnId('turn-1'), + state: 'running', + userItemId: agentJournalSubmissionKey('send-1'), + startedAt: 1_000, + requestedAt: 900 + }) + const runningRow = await rig.row(providerTurnItemId('turn-1')) + // The running row is stamped at the turn start, not at append time. + expect(runningRow?.observedAt).toBe(1_000) + + rig.assembler.apply({ + type: 'turn.end', + at: 3_000, + state: 'completed', + outcome: 'success', + durationMs: 1_800 + }) + expect(rig.assembler.openTurnId).toBeNull() + const row = await rig.row(providerTurnItemId('turn-1')) + expect(row?.body).toEqual({ + kind: 'turn', + turnId: providerTurnId('turn-1'), + state: 'completed', + outcome: 'success', + userItemId: agentJournalSubmissionKey('send-1'), + startedAt: 1_000, + requestedAt: 900, + completedAt: 3_000, + durationMs: 1_800 + }) + // Turn rows belong to no turn, and are revised rather than replaced. + expect(row?.turnScope).toEqual({ kind: 'thread' }) + expect(row?.revision).toBeGreaterThan(runningRow?.revision ?? Infinity) + }) + + it('records a cancelled turn as interrupted with the cancellation verdict', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_000 }) + rig.assembler.apply({ + type: 'turn.end', + at: 2_000, + state: 'interrupted', + outcome: 'cancellation' + }) + expect(await rig.turn('turn-1')).toMatchObject({ + state: 'interrupted', + outcome: 'cancellation', + completedAt: 2_000 + }) + }) + + it('keeps a provider-reported failure a completed turn whose verdict says it failed', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_000 }) + rig.assembler.apply({ type: 'turn.end', at: 2_000, state: 'completed', outcome: 'failure' }) + expect(await rig.turn('turn-1')).toMatchObject({ state: 'completed', outcome: 'failure' }) + }) + + it('leaves the verdict absent when the provider gave none', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_000 }) + rig.assembler.apply({ type: 'turn.end', at: 2_000, state: 'completed' }) + const turn = await rig.turn('turn-1') + expect(turn?.state).toBe('completed') + expect(turn).not.toHaveProperty('outcome') + }) + + it('mints a turn id when the provider names none, and keys a turn no send opened by its own row', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', at: 1_000 }) + const turnId = rig.assembler.openTurnId + expect(turnId).toMatch(/^m:gen-1/) + const rows = await rig.rows() + const [row] = rows + expect(rows).toHaveLength(1) + expect(row?.body).toMatchObject({ kind: 'turn', turnId, userItemId: row?.itemId }) + }) + + it('names the open turn with a send that arrives after a provider-opened turn began', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_000 }) + rig.assembler.apply({ type: 'input.accepted', clientMessageId: 'send-1', requestedAt: 1_100 }) + // A second send folds into the turn; it does not replace the opener. + rig.assembler.apply({ type: 'input.accepted', clientMessageId: 'send-2', requestedAt: 1_200 }) + expect(await rig.turn('turn-1')).toMatchObject({ + userItemId: agentJournalSubmissionKey('send-1'), + requestedAt: 1_100 + }) + }) + + it('gives each queued send to the next turn that opens, in order', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'input.accepted', clientMessageId: 'send-1', requestedAt: 900 }) + rig.assembler.apply({ type: 'input.accepted', clientMessageId: 'send-2', requestedAt: 950 }) + rig.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_000 }) + rig.assembler.apply({ type: 'turn.end', at: 1_500, state: 'completed' }) + rig.assembler.apply({ type: 'turn.open', turn: 'turn-2', at: 2_000 }) + expect((await rig.turn('turn-1'))?.userItemId).toBe(agentJournalSubmissionKey('send-1')) + expect((await rig.turn('turn-2'))?.userItemId).toBe(agentJournalSubmissionKey('send-2')) + }) + + it('ends a still-open turn as superseded when the provider opens a different one', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_000 }) + rig.assembler.apply({ type: 'turn.open', turn: 'turn-2', at: 2_000 }) + expect(await rig.turn('turn-1')).toMatchObject({ + state: 'interrupted', + outcome: 'superseded', + completedAt: 2_000 + }) + expect(await rig.turn('turn-2')).toMatchObject({ state: 'running', startedAt: 2_000 }) + }) + + it('drops a repeated open, a repeated end, and an end for a turn it never opened', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_000 }) + expect(rig.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_100 }).dropped).toBe( + 'turn-duplicate' + ) + // An open naming no turn while one is open is the same turn, not a new one. + expect(rig.assembler.apply({ type: 'turn.open', at: 1_200 }).dropped).toBe('turn-duplicate') + expect( + rig.assembler.apply({ type: 'turn.end', turn: 'turn-9', at: 1_500, state: 'completed' }) + .dropped + ).toBe('turn-unknown') + rig.assembler.apply({ type: 'turn.end', at: 2_000, state: 'completed', outcome: 'success' }) + expect( + rig.assembler.apply({ type: 'turn.end', turn: 'turn-1', at: 3_000, state: 'interrupted' }) + .dropped + ).toBe('turn-unknown') + expect(rig.assembler.apply({ type: 'turn.end', at: 3_000, state: 'completed' }).dropped).toBe( + 'no-turn' + ) + // A replayed open of a settled turn neither reopens nor rewrites it. + expect(rig.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 4_000 }).dropped).toBe( + 'turn-replayed' + ) + expect(await rig.turn('turn-1')).toMatchObject({ + state: 'completed', + outcome: 'success', + startedAt: 1_000, + completedAt: 2_000 + }) + }) + + it('never rewrites a settled turn when a restarted host replays its history', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_000 }) + rig.assembler.apply({ type: 'turn.end', at: 2_000, state: 'completed', outcome: 'success' }) + await rig.rows() + + // A new host process has no memory of the turn; only the journal knows it ended. + const restarted = rig.restart({ generation: 'gen-2' }) + restarted.apply({ type: 'turn.open', turn: 'turn-1', at: 5_000 }) + restarted.apply({ type: 'turn.end', at: 6_000, state: 'interrupted', outcome: 'cancellation' }) + expect(await rig.turn('turn-1')).toEqual({ + turnId: providerTurnId('turn-1'), + state: 'completed', + outcome: 'success', + userItemId: providerTurnItemId('turn-1'), + startedAt: 1_000, + completedAt: 2_000 + }) + }) +}) diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler.ts new file mode 100644 index 000000000000..480e40c88bac --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler.ts @@ -0,0 +1,250 @@ +// Turns a provider adapter's grammar events (`provider-timeline-event.ts`) into the journal rows +// every structured lane writes. +// +// Division of lifecycle work: +// - The adapter translates its dialect, decides when a turn opens, and re-applies an event the sink +// refused (the lane runner holds it and pauses reading under backpressure). +// - The assembler plans each event as ONE sink transition. Which row each write lands on, whether +// a replay writes at all, and every change to what the assembler knows (its ledger) are decided +// in the transition's resolvers, at the event's turn in the journal's write queue, against the +// journal as it stands then. So there is one clock: a refused event changed nothing, a write the +// journal rejects changes nothing, and a restart or an evicted cache finds the same rows again. +// - Planning reads a forecast — the ledger plus what admitted events still in the queue will +// change — only to answer `apply` at once: dropped or not, over budget or not, the open turn. +// - The journal keeps what it owns: a person's Stop, a dead generation after a restart, and the +// answer compare-and-set. + +import type { AgentType } from '../../../shared/agent-session-journal-types' +import type { AgentSessionDeltaCoalescerDeps } from '../agent-session-wire/agent-session-delta-coalescer' +import type { StructuredAgentSessionSinkAdmission } from '../agent-session-wire/structured-agent-session-event-sink' +import type { StructuredAgentSessionTransitionJournal } from '../agent-session-wire/structured-agent-session-transition' +import type { + ProviderTimelineApplyHost, + ProviderTimelineApplyResult, + ProviderTimelineInFlight +} from './provider-timeline-apply-host' +import { + PROVIDER_TIMELINE_OVER_BUDGET, + providerTimelineBudgetAdmits, + type ProviderTimelineHold +} from './provider-timeline-budget' +import { providerTimelineLedger, type ProviderTimelineContext } from './provider-timeline-context' +import { + decideProviderTimelineEvent, + type ProviderTimelineDecidedEvent, + type ProviderTimelineDecision +} from './provider-timeline-decisions' +import type { ProviderTimelineEvent } from './provider-timeline-event' +import { + createLegacyProviderTimelineIdentityScheme, + type ProviderTimelineIdentityScheme +} from './provider-timeline-identity' +import { ProviderTimelineJoins } from './provider-timeline-joins' +import { ProviderTimelinePlan, type ProviderTimelineSink } from './provider-timeline-plan' +import { ProviderTimelineState } from './provider-timeline-state' +import { + applyProviderTimelineTextClose, + applyProviderTimelineTextDelta +} from './provider-timeline-text-events' +import { ProviderTimelineTextStreams } from './provider-timeline-text-streams' +import { + planProviderTimelineBarrier, + planProviderTimelineSlots +} from './provider-timeline-transition-layout' + +export type { ProviderTimelineApplyResult } from './provider-timeline-apply-host' +export type { ProviderTimelineDropRule } from './provider-timeline-decisions' + +export type ProviderTimelineAssembler = { + apply(event: ProviderTimelineEvent): ProviderTimelineApplyResult + /** The turn id of the open turn, as its row and a client's Stop name it. */ + readonly openTurnId: string | null + /** Writes the text the coalescing window holds. */ + flush(): void + dispose(): void +} + +export type ProviderTimelineAssemblerDeps = { + sink: ProviderTimelineSink + sessionId: string + agent: AgentType + /** The acquisition: minted keys are unique per generation. */ + generation: string + /** The provider session whose ids the adapter forwards; the same one again after a restart that + * re-attaches it, so its replays are recognised. */ + namespace: string + /** The session's own provider thread, for providers that run subagents on threads of their own. */ + ownThread?: () => string | null + /** Defaults to the shared `legacy` identity arm. */ + scheme?: ProviderTimelineIdentityScheme + coalesceMs?: number + schedule?: AgentSessionDeltaCoalescerDeps['schedule'] +} + +const ADMITTED: StructuredAgentSessionSinkAdmission = { accepted: true } + +export function createProviderTimelineAssembler( + deps: ProviderTimelineAssemblerDeps +): ProviderTimelineAssembler { + const scheme = + deps.scheme ?? + createLegacyProviderTimelineIdentityScheme({ agent: deps.agent, sessionId: deps.sessionId }) + let settlements = 0 + const context: ProviderTimelineContext = { + sessionId: deps.sessionId, + agent: deps.agent, + joins: new ProviderTimelineJoins({ + scheme, + generation: deps.generation, + namespace: deps.namespace + }), + ledger: new ProviderTimelineState(deps.namespace), + ...(deps.ownThread ? { ownThread: deps.ownThread } : {}), + settlementId: (what) => { + settlements += 1 + return `provider-timeline:${deps.sessionId}:${deps.generation}:${settlements}:${what}` + } + } + const streams = new ProviderTimelineTextStreams({ + sink: deps.sink, + context, + generation: deps.generation, + ...(deps.coalesceMs === undefined ? {} : { coalesceMs: deps.coalesceMs }), + ...(deps.schedule ? { schedule: deps.schedule } : {}) + }) + let forecast = context.ledger.clone() + const inFlight: ProviderTimelineInFlight[] = [] + + /** The forecast again: the ledger, plus what admitted events not yet run will change. */ + const resync = () => { + forecast = context.ledger.clone() + for (const entry of inFlight) { + if (!entry.executed) { + entry.commit?.(forecast) + } + } + } + + const submit = ( + plan: ProviderTimelinePlan, + entry: InFlight + ): StructuredAgentSessionSinkAdmission => { + if (!plan.writes) { + return plan.submit(deps.sink) + } + inFlight.push(entry) + const settle = () => { + const at = inFlight.indexOf(entry) + if (at !== -1) { + inFlight.splice(at, 1) + resync() + } + } + const admission = plan.submit(deps.sink, settle) + if (!admission.accepted) { + inFlight.splice(inFlight.indexOf(entry), 1) + return admission + } + entry.commit?.(forecast) + return admission + } + + const admits = (hold: ProviderTimelineHold): boolean => + providerTimelineBudgetAdmits({ + hold, + forecast, + ledger: context.ledger, + streams: streams.open, + journal: deps.sink.journalItems() + }) + + const host: ProviderTimelineApplyHost = { + context, + streams, + forecast: () => forecast, + journal: () => deps.sink.journalItems(), + admits, + submit + } + + const applyDecided = (event: ProviderTimelineDecidedEvent): ProviderTimelineApplyResult => { + const journal = deps.sink.journalItems() + const decision = decideProviderTimelineEvent( + { context, state: forecast, journal, execute: false }, + event + ) + if (decision.dropped) { + return { admission: ADMITTED, dropped: decision.dropped } + } + if (decision.hold && !admits(decision.hold)) { + return { admission: PROVIDER_TIMELINE_OVER_BUDGET } + } + const plan = new ProviderTimelinePlan() + planProviderTimelineBarrier(host, plan, event, decision) + const entry: ProviderTimelineInFlight = { executed: false, commit: decision.commit } + let executed: ProviderTimelineDecision | null = null + // The event's decision on the ledger, taken once, by its first slot to run. + const decide = (at: StructuredAgentSessionTransitionJournal) => { + if (!executed) { + const ledger = providerTimelineLedger(context, at) + executed = decideProviderTimelineEvent( + { context, state: ledger, journal: at, execute: true }, + event + ) + entry.executed = true + executed.commit?.(ledger) + } + return executed.dropped ? null : executed + } + planProviderTimelineSlots(host, plan, event, decide) + if ( + event.type === 'turn.open' || + event.type === 'turn.end' || + event.type === 'session.ended' || + event.type === 'session.reset' + ) { + plan.onAdmitted(() => deps.sink.setActivity?.(null)) + } + return { admission: submit(plan, entry) } + } + + const apply = (event: ProviderTimelineEvent): ProviderTimelineApplyResult => { + const journal = deps.sink.journalItems() + if (journal && !forecast.hydrated) { + forecast.hydrate(journal) + } + switch (event.type) { + case 'text.delta': + return applyProviderTimelineTextDelta(host, event) + case 'text.close': + return applyProviderTimelineTextClose(host, event) + case 'activity': { + const open = forecast.open + if (forecast.ended || !open) { + return { admission: ADMITTED, dropped: forecast.ended ? 'session-ended' : 'no-turn' } + } + deps.sink.setActivity?.( + event.text === null ? null : { turnId: open.turnId, text: event.text } + ) + return { admission: ADMITTED } + } + case 'turn.open': + return applyDecided( + event.turn === undefined ? { ...event, minted: context.joins.mint('t') } : event + ) + case 'provider.frame': + return applyDecided({ ...event, minted: context.joins.mint('f') }) + default: + return applyDecided(event) + } + } + + return { + apply, + get openTurnId() { + return forecast.open?.turnId ?? null + }, + flush: () => streams.flush(), + dispose: () => streams.dispose() + } +} diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-budget.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-budget.ts new file mode 100644 index 000000000000..864a193818fa --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-budget.ts @@ -0,0 +1,66 @@ +// One admission budget for everything the assembler holds open: running items, pending requests +// and open text streams. It is derived, not booked: the open set is the forecast's obligations +// plus the open streams, one entry per item (an item and its stream share its join reference) and +// one per request key whatever its incarnation. Before refusing, the set is re-derived from the +// journal, so an answer a client gave or a row another writer settled frees its room. Past the +// budget an event is refused as `failed`, which ends the session the way a failed journal write +// does, and the journal-derived dead-generation settlement closes whatever it left open. + +import { requiresTerminalSettlement } from '../agent-session-journal/journal-terminal-settlement' +import type { StructuredAgentSessionSinkAdmission } from '../agent-session-wire/structured-agent-session-event-sink' +import type { StructuredAgentSessionTransitionJournal } from '../agent-session-wire/structured-agent-session-transition' +import type { ProviderTimelineState } from './provider-timeline-state' + +export const MAX_PROVIDER_TIMELINE_OPEN_ENTRIES = 128 +export const MAX_PROVIDER_TIMELINE_OPEN_BYTES = 1024 * 1024 + +export const PROVIDER_TIMELINE_OVER_BUDGET: StructuredAgentSessionSinkAdmission = { + accepted: false, + reason: 'failed' +} + +export type ProviderTimelineHold = { key: string; bytes: number } + +export function providerTimelineBudgetAdmits(input: { + hold: ProviderTimelineHold + forecast: ProviderTimelineState + ledger: ProviderTimelineState + streams: readonly ProviderTimelineHold[] + journal: StructuredAgentSessionTransitionJournal | null +}): boolean { + if (fits(input)) { + return true + } + // The fold is never behind the ledger, so what it shows settled is settled in both. + for (const state of [input.forecast, input.ledger]) { + for (const [key, obligation] of state.obligations) { + const body = obligation.itemId ? input.journal?.itemBody(obligation.itemId) : null + if (body && !requiresTerminalSettlement(body)) { + state.obligations.delete(key) + } + } + } + return fits(input) +} + +function fits(input: { + hold: ProviderTimelineHold + forecast: ProviderTimelineState + streams: readonly ProviderTimelineHold[] +}): boolean { + const open = new Map() + for (const [key, obligation] of input.forecast.obligations) { + open.set(key, obligation.bytes) + } + for (const stream of input.streams) { + open.set(stream.key, Math.max(open.get(stream.key) ?? 0, stream.bytes)) + } + open.set(input.hold.key, Math.max(open.get(input.hold.key) ?? 0, input.hold.bytes)) + let bytes = 0 + for (const each of open.values()) { + bytes += each + } + return ( + open.size <= MAX_PROVIDER_TIMELINE_OPEN_ENTRIES && bytes <= MAX_PROVIDER_TIMELINE_OPEN_BYTES + ) +} diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-context.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-context.ts new file mode 100644 index 000000000000..b451973b6710 --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-context.ts @@ -0,0 +1,67 @@ +// What every planner and resolver of one assembler shares, and where a joined row goes. + +import type { + AgentJournalProducerLinkage, + AgentType +} from '../../../shared/agent-session-journal-types' +import type { StructuredAgentSessionTransitionJournal } from '../agent-session-wire/structured-agent-session-transition' +import type { ProviderTimelineJoin } from './provider-timeline-event' +import type { ProviderTimelineJoins, ProviderTimelinePlacement } from './provider-timeline-joins' +import type { ProviderTimelineState } from './provider-timeline-state' + +export type ProviderTimelineContext = { + sessionId: string + agent: AgentType + joins: ProviderTimelineJoins + /** The truth decisions run on; resolvers only. */ + ledger: ProviderTimelineState + /** The session's own provider thread; a join naming another thread is subagent work. */ + ownThread?: () => string | null + /** A settlement id no other settlement of this journal shares. */ + settlementId(what: string): string +} + +/** The ledger, with its open turn taken from the journal before its first decision runs. */ +export function providerTimelineLedger( + context: ProviderTimelineContext, + journal: StructuredAgentSessionTransitionJournal +): ProviderTimelineState { + if (!context.ledger.hydrated) { + context.ledger.hydrate(journal) + } + return context.ledger +} + +/** The turn a new row joins: the one the provider names (subagent work joins the open turn while + * keeping its own turn in its identity), else the open one, else none. */ +export function providerTimelinePlacement( + context: ProviderTimelineContext, + state: ProviderTimelineState, + join: ProviderTimelineJoin | undefined +): ProviderTimelinePlacement { + const thread = join?.thread ?? null + if (join?.turn === undefined) { + return { thread, turn: state.open?.address.key ?? null, scope: state.scope } + } + const key = { source: 'provider', value: join.turn } as const + const own = context.ownThread?.() ?? null + const subagent = thread !== null && own !== null && thread !== own + return { + thread, + turn: key, + scope: subagent + ? state.scope + : { kind: 'turn', turnItemId: context.joins.turn(key, state.namespace).itemId } + } +} + +/** What an entry the assembler holds open costs: every provider string it keeps, and its body. */ +export function providerTimelineEntryBytes( + key: string, + body?: unknown, + producer?: AgentJournalProducerLinkage +): number { + const measure = (value: unknown) => + value === undefined ? 0 : Buffer.byteLength(JSON.stringify(value) ?? '', 'utf8') + return Buffer.byteLength(key, 'utf8') + measure(body) + measure(producer) + 64 +} diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-decision.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-decision.ts new file mode 100644 index 000000000000..ae75f819f524 --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-decision.ts @@ -0,0 +1,102 @@ +// The shape of a decision on one non-text event, and the reads every rule shares. + +import type { + AgentJournalItemBody, + AgentJournalItemIdentity +} from '../../../shared/agent-session-journal-types' +import type { JournalLifecycleMutationInput } from '../agent-session-journal/journal-row-builders' +import type { StructuredAgentSessionItemAppendOptions } from '../agent-session-wire/structured-agent-session-event-sink' +import type { StructuredAgentSessionTransitionJournal } from '../agent-session-wire/structured-agent-session-transition' +import type { ProviderTimelineHold } from './provider-timeline-budget' +import type { ProviderTimelineContext } from './provider-timeline-context' +import type { ProviderTimelineEvent } from './provider-timeline-event' +import type { ProviderTimelineKey } from './provider-timeline-identity' +import type { ProviderTimelineTurnRef } from './provider-timeline-joins' +import { + providerTimelineSettlement, + type ProviderTimelineTurnEnd +} from './provider-timeline-settlement' +import type { ProviderTimelineState } from './provider-timeline-state' + +/** Why an event wrote nothing. Each is a grammar rule the adapter broke or a replay it repeated. */ +export type ProviderTimelineDropRule = + | 'session-ended' + | 'turn-duplicate' + | 'turn-replayed' + | 'turn-unknown' + | 'no-turn' + | 'item-settled' + | 'item-replayed' + | 'request-duplicate' + | 'request-replayed' + | 'request-unknown' + | 'stream-unknown' + | 'stream-mismatch' + +/** A non-text event with the keys planning minted for it, so execution names the same ones. */ + +/** A non-text event with the keys planning minted for it, so execution names the same ones. */ +export type ProviderTimelineDecidedEvent = Exclude< + ProviderTimelineEvent, + { type: 'text.delta' | 'text.close' | 'activity' } +> & { minted?: ProviderTimelineKey } + +export type ProviderTimelineResolvedWrite = { + identity: AgentJournalItemIdentity + body: AgentJournalItemBody + options?: StructuredAgentSessionItemAppendOptions +} + +export type ProviderTimelineDecision = { + dropped?: ProviderTimelineDropRule + /** What the event would hold open, for the budget. */ + hold?: ProviderTimelineHold + /** The event's change to what is known: the forecast's at admission, the ledger's at execution. */ + commit?: (state: ProviderTimelineState) => void + /** Execution only: the settlement step's mutations and the item step's write. */ + settle?: readonly JournalLifecycleMutationInput[] + write?: ProviderTimelineResolvedWrite | null + /** The item the event closes, whose stream it releases. */ + closes?: string +} + +export type ProviderTimelineDecisionInput = { + context: ProviderTimelineContext + state: ProviderTimelineState + journal: StructuredAgentSessionTransitionJournal | null + /** Execution: place rows and compute writes. Planning never does. */ + execute: boolean +} + +export const providerKey = (value: string): ProviderTimelineKey => ({ source: 'provider', value }) + +export function settledTool(body: AgentJournalItemBody | null): boolean { + return body?.kind === 'tool-call' && body.state !== 'running' +} + +export function runningTool(body: AgentJournalItemBody): boolean { + return body.kind === 'tool-call' && body.state === 'running' +} + +export function pendingPrompt(body: AgentJournalItemBody | null): boolean { + return ( + (body?.kind === 'approval' || body?.kind === 'question') && body.resolution.state === 'pending' + ) +} + +export function turnOf(scope: { kind: string; turnItemId?: string }): string | null { + return scope.kind === 'turn' ? (scope.turnItemId ?? null) : null +} + +/** A turn's journal-derived settlement, at execution. */ +export function settlementOf( + input: ProviderTimelineDecisionInput, + turn: ProviderTimelineTurnRef, + end: ProviderTimelineTurnEnd +): readonly JournalLifecycleMutationInput[] | undefined { + if (!input.execute || !input.journal) { + return undefined + } + const scope = { turnItemId: turn.itemId, outlivesTurn: input.state.outliving(turn.itemId) } + return providerTimelineSettlement(input.journal, scope, [turn], end) +} diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-decisions.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-decisions.ts new file mode 100644 index 000000000000..7f03e7267c00 --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-decisions.ts @@ -0,0 +1,76 @@ +// What each non-text event does: one rule set, run twice. +// +// Planning runs it on the forecast and the journal as it stands, to answer `apply` (dropped or +// not, and what it would hold open) without writing or allocating anything. Execution runs it +// again in the event's first resolver, on the ledger and the fold at that point in the journal's +// write queue: only then are rows placed, ordinals and incarnations taken, and the ledger changed. +// So when the two disagree — before bind, after a restart, behind a replay the journal rejects — +// the journal's answer is the one that lands, and the ledger follows it. + +import { + decideFrame, + decideItem, + decideRequest, + decideWithdrawal +} from './provider-timeline-item-decisions' +import type { + ProviderTimelineDecidedEvent, + ProviderTimelineDecision, + ProviderTimelineDecisionInput +} from './provider-timeline-decision' +import { + decideContextUsage, + decideInput, + decideSessionEnd, + decideTurnEnd, + decideTurnOpen +} from './provider-timeline-turn-decisions' + +export type { + ProviderTimelineDecidedEvent, + ProviderTimelineDecision, + ProviderTimelineDropRule +} from './provider-timeline-decision' + +export function decideProviderTimelineEvent( + input: ProviderTimelineDecisionInput, + event: ProviderTimelineDecidedEvent +): ProviderTimelineDecision { + if (input.state.ended && event.type !== 'session.reset') { + return { dropped: 'session-ended' } + } + switch (event.type) { + case 'input.accepted': + return decideInput(input, event) + case 'turn.open': + return decideTurnOpen(input, event) + case 'turn.end': + return decideTurnEnd(input, event) + case 'item.open': + case 'item.update': + case 'item.close': + return decideItem(input, event) + case 'request.open': + return decideRequest(input, event) + case 'request.withdrawn': + return decideWithdrawal(input, event) + case 'context.usage': + return decideContextUsage(input, event) + case 'provider.frame': + return decideFrame(input, event) + case 'session.ended': + return decideSessionEnd(input, event.verdict, (state) => { + state.ended = true + state.endSession() + }) + case 'session.reset': + // Nothing ended the old session's open work, so it is lost, not interrupted; the resolver + // finds nothing when an end already settled it. + return decideSessionEnd(input, { state: 'unverifiable' }, (state) => { + state.reset(event.namespace) + if (input.execute) { + input.context.joins.reset(event.namespace) + } + }) + } +} diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-event.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-event.ts new file mode 100644 index 000000000000..1a1ca6ad1746 --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-event.ts @@ -0,0 +1,128 @@ +// The narrow grammar a provider adapter speaks to the timeline assembler. +// +// The adapter knows the provider's dialect; the assembler knows the timeline. An adapter parses +// provider traffic into these semantic events and never writes a journal row or mints a journal +// identity: every key here is the provider's own id (a turn, a tool call, a message, a thread), +// or a name the adapter keeps stable for something the provider does not name. The assembler +// resolves those joins to rows, scopes rows to turns, and owns the turn and item rules in +// `agent-session-journal-types.ts`; the journal decides, at each write, what a replay changes. +// +// Rules an adapter can rely on: +// - Only `turn.open` opens a turn. Items, text and requests never do: one that arrives with no +// turn open and names none is written as a thread row. An adapter whose provider starts work on +// its own (a background wake, auto-compaction) decides that a turn began and says `turn.open` +// before that work's items. +// - `join.turn` attaches a row to the turn the provider says it belongs to, open or not. +// - A turn settles once, with only the provider's verdict; the journal refuses a second settlement +// and a replayed open, even after a restart. +// - An event the sink refused changed nothing. Re-apply the same event to retry it (after +// `backpressure`); `failed` and `closed` are final. +// - A provider item is (thread, id): the same id on another thread is another item. Text and +// full snapshots of one id are one row, and its close settles it for both. +// - `dropped` is the verdict when the event was applied. The journal has the last word when the +// event's writes run: a replay the journal already holds writes nothing even if it was taken. +// - After `session.ended` every event is dropped until `session.reset`. Reset whenever the +// provider's ids may repeat (a new process): its ids then name new rows. + +import type { AgentSessionContextUsage } from '../../../shared/agent-session-context-usage' +import type { + AgentJournalApprovalItem, + AgentJournalItemBody, + AgentJournalProducerLinkage, + AgentJournalQuestionItem, + AgentJournalTurnOutcome +} from '../../../shared/agent-session-journal-types' +import type { StructuredAgentSessionTurnVerdict } from '../agent-session-wire/structured-agent-session-stale-turn-verdict' + +/** Bodies an item event may carry. Turn rows are the assembler's; prompts travel as requests. */ +export type ProviderTimelineItemBody = Exclude< + AgentJournalItemBody, + { kind: 'turn' | 'approval' | 'question' } +> + +export type ProviderTimelineRequestBody = AgentJournalApprovalItem | AgentJournalQuestionItem + +/** `assistant` is reply text; `reasoning` is the model's visible thinking. */ +export type ProviderTimelineTextChannel = 'assistant' | 'reasoning' + +/** Where the provider says something belongs. */ +export type ProviderTimelineJoin = { + /** The provider thread it came from, when the provider runs several (a subagent's own thread). */ + thread?: string + /** The provider turn it belongs to. Absent: the turn open now. */ + turn?: string +} + +/** A streamed text item: one the provider names (the same item as item events with that id), or + * an anonymous stream that becomes a new message each time it starts. */ +export type ProviderTimelineTextItem = { id: string } | { stream: string } + +type Produced = { + /** The subagent that produced this, when not the session's own agent. Stamped on the row as is. */ + producer?: AgentJournalProducerLinkage +} + +type Joined = { join?: ProviderTimelineJoin } + +export type ProviderTimelineEvent = + /** Orca's send reached the provider. It names the open turn when nothing opened that one, else + * the next. `join.item`: the provider's id for its echo of the send, which takes that message's + * place among its turn's messages and writes no row (the send's own row is the bubble). */ + | { + type: 'input.accepted' + clientMessageId: string + requestedAt: number + join?: ProviderTimelineJoin & { item?: string } + } + /** A turn began. `turn` is the provider's turn id when it has one; the assembler mints one otherwise. */ + | { type: 'turn.open'; turn?: string; at: number } + /** The provider ended a turn. Absent `turn` means the open turn. */ + | { + type: 'turn.end' + turn?: string + at: number + state: 'completed' | 'interrupted' + /** The provider's own verdict. Absent means it gave none, which reads as unknown. */ + outcome?: AgentJournalTurnOutcome + /** The provider's own measured duration. */ + durationMs?: number + } + /** Work began. `outlivesTurn` keeps it open past its turn's end (a backgrounded task). */ + | ({ + type: 'item.open' + item: string + body: ProviderTimelineItemBody + outlivesTurn?: true + } & Produced & + Joined) + /** The item's whole current body. Content may be replaced; a settled tool never runs again. */ + | ({ type: 'item.update'; item: string; body: ProviderTimelineItemBody } & Produced & Joined) + /** The item's whole terminal body; it replaces any text streamed into the same item. */ + | ({ type: 'item.close'; item: string; body: ProviderTimelineItemBody } & Produced & Joined) + /** Streamed text. */ + | ({ + type: 'text.delta' + item: ProviderTimelineTextItem + channel: ProviderTimelineTextChannel + text: string + } & Produced & + Joined) + /** The stream ended; `text` is the provider's final text, else what streamed is kept. `join` + * names the same item its deltas named. */ + | ({ type: 'text.close'; item: ProviderTimelineTextItem; text?: string } & Joined) + /** The provider asked the user something and waits on the answer. */ + | ({ type: 'request.open'; request: string; body: ProviderTimelineRequestBody } & Produced & + Joined) + /** The provider stopped waiting for an answer it never got. */ + | { type: 'request.withdrawn'; request: string } + /** What the provider said about its context window: for `join.turn`, else the open turn, else the last. */ + | ({ type: 'context.usage'; usage: AgentSessionContextUsage } & Joined) + /** The live activity line for the open turn; null clears it. */ + | { type: 'activity'; text: string | null } + /** Provider traffic no typed event covers; it becomes the shared bounded fallback row. */ + | ({ type: 'provider.frame'; frameKind: string; payload: unknown } & Joined) + /** The provider child is gone. The verdict is what the host can prove about its end. */ + | { type: 'session.ended'; verdict: StructuredAgentSessionTurnVerdict } + /** A new provider session began; its ids live in `namespace`. Work still open is settled first, + * as lost (`unverifiable`), since nothing ended it. */ + | { type: 'session.reset'; namespace: string } diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-item-decisions.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-item-decisions.ts new file mode 100644 index 000000000000..90200b66bae7 --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-item-decisions.ts @@ -0,0 +1,196 @@ +// What item, request and frame events do, decided on the forecast and again on the ledger. + +import { requiresTerminalSettlement } from '../agent-session-journal/journal-terminal-settlement' +import { cancelledJournalPromptBody } from '../agent-session-journal/journal-prompt-body-bounds' +import { unhandledProviderFrameJournalItem } from '../agent-session-wire/unhandled-provider-frame' +import { providerTimelineEntryBytes, providerTimelinePlacement } from './provider-timeline-context' +import { + pendingPrompt, + providerKey, + runningTool, + settledTool, + turnOf, + type ProviderTimelineDecidedEvent, + type ProviderTimelineDecision, + type ProviderTimelineDecisionInput +} from './provider-timeline-decision' +import { providerTimelineItemClass, type ProviderTimelineKey } from './provider-timeline-identity' +import type { ProviderTimelineItemJoin } from './provider-timeline-joins' + +export function decideItem( + input: ProviderTimelineDecisionInput, + event: Extract +): ProviderTimelineDecision { + const { state, journal, context } = input + const change = + event.type === 'item.open' ? 'open' : event.type === 'item.update' ? 'update' : 'close' + const join: ProviderTimelineItemJoin = { + family: 'item', + key: providerKey(event.item), + thread: event.join?.thread ?? null + } + const ref = context.joins.reference(join, state.namespace) + const found = context.joins.find(join, journal, state.namespace) + const held = found ? (journal?.itemBody(found.itemId) ?? null) : null + // This run closed it: an open reopens the same row, a second close is a repeat. + const closed = state.closed.has(ref) + if (change === 'open' && !closed && held) { + return { dropped: 'item-replayed' } + } + if (change === 'update' && runningTool(event.body) && (closed || settledTool(held))) { + return { dropped: 'item-settled' } + } + if (change === 'close' && (closed || settledTool(held))) { + return { dropped: 'item-settled' } + } + const known = state.obligations.get(ref) + const outlivesTurn = + (event.type === 'item.open' && event.outlivesTurn === true) || known?.outlivesTurn === true + const obligation = change !== 'close' && (requiresTerminalSettlement(event.body) || outlivesTurn) + const row = + found ?? + (input.execute && journal + ? context.joins.place( + join, + providerTimelineItemClass(event.body), + providerTimelinePlacement(context, state, event.join), + journal + ) + : null) + const scope = row?.scope ?? providerTimelinePlacement(context, state, event.join).scope + const turnItemId = turnOf(scope) + const bytes = providerTimelineEntryBytes(event.item, event.body, event.producer) + return { + ...(obligation ? { hold: { key: ref, bytes } } : {}), + write: row && { + identity: row.identity, + body: event.body, + options: { + ...event.producer, + turnScope: row.scope, + ...(row.ref === undefined ? {} : { providerItemRef: row.ref }) + } + }, + ...(change === 'close' ? { closes: ref } : {}), + commit: (next) => { + if (obligation) { + next.obligations.set(ref, { itemId: row?.itemId ?? null, turnItemId, outlivesTurn, bytes }) + } else { + next.obligations.delete(ref) + } + if (change === 'close') { + next.closed.set(ref, turnItemId) + } else if (change === 'open') { + next.closed.delete(ref) + } + } + } +} + +export function requestRef(input: ProviderTimelineDecisionInput, key: ProviderTimelineKey): string { + return input.context.joins.reference( + { family: 'request', key, thread: null }, + input.state.namespace + ) +} + +export function decideRequest( + input: ProviderTimelineDecisionInput, + event: Extract +): ProviderTimelineDecision { + const { state, journal, context } = input + const key = providerKey(event.request) + const ref = requestRef(input, key) + const current = context.joins.request(key, journal, state.namespace) + const held = current ? (journal?.itemBody(current.itemId) ?? null) : null + // Pending in the journal, or opened here and not landed yet: the same request again. + if (pendingPrompt(held) || (state.obligations.has(ref) && held === null)) { + return { dropped: 'request-duplicate' } + } + const settledTurn = current ? turnOf(current.scope) : null + if ( + held && + settledTurn !== null && + state.status({ itemId: settledTurn }, journal) === 'settled' + ) { + return { dropped: 'request-replayed' } + } + const placement = providerTimelinePlacement(context, state, event.join) + const row = + input.execute && journal + ? context.joins.nextRequest(key, event.body.kind, placement, journal) + : null + const bytes = providerTimelineEntryBytes(event.request, event.body, event.producer) + return { + hold: { key: ref, bytes }, + // A row already there is a client's answer or a replay; neither is overwritten. + write: + row && journal?.itemBody(row.itemId) === null + ? { + identity: row.identity, + body: event.body, + options: { ...event.producer, turnScope: row.scope, lifecycle: true } + } + : null, + commit: (next) => + next.obligations.set(ref, { + itemId: row?.itemId ?? null, + turnItemId: turnOf(row?.scope ?? placement.scope), + outlivesTurn: false, + bytes + }) + } +} + +export function decideWithdrawal( + input: ProviderTimelineDecisionInput, + event: Extract +): ProviderTimelineDecision { + const { state, journal } = input + const key = providerKey(event.request) + const ref = requestRef(input, key) + // The latest incarnation the journal holds, however long ago this run (or another) opened it. + const current = input.context.joins.request(key, journal, state.namespace) + const held = current ? (journal?.itemBody(current.itemId) ?? null) : null + if (!pendingPrompt(held) && !(state.obligations.has(ref) && held === null)) { + return { dropped: 'request-unknown' } + } + // Only while the journal holds it pending: a client's answer that landed first stands. + const cancelled = current && held && pendingPrompt(held) ? cancelledJournalPromptBody(held) : null + return { + ...(input.execute + ? { + settle: + current && cancelled + ? [ + { + kind: 'item' as const, + identity: current.identity, + body: cancelled, + turnScope: current.scope + } + ] + : [] + } + : {}), + commit: (next) => next.obligations.delete(ref) + } +} + +export function decideFrame( + input: ProviderTimelineDecisionInput, + event: Extract +): ProviderTimelineDecision { + const { state, journal, context } = input + const frame = unhandledProviderFrameJournalItem(context.agent, event.frameKind, event.payload) + if (!frame || !event.minted || !input.execute || !journal) { + return {} + } + const row = context.joins.place( + { family: 'frame', key: event.minted, thread: event.join?.thread ?? null }, + 'frame', + providerTimelinePlacement(context, state, event.join), + journal + ) + return { write: { identity: row.identity, body: frame.body, options: { turnScope: row.scope } } } +} diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-plan.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-plan.ts new file mode 100644 index 000000000000..44dec71dc576 --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-plan.ts @@ -0,0 +1,144 @@ +// One grammar event's journal writes, admitted as one sink transition. +// +// Planning lays out the writes the event may need and changes nothing. What each write says, and +// which row it lands on, is decided by its resolver when the transition reaches its turn in the +// journal's write queue; those resolvers are the only code that changes the assembler's ledger. +// `onAdmitted` work (the forecast, text marked written) runs only when the sink takes the +// transition, so a refused event leaves the assembler exactly as it was and re-applying it is the +// retry. + +import type { AgentSessionTurnActivity } from '../../../shared/agent-session-wire' +import type { + StructuredAgentSessionEventSink, + StructuredAgentSessionSinkAdmission +} from '../agent-session-wire/structured-agent-session-event-sink' +import type { + StructuredAgentSessionTransitionJournal, + StructuredAgentSessionTransitionStep +} from '../agent-session-wire/structured-agent-session-transition' + +/** The sink calls the assembler needs: one-operation transitions, and the journal as it stands. */ +export type ProviderTimelineSink = Required< + Pick +> & + Pick + +type ItemStep = Extract +type SettlementStep = Extract + +const ADMITTED: StructuredAgentSessionSinkAdmission = { accepted: true } + +export class ProviderTimelinePlan { + private readonly steps: StructuredAgentSessionTransitionStep[] = [] + private readonly admitted: (() => void)[] = [] + private readonly executions: ((journal: StructuredAgentSessionTransitionJournal) => void)[] = [] + private lifecycle = false + private refusal: StructuredAgentSessionSinkAdmission | null = null + + item(step: Omit, lifecycle = false): void { + this.steps.push({ kind: 'item', ...step }) + this.lifecycle ||= lifecycle + } + + settlement(step: Omit): void { + this.steps.push({ kind: 'settlement', ...step }) + this.lifecycle = true + } + + /** Runs once the sink takes the transition. */ + onAdmitted(change: () => void): void { + this.admitted.push(change) + } + + /** Ledger work that rides the transition's place in the queue, after its last step resolved. */ + atExecution(change: (journal: StructuredAgentSessionTransitionJournal) => void): void { + this.executions.push(change) + } + + /** The event cannot be taken at all (over budget); nothing is submitted. */ + refuse(admission: StructuredAgentSessionSinkAdmission): void { + this.refusal ??= admission + } + + /** Whether submitting sends a transition (and so `landed` will be heard). */ + get writes(): boolean { + return this.steps.length > 0 || this.executions.length > 0 + } + + submit(sink: ProviderTimelineSink, landed?: () => void): StructuredAgentSessionSinkAdmission { + if (this.refusal) { + return this.refusal + } + if (this.executions.length > 0 && this.steps.length === 0) { + this.item({ + reservedBytes: 0, + resolve: () => null, + options: { turnScope: { kind: 'thread' } } + }) + } + if (this.steps.length > 0) { + const admission = sink.tryAppendTransition({ + steps: this.withExecutions(), + lifecycle: this.lifecycle, + publish: true, + ...(landed ? { landed: () => landed() } : {}) + }) + if (!admission.accepted) { + return admission + } + } + for (const change of this.admitted) { + change() + } + return ADMITTED + } + + private withExecutions(): StructuredAgentSessionTransitionStep[] { + const last = this.steps.at(-1) + if (!last || this.executions.length === 0) { + return this.steps + } + const executions = this.executions + const finish = (journal: StructuredAgentSessionTransitionJournal) => + executions.forEach((change) => change(journal)) + const wrapped: StructuredAgentSessionTransitionStep = + last.kind === 'item' + ? { + ...last, + resolve: (journal) => { + const resolved = last.resolve(journal) + finish(journal) + return resolved + } + } + : { + ...last, + resolve: (journal) => { + const resolved = last.resolve(journal) + finish(journal) + return resolved + } + } + return [...this.steps.slice(0, -1), wrapped] + } +} + +/** The sink as the assembler needs it; null for a sink without transitions or a journal view. */ +export function providerTimelineSink( + sink: StructuredAgentSessionEventSink +): ProviderTimelineSink | null { + const { tryAppendTransition, journalItems, setActivity } = sink + if (!tryAppendTransition || !journalItems) { + return null + } + return { + tryAppendTransition: (transition) => tryAppendTransition.call(sink, transition), + journalItems: () => journalItems.call(sink), + ...(setActivity + ? { + setActivity: (activity: AgentSessionTurnActivity | null) => + setActivity.call(sink, activity) + } + : {}) + } +} diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-settlement.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-settlement.ts new file mode 100644 index 000000000000..390160e99e1a --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-settlement.ts @@ -0,0 +1,147 @@ +// What a turn's end or the session's end settles, read from the journal when the settlement runs. +// +// The same mechanism the dead-generation settlement uses after a restart: every row still waiting +// on the row that settles it — a running tool call, a pending prompt — is settled from its body as +// the journal holds it then. Nothing about open work is trusted from memory, so a prompt a client +// answered a moment earlier stays answered, a row a previous assembler opened is settled too, and +// a second settlement finds nothing left to do. + +import { parseAgentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalItemBody, + type AgentJournalItemIdentity, + type AgentJournalTurnLifecycle +} from '../../../shared/agent-session-journal-types' +import { + agentJournalTurnBody, + readAgentJournalTurn +} from '../../../shared/agent-session-turn-record' +import { cancelledJournalPromptBody } from '../agent-session-journal/journal-prompt-body-bounds' +import type { JournalLifecycleMutationInput } from '../agent-session-journal/journal-row-builders' +import type { StructuredAgentSessionTransitionJournal } from '../agent-session-wire/structured-agent-session-transition' +import { + agentJournalTurnRowReservedBytes, + resolveAgentJournalTurnRowWrite +} from './agent-journal-turn-row-revision' + +/** How a turn ended: the provider's report, or what the host could prove when the child went. */ +export type ProviderTimelineTurnEnd = + | { + state: 'completed' | 'interrupted' + completedAt: number + outcome?: AgentJournalTurnLifecycle['outcome'] + durationMs?: number + } + | { state: 'unverifiable' } + +/** What a row still open when its turn or the session ends becomes; null when it already stands. */ +export function settledProviderTimelineBody( + body: AgentJournalItemBody +): AgentJournalItemBody | null { + if (body.kind === 'tool-call') { + return body.state === 'running' ? { ...body, state: 'failed' } : null + } + if (body.kind === 'approval' || body.kind === 'question') { + return body.resolution.state === 'pending' ? cancelledJournalPromptBody(body) : null + } + return null +} + +/** The end owns the turn's terminal fields; `unverifiable` carries no end and no verdict. Fields + * keep the order every lane writes them in, so the row is the same bytes whoever ends it. */ +export function endedProviderTimelineTurn( + running: AgentJournalTurnLifecycle, + end: ProviderTimelineTurnEnd +): AgentJournalTurnLifecycle { + const { turnId, userItemId, startedAt, requestedAt } = running + const opened = { + ...(userItemId !== undefined ? { userItemId } : {}), + ...(startedAt !== undefined ? { startedAt } : {}), + ...(requestedAt !== undefined ? { requestedAt } : {}) + } + if (end.state === 'unverifiable') { + return { turnId, state: 'unverifiable', ...opened } + } + return { + turnId, + state: end.state, + ...(end.outcome !== undefined ? { outcome: end.outcome } : {}), + ...opened, + completedAt: end.completedAt, + ...(end.durationMs !== undefined ? { durationMs: end.durationMs } : {}) + } +} + +/** Which open rows a settlement covers: one turn's, or every row when the session ends. */ +export type ProviderTimelineSettlementScope = + | { turnItemId: string; outlivesTurn: ReadonlySet } + | 'session' + +/** The settled rows of `scope`, then each listed turn's end while the journal holds it running. */ +export function providerTimelineSettlement( + journal: StructuredAgentSessionTransitionJournal, + scope: ProviderTimelineSettlementScope, + turns: readonly { identity: AgentJournalItemIdentity; itemId: string }[], + end: ProviderTimelineTurnEnd +): JournalLifecycleMutationInput[] { + const mutations: JournalLifecycleMutationInput[] = [] + journal.visitItemsWithLinkage((itemId, _sequence, body, attribution) => { + const turnScope = attribution.turnScope ?? AGENT_JOURNAL_THREAD_SCOPE + const covered = + scope === 'session' || + (turnScope.kind === 'turn' && + turnScope.turnItemId === scope.turnItemId && + !scope.outlivesTurn.has(itemId)) + const settled = covered ? settledProviderTimelineBody(body) : null + const identity = settled ? parseAgentJournalItemKey(itemId) : null + if (settled && identity) { + // No linkage: a revision keeps the row's own producer. + mutations.push({ kind: 'item', identity, body: settled, turnScope }) + } + }) + for (const turn of turns) { + const ended = endedTurnRow(journal, turn, end) + if (ended) { + mutations.push({ kind: 'item', ...ended, turnScope: AGENT_JOURNAL_THREAD_SCOPE }) + } + } + return mutations +} + +/** Every turn row the journal holds running, for the session's end. */ +export function runningProviderTimelineTurns( + journal: StructuredAgentSessionTransitionJournal +): { identity: AgentJournalItemIdentity; itemId: string }[] { + const running: { identity: AgentJournalItemIdentity; itemId: string }[] = [] + journal.visitItems((itemId, _sequence, body) => { + const identity = + readAgentJournalTurn(body)?.state === 'running' ? parseAgentJournalItemKey(itemId) : null + if (identity) { + running.push({ identity, itemId }) + } + }) + return running +} + +function endedTurnRow( + journal: StructuredAgentSessionTransitionJournal, + turn: { identity: AgentJournalItemIdentity; itemId: string }, + end: ProviderTimelineTurnEnd +): { identity: AgentJournalItemIdentity; body: AgentJournalItemBody } | null { + const running = readAgentJournalTurn(journal.itemBody(turn.itemId) ?? undefined) + if (running?.state !== 'running') { + return null + } + const target = { identity: turn.identity } + const write = { + lifecycle: agentJournalTurnBody(endedProviderTimelineTurn(running, end)), + onlyWhileRunning: true as const + } + return resolveAgentJournalTurnRowWrite( + journal, + target, + write, + agentJournalTurnRowReservedBytes(target, write) + ) +} diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-state.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-state.ts new file mode 100644 index 000000000000..d3daf3ef7056 --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-state.ts @@ -0,0 +1,172 @@ +// What the assembler knows about the session, held twice. +// +// The ledger is the truth the decisions run on: it changes only inside transition resolvers, at +// each event's turn in the journal's write queue, and everything in it is either a cache of the +// journal (re-derived from the fold when missing) or this run's own memory (items it closed, the +// sends waiting for a turn). The forecast is a copy plus what admitted events still in the queue +// are expected to change; it only answers `apply` before those events land, and is rebuilt from +// the ledger as they do. + +import { parseAgentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalTurnLifecycle, + type AgentJournalTurnScope +} from '../../../shared/agent-session-journal-types' +import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' +import { BoundedMap } from '../../../shared/bounded-map' +import type { StructuredAgentSessionTransitionJournal } from '../agent-session-wire/structured-agent-session-transition' +import type { ProviderTimelineTurnRef } from './provider-timeline-joins' + +/** Closed items remembered so a repeat close is dropped and an open reopens; past this, the + * journal decides from the row alone. */ +const MAX_CLOSED_ITEMS = 512 +/** Turns remembered as settled without asking the journal. */ +const MAX_SETTLED_TURNS = 256 +/** Sends waiting for a turn; a provider that never opens one cannot grow this without bound. */ +export const MAX_PENDING_INPUTS = 64 + +export type ProviderTimelineOpenTurn = ProviderTimelineTurnRef & { + running: AgentJournalTurnLifecycle +} + +export type ProviderTimelinePendingInput = { clientMessageId: string; requestedAt: number } + +/** Work still waiting on a row that settles it: a running tool, a backgrounded task, a pending + * request. Keyed by the item's join reference (a request's by its key, whatever its incarnation). */ +export type ProviderTimelineObligation = { + /** The row, once a resolver placed it. */ + itemId: string | null + turnItemId: string | null + outlivesTurn: boolean + bytes: number +} + +export type ProviderTimelineTurnStatus = 'absent' | 'running' | 'settled' + +export class ProviderTimelineState { + ended = false + open: ProviderTimelineOpenTurn | null = null + latest: ProviderTimelineTurnRef | null = null + inputs: ProviderTimelinePendingInput[] = [] + /** Items this run closed, with the turn they closed in. */ + closed = new BoundedMap({ maxEntries: MAX_CLOSED_ITEMS }) + settled = new BoundedMap({ maxEntries: MAX_SETTLED_TURNS }) + obligations = new Map() + /** Whether the open turn was taken from the journal yet (ledger only). */ + hydrated = false + + constructor(public namespace: string) {} + + get scope(): AgentJournalTurnScope { + return this.open ? { kind: 'turn', turnItemId: this.open.itemId } : AGENT_JOURNAL_THREAD_SCOPE + } + + clone(): ProviderTimelineState { + const copy = new ProviderTimelineState(this.namespace) + copy.ended = this.ended + copy.open = this.open && { ...this.open } + copy.latest = this.latest + copy.inputs = [...this.inputs] + for (const [key, value] of this.closed.entries()) { + copy.closed.set(key, value) + } + for (const [key, value] of this.settled.entries()) { + copy.settled.set(key, value) + } + copy.obligations = new Map(this.obligations) + copy.hydrated = this.hydrated + return copy + } + + status( + turn: { itemId: string }, + journal: StructuredAgentSessionTransitionJournal | null + ): ProviderTimelineTurnStatus { + if (this.open?.itemId === turn.itemId) { + return 'running' + } + if (this.settled.has(turn.itemId)) { + return 'settled' + } + const row = readAgentJournalTurn(journal?.itemBody(turn.itemId) ?? undefined) + return row === null ? 'absent' : row.state === 'running' ? 'running' : 'settled' + } + + /** The obligations a turn's end leaves open: the ones that outlive it. */ + outliving(turnItemId: string): Set { + const outliving = new Set() + for (const obligation of this.obligations.values()) { + if (obligation.outlivesTurn && obligation.itemId && obligation.turnItemId === turnItemId) { + outliving.add(obligation.itemId) + } + } + return outliving + } + + /** The turn is over, and so is everything its settlement covered. */ + endTurn(turn: ProviderTimelineTurnRef): void { + if (this.open?.itemId === turn.itemId) { + this.open = null + } + this.latest = turn + this.settled.set(turn.itemId, true) + for (const [key, obligation] of this.obligations) { + if (obligation.turnItemId === turn.itemId && !obligation.outlivesTurn) { + this.obligations.delete(key) + } + } + // A settled turn's rows are judged by the turn now. + for (const [key, turnItemId] of this.closed.entries()) { + if (turnItemId === turn.itemId) { + this.closed.delete(key) + } + } + } + + /** The session is over: nothing it held is open any more. */ + endSession(): void { + this.open = null + this.obligations.clear() + this.closed.clear() + } + + reset(namespace: string): void { + this.namespace = namespace + this.ended = false + this.open = null + this.latest = null + this.inputs = [] + this.obligations.clear() + this.closed.clear() + this.settled.clear() + } + + /** Takes the open turn from the journal: the newest turn row it holds running. Its key is the + * turn id the row carries, which is the provider's own turn id for a scheme that spells it. */ + hydrate(journal: StructuredAgentSessionTransitionJournal): void { + this.hydrated = true + if (this.open) { + return + } + let newest = -1 + journal.visitItems((itemId, sequence, body) => { + const running = readAgentJournalTurn(body) + const identity = running?.state === 'running' ? parseAgentJournalItemKey(itemId) : null + if (!running || !identity || sequence < newest) { + return + } + newest = sequence + this.open = { + address: { + namespace: this.namespace, + key: { source: 'provider', value: running.turnId } + }, + identity, + itemId, + turnId: running.turnId, + running + } + }) + } +} diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-text-events.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-text-events.ts new file mode 100644 index 000000000000..8b793ef2139f --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-text-events.ts @@ -0,0 +1,105 @@ +// Text events: planning-side streams, admitted like every other event. + +import type { StructuredAgentSessionTransitionJournal } from '../agent-session-wire/structured-agent-session-transition' +import type { StructuredAgentSessionSinkAdmission } from '../agent-session-wire/structured-agent-session-event-sink' +import type { + ProviderTimelineApplyHost, + ProviderTimelineApplyResult, + ProviderTimelineInFlight +} from './provider-timeline-apply-host' +import { PROVIDER_TIMELINE_OVER_BUDGET } from './provider-timeline-budget' +import type { ProviderTimelineEvent } from './provider-timeline-event' +import { ProviderTimelinePlan } from './provider-timeline-plan' + +const ADMITTED: StructuredAgentSessionSinkAdmission = { accepted: true } + +export function applyProviderTimelineTextDelta( + host: ProviderTimelineApplyHost, + event: Extract +): ProviderTimelineApplyResult { + const { streams } = host + const forecast = host.forecast() + if (forecast.ended) { + return { admission: ADMITTED, dropped: 'session-ended' } + } + const journal = host.journal() + const plan = new ProviderTimelinePlan() + let stream = streams.get(streams.key(event.item, event.join, forecast)) + if (stream && !streams.continues(stream, event.channel, event.producer)) { + if (stream.named) { + return { admission: ADMITTED, dropped: 'stream-mismatch' } + } + // The anonymous stream's next message: what the last one owes lands first. + streams.planFlush(plan) + const ended = stream + streams.planRelease(plan, (each) => each === ended) + stream = undefined + } + if (!stream) { + if ('id' in event.item && settledNamedItem(host, event, journal)) { + return { admission: ADMITTED, dropped: 'item-settled' } + } + stream = streams.start({ + item: event.item, + join: event.join, + channel: event.channel, + producer: event.producer, + state: forecast + }) + if (!host.admits({ key: stream.key, bytes: stream.bytes })) { + return { admission: PROVIDER_TIMELINE_OVER_BUDGET } + } + } + streams.planAppend(plan, stream, event.text) + return { admission: host.submit(plan, { executed: false, commit: undefined }) } +} + +/** A named delta for an item this run closed, or one the journal holds in a settled turn. */ +function settledNamedItem( + host: ProviderTimelineApplyHost, + event: Extract, + journal: StructuredAgentSessionTransitionJournal | null +): boolean { + const { context } = host + const forecast = host.forecast() + if (!('id' in event.item)) { + return false + } + const join = { + family: 'item' as const, + key: { source: 'provider' as const, value: event.item.id }, + thread: event.join?.thread ?? null + } + if (forecast.closed.has(context.joins.reference(join, forecast.namespace))) { + return true + } + const row = context.joins.find(join, journal, forecast.namespace) + const turnItemId = row?.scope.kind === 'turn' ? row.scope.turnItemId : null + return turnItemId !== null && forecast.status({ itemId: turnItemId }, journal) === 'settled' +} + +export function applyProviderTimelineTextClose( + host: ProviderTimelineApplyHost, + event: Extract +): ProviderTimelineApplyResult { + const { streams } = host + const forecast = host.forecast() + if (forecast.ended) { + return { admission: ADMITTED, dropped: 'session-ended' } + } + const stream = streams.get(streams.key(event.item, event.join, forecast)) + if (!stream) { + return { admission: ADMITTED, dropped: 'stream-unknown' } + } + const plan = new ProviderTimelinePlan() + streams.planFlush(plan, stream) + streams.planClose(plan, stream, event.text) + const entry: ProviderTimelineInFlight = { + executed: false, + commit: stream.named ? (state) => state.closed.set(stream.key, stream.turnItemId) : undefined + } + plan.atExecution(() => { + entry.executed = true + }) + return { admission: host.submit(plan, entry) } +} diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-text-streams.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-text-streams.ts new file mode 100644 index 000000000000..90dc99135631 --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-text-streams.ts @@ -0,0 +1,360 @@ +// Streamed text, as message rows. +// +// A named stream is the item with that id on its thread, so its deltas, its close and any full +// snapshot of it are one row. An anonymous stream becomes a new message each time it starts, and +// its identity is its name, thread, channel and producer: a change to any starts the next +// message. Deltas accumulate in the shared coalescer, and a row is a snapshot of the text so far. +// Text owed to the journal is written inside the next event's transition — every other event is +// an ordering barrier — or by the coalescer's window, and is marked written once the sink takes it. +// +// Which row a stream writes is decided when its first write runs, like every other row: a +// provider-named message the journal already holds is a message this run is resuming (its turn +// still running: the stream adopts the row's text as its prefix) or a replay (its turn settled, or +// this run closed it: nothing is written). + +import type { + AgentJournalItemBody, + AgentJournalProducerLinkage +} from '../../../shared/agent-session-journal-types' +import { + boundInlineText, + DEFAULT_JOURNAL_PAYLOAD_LIMITS +} from '../agent-session-journal/journal-payload-bounds' +import { + createAgentSessionDeltaCoalescer, + type AgentSessionDeltaCoalescerDeps +} from '../agent-session-wire/agent-session-delta-coalescer' +import { estimateStructuredAgentSessionItemBytes } from '../agent-session-wire/structured-agent-session-event-sink-estimate' +import type { + StructuredAgentSessionItemAppendOptions, + StructuredAgentSessionSinkAdmission +} from '../agent-session-wire/structured-agent-session-event-sink' +import type { StructuredAgentSessionTransitionJournal } from '../agent-session-wire/structured-agent-session-transition' +import { + providerTimelineEntryBytes, + providerTimelineLedger, + providerTimelinePlacement, + type ProviderTimelineContext +} from './provider-timeline-context' +import type { + ProviderTimelineJoin, + ProviderTimelineTextChannel, + ProviderTimelineTextItem +} from './provider-timeline-event' +import { providerTimelineKeyPart } from './provider-timeline-identity' +import type { ProviderTimelineItemJoin, ProviderTimelineRow } from './provider-timeline-joins' +import { ProviderTimelinePlan, type ProviderTimelineSink } from './provider-timeline-plan' +import type { ProviderTimelineState } from './provider-timeline-state' + +/** One open text stream, as planning knows it. */ +export type ProviderTimelineStream = { + /** Unique per stream: the coalescer's key, and the ledger's for the row it owns. */ + id: string + /** The stream's slot: a named stream's is its item's join reference, which the budget shares. */ + key: string + join: ProviderTimelineItemJoin + /** The turn the provider named, if any. */ + turn: string | undefined + /** The turn planning expects its row in, so a turn's end releases it. */ + turnItemId: string | null + named: boolean + channel: ProviderTimelineTextChannel + producer: AgentJournalProducerLinkage | undefined + /** Whether any text was written; a whitespace-only stream completes nothing. */ + written: boolean + bytes: number +} + +/** The row a stream writes, and the text it held before this run streamed into it. */ +type Owned = { row: ProviderTimelineRow; prefix: string } | 'replayed' + +type Resolved = { + identity: ProviderTimelineRow['identity'] + body: AgentJournalItemBody + options: StructuredAgentSessionItemAppendOptions +} + +export function providerTimelineMessageText(body: AgentJournalItemBody | null): string { + return body?.kind === 'message' && body.blocks[0]?.type === 'text' ? body.blocks[0].text : '' +} + +export class ProviderTimelineTextStreams { + private readonly streams = new Map() + private readonly byId = new Map() + /** Ledger: the row each stream writes, decided by its first write. */ + private readonly owned = new Map() + private readonly coalescer + private serial = 0 + + constructor( + private readonly deps: { + sink: ProviderTimelineSink + context: ProviderTimelineContext + generation: string + coalesceMs?: number + schedule?: AgentSessionDeltaCoalescerDeps['schedule'] + } + ) { + this.coalescer = createAgentSessionDeltaCoalescer({ + ...(deps.coalesceMs === undefined ? {} : { windowMs: deps.coalesceMs }), + ...(deps.schedule ? { schedule: deps.schedule } : {}), + // Every stream here is one this assembler holds open; the shared budget counts them. + isProtected: () => true, + emit: (id, text) => this.flushOnWindow(id, text) + }) + } + + /** What the budget counts: one entry per open stream. */ + get open(): readonly ProviderTimelineStream[] { + return [...this.streams.values()] + } + + /** The stream slot `item` names on its thread. */ + key( + item: ProviderTimelineTextItem, + join: ProviderTimelineJoin | undefined, + state: ProviderTimelineState + ): string { + return 'id' in item + ? this.deps.context.joins.reference(this.itemJoin(item, join), state.namespace) + : `stream:${providerTimelineKeyPart(join?.thread ?? '')}:${providerTimelineKeyPart(item.stream)}` + } + + get(key: string): ProviderTimelineStream | undefined { + return this.streams.get(key) + } + + /** A new stream; the caller has checked the budget for its `bytes`. */ + start(input: { + item: ProviderTimelineTextItem + join: ProviderTimelineJoin | undefined + channel: ProviderTimelineTextChannel + producer: AgentJournalProducerLinkage | undefined + state: ProviderTimelineState + }): ProviderTimelineStream { + this.serial += 1 + const named = 'id' in input.item + const join = named ? this.itemJoin(input.item, input.join) : this.anonymousJoin(input.join) + const placement = providerTimelinePlacement(this.deps.context, input.state, input.join) + return { + id: `${this.deps.generation}:s${this.serial}`, + key: this.key(input.item, input.join, input.state), + join, + turn: input.join?.turn, + turnItemId: placement.scope.kind === 'turn' ? placement.scope.turnItemId : null, + named, + channel: input.channel, + producer: input.producer, + written: false, + bytes: providerTimelineEntryBytes( + 'id' in input.item ? input.item.id : input.item.stream, + undefined, + input.producer + ) + } + } + + /** Whether a delta on `stream` continues it or begins another message. */ + continues( + stream: ProviderTimelineStream, + channel: ProviderTimelineTextChannel, + producer: AgentJournalProducerLinkage | undefined + ): boolean { + return stream.channel === channel && stream.producer?.agentId === producer?.agentId + } + + /** The delta's text; the stream opens when the event is admitted. */ + planAppend(plan: ProviderTimelinePlan, stream: ProviderTimelineStream, text: string): void { + plan.onAdmitted(() => { + if (!this.byId.has(stream.id)) { + this.streams.set(stream.key, stream) + this.byId.set(stream.id, stream) + } + this.coalescer.append(stream.id, text) + }) + } + + /** Every stream's unwritten text, ahead of whatever the event writes. */ + planFlush(plan: ProviderTimelinePlan, except?: ProviderTimelineStream): void { + for (const { key, snapshot } of this.coalescer.dirty()) { + const stream = this.byId.get(key) + if (stream && stream !== except) { + this.planText(plan, stream, snapshot.text) + } + } + } + + /** Ends the streams `which` selects; their text was flushed by the same event. */ + planRelease( + plan: ProviderTimelinePlan, + which: (stream: ProviderTimelineStream) => boolean + ): void { + const released = [...this.streams.values()].filter(which) + if (released.length === 0) { + return + } + plan.onAdmitted(() => released.forEach((stream) => this.forget(stream))) + plan.atExecution(() => released.forEach((stream) => this.owned.delete(stream.id))) + } + + /** Ends one stream with the provider's final text, else what streamed; it settles the item. */ + planClose(plan: ProviderTimelinePlan, stream: ProviderTimelineStream, finalText?: string): void { + if (finalText === undefined) { + const owed = this.coalescer.dirty().find(({ key }) => key === stream.id) + if (owed) { + this.planText(plan, stream, owed.snapshot.text) + } + } else { + this.planText( + plan, + stream, + boundInlineText(finalText, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text, + true + ) + } + this.planRelease(plan, (each) => each === stream) + if (stream.named) { + // The close settles the item for full snapshots too. + plan.atExecution(() => { + const owned = this.owned.get(stream.id) + const row = owned && owned !== 'replayed' ? owned.row : null + this.deps.context.ledger.closed.set( + stream.key, + row?.scope.kind === 'turn' ? row.scope.turnItemId : null + ) + }) + } + } + + flush(): void { + this.coalescer.flushAll() + } + + dispose(): void { + this.open.forEach((stream) => this.forget(stream)) + this.owned.clear() + this.coalescer.dispose() + } + + /** The window's flush: false keeps the text for a retry, but only while the sink is merely full. */ + private flushOnWindow(id: string, text: string): boolean { + const stream = this.byId.get(id) + if (!stream) { + return true + } + const plan = new ProviderTimelinePlan() + this.planText(plan, stream, text) + const admission: StructuredAgentSessionSinkAdmission = plan.submit(this.deps.sink) + // A failed or closed sink can never take it; the session's end owns what it leaves. + return admission.accepted || admission.reason !== 'backpressure' + } + + private planText( + plan: ProviderTimelinePlan, + stream: ProviderTimelineStream, + text: string, + providerFinal = false + ): void { + const empty = providerFinal ? text.length === 0 : text.trim().length === 0 + if (!stream.written && empty) { + plan.onAdmitted(() => this.coalescer.markFlushed(stream.id)) + return + } + const placeholder = this.message(stream, text) + plan.item({ + // Room for the text and the longest identity a scheme spells; a resumed prefix only paces. + reservedBytes: estimateStructuredAgentSessionItemBytes( + { provider: 'orca', clientMessageId: 'x'.repeat(1024) }, + placeholder + ), + paced: true, + resolve: (journal) => this.resolveText(stream, text, providerFinal, journal), + options: { ...stream.producer, turnScope: { kind: 'thread' } } + }) + plan.onAdmitted(() => { + stream.written = true + this.coalescer.markFlushed(stream.id) + }) + } + + /** At the write's turn in the queue: the row the stream owns, and its text so far. */ + private resolveText( + stream: ProviderTimelineStream, + text: string, + providerFinal: boolean, + journal: StructuredAgentSessionTransitionJournal + ): Resolved | null { + const ledger = providerTimelineLedger(this.deps.context, journal) + let owned = this.owned.get(stream.id) + if (!owned) { + owned = this.own(stream, ledger, journal) + this.owned.set(stream.id, owned) + } + if (owned === 'replayed' || ledger.ended) { + return null + } + const { row, prefix } = owned + return { + identity: row.identity, + body: this.message(stream, providerFinal ? text : `${prefix}${text}`), + options: { + ...stream.producer, + turnScope: row.scope, + ...(row.ref === undefined ? {} : { providerItemRef: row.ref }) + } + } + } + + private own( + stream: ProviderTimelineStream, + ledger: ProviderTimelineState, + journal: StructuredAgentSessionTransitionJournal + ): Owned { + const { joins } = this.deps.context + if (stream.named && ledger.closed.has(stream.key)) { + return 'replayed' + } + const found = stream.named ? joins.find(stream.join, journal) : null + if (found) { + const turnItemId = found.scope.kind === 'turn' ? found.scope.turnItemId : null + if (turnItemId !== null && ledger.status({ itemId: turnItemId }, journal) === 'settled') { + return 'replayed' + } + return { row: found, prefix: providerTimelineMessageText(journal.itemBody(found.itemId)) } + } + const placement = providerTimelinePlacement(this.deps.context, ledger, { + ...(stream.join.thread === null ? {} : { thread: stream.join.thread }), + ...(stream.turn === undefined ? {} : { turn: stream.turn }) + }) + const itemClass = stream.channel === 'assistant' ? 'message' : 'reasoning' + return { row: joins.place(stream.join, itemClass, placement, journal), prefix: '' } + } + + private message(stream: ProviderTimelineStream, text: string): AgentJournalItemBody { + return { + kind: 'message', + role: stream.channel === 'assistant' ? 'assistant' : 'reasoning', + blocks: [{ type: 'text', text }] + } + } + + private itemJoin(item: { id: string }, join?: ProviderTimelineJoin): ProviderTimelineItemJoin { + return { + family: 'item', + key: { source: 'provider', value: item.id }, + thread: join?.thread ?? null + } + } + + private anonymousJoin(join?: ProviderTimelineJoin): ProviderTimelineItemJoin { + return { family: 'item', key: this.deps.context.joins.mint('s'), thread: join?.thread ?? null } + } + + private forget(stream: ProviderTimelineStream): void { + this.coalescer.forget(stream.id) + if (this.streams.get(stream.key) === stream) { + this.streams.delete(stream.key) + } + this.byId.delete(stream.id) + } +} diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-transition-layout.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-transition-layout.ts new file mode 100644 index 000000000000..726070438901 --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-transition-layout.ts @@ -0,0 +1,120 @@ +// The writes one non-text event may need, laid out before anything is decided: the text owed +// ahead of it, then its fixed settlement and item slots, each resolved when it runs. + +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' +import { estimateStructuredAgentSessionItemBytes } from '../agent-session-wire/structured-agent-session-event-sink-estimate' +import type { StructuredAgentSessionTransitionJournal } from '../agent-session-wire/structured-agent-session-transition' +import { unhandledProviderFrameJournalItem } from '../agent-session-wire/unhandled-provider-frame' +import type { ProviderTimelineApplyHost } from './provider-timeline-apply-host' +import type { + ProviderTimelineDecidedEvent, + ProviderTimelineDecision +} from './provider-timeline-decisions' +import type { ProviderTimelinePlan } from './provider-timeline-plan' + +/** Room for a settled turn row and its context facts. */ +const TURN_ROW_RESERVED_BYTES = 64 * 1024 +/** Identities a scheme spells stay well under this; item rows reserve it for theirs. */ +const IDENTITY_PLACEHOLDER = { provider: 'orca', clientMessageId: 'x'.repeat(1024) } as const + +/** Text owed ahead of the event lands first; a row-writing event also ends the messages it + * separates: anonymous ones of its producer, every stream of a turn it ends, all on a session end. */ +export function planProviderTimelineBarrier( + host: ProviderTimelineApplyHost, + plan: ProviderTimelinePlan, + event: ProviderTimelineDecidedEvent, + decision: ProviderTimelineDecision +): void { + const { streams, context } = host + const forecast = host.forecast() + // A full snapshot of a streamed message replaces what streamed, so that text is not flushed. + const replaced = decision.closes ? streams.get(decision.closes) : undefined + streams.planFlush(plan, replaced) + if (replaced) { + streams.planRelease(plan, (stream) => stream === replaced) + } + if (event.type === 'session.ended' || event.type === 'session.reset') { + streams.planRelease(plan, () => true) + return + } + if (event.type === 'turn.end' || event.type === 'turn.open') { + const ending = + event.type === 'turn.end' && event.turn !== undefined + ? context.joins.turn({ source: 'provider', value: event.turn }, forecast.namespace).itemId + : forecast.open?.itemId + streams.planRelease( + plan, + (stream) => + (!stream.named && stream.producer?.agentId === undefined) || + (ending !== undefined && stream.turnItemId === ending) + ) + return + } + if (event.type !== 'input.accepted') { + const agentId = 'producer' in event ? event.producer?.agentId : undefined + streams.planRelease(plan, (stream) => !stream.named && stream.producer?.agentId === agentId) + } +} + +export function planProviderTimelineSlots( + host: ProviderTimelineApplyHost, + plan: ProviderTimelinePlan, + event: ProviderTimelineDecidedEvent, + decide: (journal: StructuredAgentSessionTransitionJournal) => ProviderTimelineDecision | null +): void { + const { context } = host + const settlement = (what: string) => + plan.settlement({ + settlementId: context.settlementId(what), + reservedBytes: TURN_ROW_RESERVED_BYTES, + resolve: (journal) => decide(journal)?.settle ?? [] + }) + const item = (reservedBytes: number, lifecycle: boolean) => + plan.item( + { + reservedBytes, + resolve: (journal) => decide(journal)?.write ?? null, + options: { turnScope: AGENT_JOURNAL_THREAD_SCOPE } + }, + lifecycle + ) + switch (event.type) { + case 'turn.open': + settlement('turn-superseded') + item(TURN_ROW_RESERVED_BYTES, true) + return + case 'turn.end': + settlement('turn-end') + return + case 'request.withdrawn': + settlement('request-withdrawn') + return + case 'session.ended': + settlement('session-end') + return + case 'session.reset': + settlement('session-reset') + return + case 'input.accepted': + case 'context.usage': + item(TURN_ROW_RESERVED_BYTES, true) + return + case 'item.open': + case 'item.update': + case 'item.close': + item( + estimateStructuredAgentSessionItemBytes(IDENTITY_PLACEHOLDER, event.body), + event.type !== 'item.update' + ) + return + case 'request.open': + item(estimateStructuredAgentSessionItemBytes(IDENTITY_PLACEHOLDER, event.body), true) + return + case 'provider.frame': { + const frame = unhandledProviderFrameJournalItem(context.agent, event.frameKind, event.payload) + if (frame) { + item(estimateStructuredAgentSessionItemBytes(IDENTITY_PLACEHOLDER, frame.body), false) + } + } + } +} diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-turn-decisions.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-turn-decisions.ts new file mode 100644 index 000000000000..2ae960936e0c --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-turn-decisions.ts @@ -0,0 +1,235 @@ +// What turn, send, context and session events do, decided on the forecast and again on the ledger. + +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalTurnLifecycle +} from '../../../shared/agent-session-journal-types' +import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' +import { + agentJournalTurnBody, + readAgentJournalTurn +} from '../../../shared/agent-session-turn-record' +import type { StructuredAgentSessionTransitionJournal } from '../agent-session-wire/structured-agent-session-transition' +import { + agentJournalTurnRowReservedBytes, + resolveAgentJournalTurnRowWrite +} from './agent-journal-turn-row-revision' +import { + providerKey, + settlementOf, + type ProviderTimelineDecidedEvent, + type ProviderTimelineDecision, + type ProviderTimelineDecisionInput, + type ProviderTimelineResolvedWrite +} from './provider-timeline-decision' +import type { ProviderTimelineTurnRef } from './provider-timeline-joins' +import { + providerTimelineSettlement, + runningProviderTimelineTurns, + type ProviderTimelineTurnEnd +} from './provider-timeline-settlement' +import { MAX_PENDING_INPUTS, type ProviderTimelineState } from './provider-timeline-state' + +export function decideTurnOpen( + input: ProviderTimelineDecisionInput, + event: Extract +): ProviderTimelineDecision { + const { state, context } = input + const open = state.open + const key = event.turn === undefined ? event.minted : providerKey(event.turn) + if (!key || (event.turn === undefined && open)) { + return { dropped: 'turn-duplicate' } + } + const turn = context.joins.turn(key, state.namespace) + if (open?.itemId === turn.itemId) { + return { dropped: 'turn-duplicate' } + } + if (state.status(turn, input.journal) !== 'absent') { + return { dropped: 'turn-replayed' } + } + const pending = state.inputs[0] + const running: AgentJournalTurnLifecycle = { + turnId: turn.turnId, + state: 'running', + userItemId: pending + ? agentJournalSubmissionKey(pending.clientMessageId) + : context.joins.turnOpener(turn), + startedAt: event.at, + ...(pending ? { requestedAt: pending.requestedAt } : {}) + } + // A newer turn ended this one, whoever asked for it. + const superseded = open + ? settlementOf(input, open, { + state: 'interrupted', + completedAt: event.at, + outcome: 'superseded' + }) + : [] + return { + ...(superseded ? { settle: superseded } : {}), + write: input.execute + ? { + identity: turn.identity, + body: agentJournalTurnBody(running), + // The running row's ts is the turn start itself, so clients read no append lag. + options: { turnScope: AGENT_JOURNAL_THREAD_SCOPE, lifecycle: true, observedAt: event.at } + } + : null, + commit: (next) => { + if (next.open) { + next.endTurn(next.open) + } + if (pending) { + next.inputs.shift() + } + next.open = { ...turn, running } + } + } +} + +export function decideTurnEnd( + input: ProviderTimelineDecisionInput, + event: Extract +): ProviderTimelineDecision { + const { state } = input + const turn = + event.turn === undefined + ? state.open + : input.context.joins.turn(providerKey(event.turn), state.namespace) + if (!turn) { + return { dropped: 'no-turn' } + } + if (state.status(turn, input.journal) !== 'running') { + return { dropped: 'turn-unknown' } + } + const settle = settlementOf(input, turn, { + state: event.state, + completedAt: event.at, + ...(event.outcome !== undefined ? { outcome: event.outcome } : {}), + ...(event.durationMs !== undefined ? { durationMs: event.durationMs } : {}) + }) + return { ...(settle ? { settle } : {}), commit: (next) => next.endTurn(turn) } +} + +export function decideInput( + input: ProviderTimelineDecisionInput, + event: Extract +): ProviderTimelineDecision { + const { state, journal, context } = input + const named = event.join?.turn + const turnKey = named === undefined ? (state.open?.address.key ?? null) : providerKey(named) + if (input.execute && journal && turnKey && event.join?.item !== undefined) { + context.joins.reserveEcho( + { family: 'item', key: providerKey(event.join.item), thread: event.join.thread ?? null }, + { thread: event.join.thread ?? null, turn: turnKey, scope: state.scope }, + journal + ) + } + const open = state.open + // A late echo of a turn already over names nothing. + if ( + named !== undefined && + context.joins.turn(providerKey(named), state.namespace).itemId !== open?.itemId + ) { + return {} + } + const pending = { clientMessageId: event.clientMessageId, requestedAt: event.requestedAt } + if (!open) { + return { + commit: (next) => { + next.inputs.push(pending) + next.inputs.splice(0, Math.max(0, next.inputs.length - MAX_PENDING_INPUTS)) + } + } + } + if (open.running.userItemId !== context.joins.turnOpener(open)) { + return {} + } + const userItemId = agentJournalSubmissionKey(event.clientMessageId) + const running = { ...open.running, userItemId, requestedAt: event.requestedAt } + return { + write: + input.execute && journal ? reviseOpener(journal, open, userItemId, event.requestedAt) : null, + commit: (next) => { + if (next.open?.itemId === open.itemId) { + next.open = { ...next.open, running } + } + } + } +} + +/** Only the opener fields change; the rest are the row's as the journal holds it. */ + +/** Only the opener fields change; the rest are the row's as the journal holds it. */ +export function reviseOpener( + journal: StructuredAgentSessionTransitionJournal, + open: ProviderTimelineTurnRef, + userItemId: string, + requestedAt: number +): ProviderTimelineResolvedWrite | null { + const row = readAgentJournalTurn(journal.itemBody(open.itemId) ?? undefined) + if (row?.state !== 'running') { + return null + } + const target = { identity: open.identity } + const write = { + lifecycle: agentJournalTurnBody({ ...row, userItemId, requestedAt }), + onlyWhileRunning: true as const + } + const resolved = resolveAgentJournalTurnRowWrite( + journal, + target, + write, + agentJournalTurnRowReservedBytes(target, write) + ) + return ( + resolved && { ...resolved, options: { turnScope: AGENT_JOURNAL_THREAD_SCOPE, lifecycle: true } } + ) +} + +export function decideSessionEnd( + input: ProviderTimelineDecisionInput, + end: ProviderTimelineTurnEnd, + commit: (state: ProviderTimelineState) => void +): ProviderTimelineDecision { + const { journal } = input + return { + ...(input.execute && journal + ? { + settle: providerTimelineSettlement( + journal, + 'session', + runningProviderTimelineTurns(journal), + end + ) + } + : {}), + commit + } +} + +export function decideContextUsage( + input: ProviderTimelineDecisionInput, + event: Extract +): ProviderTimelineDecision { + const { state, journal } = input + const named = event.join?.turn + const turn = + named === undefined + ? (state.open ?? state.latest) + : input.context.joins.turn(providerKey(named), state.namespace) + if (!input.execute || !journal) { + return {} + } + const target = turn ? { identity: turn.identity } : ({ newest: true } as const) + const write = { contextUsage: event.usage } + const resolved = resolveAgentJournalTurnRowWrite( + journal, + target, + write, + agentJournalTurnRowReservedBytes(target, write) + ) + return { + write: resolved && { ...resolved, options: { turnScope: AGENT_JOURNAL_THREAD_SCOPE } } + } +} From 9408e432e69a150a7eb979cf4011be9b5e1d4ae1 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 22:03:18 -0700 Subject: [PATCH 13/62] Fix the types and the exhaustive event switch CI reported for the assembler --- .../provider-timeline-assembler.ts | 13 +++++++++++-- .../provider-timeline-text-streams.ts | 3 ++- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler.ts index 480e40c88bac..4fcb4459190a 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler.ts @@ -127,7 +127,7 @@ export function createProviderTimelineAssembler( const submit = ( plan: ProviderTimelinePlan, - entry: InFlight + entry: ProviderTimelineInFlight ): StructuredAgentSessionSinkAdmission => { if (!plan.writes) { return plan.submit(deps.sink) @@ -234,7 +234,16 @@ export function createProviderTimelineAssembler( ) case 'provider.frame': return applyDecided({ ...event, minted: context.joins.mint('f') }) - default: + case 'input.accepted': + case 'turn.end': + case 'item.open': + case 'item.update': + case 'item.close': + case 'request.open': + case 'request.withdrawn': + case 'context.usage': + case 'session.ended': + case 'session.reset': return applyDecided(event) } } diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-text-streams.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-text-streams.ts index 90dc99135631..27c663b4239b 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-text-streams.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-text-streams.ts @@ -134,7 +134,8 @@ export class ProviderTimelineTextStreams { }): ProviderTimelineStream { this.serial += 1 const named = 'id' in input.item - const join = named ? this.itemJoin(input.item, input.join) : this.anonymousJoin(input.join) + const join = + 'id' in input.item ? this.itemJoin(input.item, input.join) : this.anonymousJoin(input.join) const placement = providerTimelinePlacement(this.deps.context, input.state, input.join) return { id: `${this.deps.generation}:s${this.serial}`, From c491688b29ed80939476f99af40df40b34b3d22d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 23:15:06 -0700 Subject: [PATCH 14/62] Let the journal decide stream lifetimes, request reuse, named sends and background work A third review found two blockers with the earlier rounds' cause, a remembered interpretation trusted after the journal moved on: - A reused request id was judged by its earlier prompt's settled turn before asking which turn the new one lands in, so a real approval in a later turn was dropped. The target turn now decides: the old turn again is a replay; a different live turn opens the next prompt beside it. - A text stream checked its row's turn only on its first write, and a turn's end released streams by the turn planning expected. Every write now checks the row, a turn's end stops the streams whose rows are in it, and turn status reads the journal first, so another writer's Stop wins. Also: a message boundary drawn by an event the journal held as a replay no longer splits an anonymous message; a send naming a turn not yet open waits for that turn; the budget charges a stream's thread and turn strings and the turn caches are byte-bounded; the open turn ends when the journal shows it settled; a turn's opener is read from the journal's row. Background work is now Orca's existing background-task row instead of a tool call flagged `outlivesTurn` (a flag remembered only in memory, so a restart failed the task). A turn's end never settles that row, so it survives restarts; session end leaves one in flight unverifiable. Three tests that opened a background tool call with `outlivesTurn` now open a background-task row and keep their original expectations about which turn the row stays in. --- ...der-timeline-assembler-attribution.test.ts | 265 ++++++++++++++++++ .../provider-timeline-assembler-items.test.ts | 24 +- .../provider-timeline-assembler-order.test.ts | 19 +- ...ovider-timeline-assembler-recovery.test.ts | 8 +- ...rovider-timeline-assembler-streams.test.ts | 177 ++++++++++++ ...rovider-timeline-assembler-test-support.ts | 75 +++++ .../provider-timeline-assembler.ts | 8 +- .../provider-timeline-background-tasks.ts | 68 +++++ .../provider-timeline-context.ts | 28 +- .../provider-timeline-decision.ts | 3 +- .../provider-timeline-event.ts | 13 +- .../provider-timeline-item-decisions.ts | 50 ++-- .../provider-timeline-settlement.ts | 15 +- .../provider-timeline-state.ts | 78 ++++-- .../provider-timeline-stream-rows.ts | 168 +++++++++++ .../provider-timeline-text-streams.ts | 123 ++++---- .../provider-timeline-transition-layout.ts | 20 +- .../provider-timeline-turn-decisions.ts | 45 +-- 18 files changed, 997 insertions(+), 190 deletions(-) create mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-assembler-attribution.test.ts create mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-assembler-streams.test.ts create mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-background-tasks.ts create mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-stream-rows.ts diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-attribution.test.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-attribution.test.ts new file mode 100644 index 000000000000..92238e697090 --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-attribution.test.ts @@ -0,0 +1,265 @@ +// Where a request, a send or a background task belongs is decided from the turn the event names +// and the journal as it stands, not from what an earlier event left in memory. + +import { afterEach, describe, expect, it } from 'vitest' +import { parseAgentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import { agentJournalTurnBody } from '../../../shared/agent-session-turn-record' +import { createCodexProviderTimelineIdentityScheme } from '../../codex/codex-provider-timeline-identity' +import { + assistantText, + backgroundTask, + backgroundTaskState, + closeProviderTimelineRigs, + messageText, + openProviderTimelineRig, + openUnboundProviderTimelineAssembler, + pendingApproval, + providerItemId, + providerTurnItemId, + SESSION, + type ProviderTimelineRig +} from './provider-timeline-assembler-test-support' + +afterEach(closeProviderTimelineRigs) + +const answered = { + ...pendingApproval, + resolution: { + state: 'resolved' as const, + selectedOptionId: 'allow', + resolvedBy: 'phone', + resolvedAt: 1_500 + } +} + +function identityOf(itemId: string) { + const identity = parseAgentJournalItemKey(itemId) + if (!identity) { + throw new Error(`${itemId} did not parse`) + } + return identity +} + +/** `p1` opened in `t1` and answered by a client, `t1` over, `t2` running. */ +async function answeredInAnEarlierTurn(): Promise { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ type: 'request.open', request: 'p1', body: pendingApproval }) + await rig.journal.appendItem(identityOf(providerItemId('request', 'p1')), answered, { + fence: 1, + turnScope: { kind: 'turn', turnItemId: providerTurnItemId('t1') } + }) + rig.assembler.apply({ type: 'turn.end', turn: 't1', at: 2_000, state: 'completed' }) + rig.assembler.apply({ type: 'turn.open', turn: 't2', at: 3_000 }) + await rig.rows() + return rig +} + +const second = providerItemId('request', 'p1', { incarnation: 2 }) + +describe('a request key reused in another turn', () => { + it('opens a new prompt in the live turn it names after a withdrawn one', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ type: 'request.open', request: 'p1', body: pendingApproval }) + rig.assembler.apply({ type: 'request.withdrawn', request: 'p1' }) + rig.assembler.apply({ type: 'turn.end', turn: 't1', at: 2_000, state: 'completed' }) + rig.assembler.apply({ type: 'turn.open', turn: 't2', at: 3_000 }) + await rig.rows() + const opened = rig.assembler.apply({ + type: 'request.open', + request: 'p1', + body: pendingApproval, + join: { turn: 't2' } + }) + expect(opened.dropped).toBeUndefined() + expect(await rig.row(second)).toMatchObject({ + body: { resolution: { state: 'pending' } }, + turnScope: { kind: 'turn', turnItemId: providerTurnItemId('t2') } + }) + expect((await rig.row(providerItemId('request', 'p1')))?.body).toMatchObject({ + resolution: { state: 'cancelled' } + }) + }) + + it('opens a new prompt in the open turn after an answered one, leaving the answer', async () => { + const rig = await answeredInAnEarlierTurn() + expect( + rig.assembler.apply({ type: 'request.open', request: 'p1', body: pendingApproval }).dropped + ).toBeUndefined() + expect((await rig.row(second))?.body).toMatchObject({ resolution: { state: 'pending' } }) + expect((await rig.row(providerItemId('request', 'p1')))?.body).toMatchObject({ + resolution: { state: 'resolved', selectedOptionId: 'allow' } + }) + }) + + it('is a replay when it names the turn that is over', async () => { + const rig = await answeredInAnEarlierTurn() + expect( + rig.assembler.apply({ + type: 'request.open', + request: 'p1', + body: pendingApproval, + join: { turn: 't1' } + }).dropped + ).toBe('request-replayed') + expect(await rig.row(second)).toBeUndefined() + }) + + it('decides at execution: queued before bind, a replay of the old turn writes nothing', async () => { + const rig = await answeredInAnEarlierTurn() + const { assembler, bind } = openUnboundProviderTimelineAssembler(rig.journal) + const join = { turn: 't1' } + expect( + assembler.apply({ type: 'request.open', request: 'p1', body: pendingApproval, join }).dropped + ).toBeUndefined() + await bind() + expect(await rig.row(second)).toBeUndefined() + expect((await rig.row(providerItemId('request', 'p1')))?.body).toMatchObject({ + resolution: { state: 'resolved' } + }) + }) + + it('decides at execution: queued before bind, a new prompt in the live turn opens', async () => { + const rig = await answeredInAnEarlierTurn() + const { assembler, bind } = openUnboundProviderTimelineAssembler(rig.journal) + const join = { turn: 't2' } + assembler.apply({ type: 'request.open', request: 'p1', body: pendingApproval, join }) + await bind() + expect((await rig.row(second))?.body).toMatchObject({ resolution: { state: 'pending' } }) + }) + + it('decides at execution: a turn another writer ended while it was queued takes no prompt', async () => { + const rig = await answeredInAnEarlierTurn() + const { assembler, bind } = openUnboundProviderTimelineAssembler(rig.journal) + const join = { turn: 't2' } + assembler.apply({ type: 'request.open', request: 'p1', body: pendingApproval, join }) + const running = await rig.turn('t2') + if (!running) { + throw new Error('t2 is running') + } + await rig.journal.appendItem( + identityOf(providerTurnItemId('t2')), + agentJournalTurnBody({ ...running, state: 'interrupted', completedAt: 4_000 }), + { fence: 1, turnScope: { kind: 'thread' } } + ) + await bind() + expect(await rig.row(second)).toBeUndefined() + }) +}) + +describe('a send that names its turn', () => { + it('opens the turn it names when that turn opens later', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ + type: 'input.accepted', + clientMessageId: 'send1', + requestedAt: 900, + join: { turn: 't1' } + }) + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + expect(await rig.turn('t1')).toMatchObject({ userItemId: 'orca:send1', requestedAt: 900 }) + }) + + it('never replaces an opener another writer gave the running turn', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + const running = await rig.turn('t1') + if (!running) { + throw new Error('t1 is running') + } + await rig.journal.appendItem( + identityOf(providerTurnItemId('t1')), + agentJournalTurnBody({ ...running, userItemId: 'orca:first', requestedAt: 800 }), + { fence: 1, turnScope: { kind: 'thread' } } + ) + rig.assembler.apply({ type: 'input.accepted', clientMessageId: 'later', requestedAt: 900 }) + expect(await rig.turn('t1')).toMatchObject({ userItemId: 'orca:first', requestedAt: 800 }) + }) + + it('waits for its own turn rather than the next one to open', async () => { + const rig = await openProviderTimelineRig() + for (const [send, turn] of [ + ['send-b', 't2'], + ['send-a', 't1'] + ] as const) { + rig.assembler.apply({ + type: 'input.accepted', + clientMessageId: send, + requestedAt: 900, + join: { turn } + }) + } + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ type: 'turn.end', at: 2_000, state: 'completed' }) + rig.assembler.apply({ type: 'turn.open', turn: 't3', at: 3_000 }) + rig.assembler.apply({ type: 'turn.end', at: 4_000, state: 'completed' }) + rig.assembler.apply({ type: 'turn.open', turn: 't2', at: 5_000 }) + expect((await rig.turn('t1'))?.userItemId).toBe('orca:send-a') + // A turn no send named opens as the provider's own. + expect((await rig.turn('t3'))?.userItemId).toBe(providerTurnItemId('t3')) + expect((await rig.turn('t2'))?.userItemId).toBe('orca:send-b') + }) +}) + +describe('a background task', () => { + it('outlives its turn after a restart, and settles on its own update', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ type: 'item.open', item: 'bg', body: backgroundTask('bg', 'working') }) + await rig.rows() + const restarted = rig.restart({ generation: 'gen-2' }) + restarted.apply({ type: 'turn.end', turn: 't1', at: 2_000, state: 'completed' }) + expect(await backgroundTaskState(rig, 'bg')).toBe('working') + expect( + restarted.apply({ type: 'item.close', item: 'bg', body: backgroundTask('bg', 'done') }) + .dropped + ).toBeUndefined() + expect(await backgroundTaskState(rig, 'bg')).toBe('done') + // A straggler progress report never re-lights it. + expect( + restarted.apply({ type: 'item.update', item: 'bg', body: backgroundTask('bg', 'working') }) + .dropped + ).toBe('item-settled') + expect(await backgroundTaskState(rig, 'bg')).toBe('done') + }) + + it('is left unverifiable, not exited, when the session ends with it in flight', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ type: 'item.open', item: 'bg', body: backgroundTask('bg', 'working') }) + rig.assembler.apply({ type: 'item.open', item: 'ran', body: backgroundTask('ran', 'working') }) + rig.assembler.apply({ type: 'item.close', item: 'ran', body: backgroundTask('ran', 'done') }) + rig.assembler.apply({ type: 'session.ended', verdict: { state: 'unverifiable' } }) + expect((await rig.row(providerItemId('item', 'bg')))?.body).toEqual( + backgroundTask('bg', 'unverifiable') + ) + expect(await backgroundTaskState(rig, 'ran')).toBe('done') + }) +}) + +describe('message places after a restart', () => { + it('continues past the highest place the journal holds, not the first gap', async () => { + const rig = await openProviderTimelineRig({ + scheme: createCodexProviderTimelineIdentityScheme({ + sessionId: SESSION, + primaryThreadId: () => 'root' + }), + ownThread: () => 'root' + }) + await rig.journal.appendItem( + { provider: 'codex', threadId: 'root', turnId: 't1', ordinal: 2 }, + assistantText('old'), + { fence: 1, turnScope: { kind: 'thread' }, providerItemRef: 'item:old' } + ) + const restarted = rig.restart({ generation: 'gen-2' }) + restarted.apply({ + type: 'item.close', + item: 'new', + body: assistantText('new'), + join: { thread: 'root', turn: 't1' } + }) + const placed = (await rig.rows()).find((row) => messageText(row.body) === 'new') + expect(placed?.itemId).toBe('codex:root:t1:3') + }) +}) diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-items.test.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-items.test.ts index 0172719fef48..7d6a12178a11 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-items.test.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-items.test.ts @@ -5,6 +5,8 @@ import type { } from '../../../shared/agent-session-journal-types' import { assistantText, + backgroundTask, + backgroundTaskState, closeProviderTimelineRigs, openProviderTimelineRig, providerItemId, @@ -133,15 +135,14 @@ describe('provider timeline items', () => { expect(rows[0]?.turnScope).toEqual({ kind: 'thread' }) }) - it('fails a tool call its turn left running, and keeps one that outlives the turn open', async () => { + it('fails a tool call its turn left running, and leaves a background task it started running', async () => { const rig = await openProviderTimelineRig() rig.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_000 }) rig.assembler.apply({ type: 'item.open', item: 'call-a', body: runningTool('read') }) rig.assembler.apply({ type: 'item.open', item: 'bg-1', - body: runningTool('background'), - outlivesTurn: true + body: backgroundTask('bg-1', 'working') }) rig.assembler.apply({ type: 'turn.end', @@ -152,19 +153,12 @@ describe('provider timeline items', () => { expect((await rig.row(providerItemId('item', 'call-a')))?.body).toMatchObject({ state: 'failed' }) - expect((await rig.row(providerItemId('item', 'bg-1')))?.body).toMatchObject({ - state: 'running' - }) + expect(await backgroundTaskState(rig, 'bg-1')).toBe('working') - // It settles on its own close later, still scoped to the turn that started it. - rig.assembler.apply({ - type: 'item.close', - item: 'bg-1', - body: { ...runningTool('background'), state: 'completed' } - }) - const background = await rig.row(providerItemId('item', 'bg-1')) - expect(background?.body).toMatchObject({ state: 'completed' }) - expect(background?.turnScope).toEqual({ + // It settles on its own update later, still in the turn that started it. + rig.assembler.apply({ type: 'item.close', item: 'bg-1', body: backgroundTask('bg-1', 'done') }) + expect(await backgroundTaskState(rig, 'bg-1')).toBe('done') + expect((await rig.row(providerItemId('item', 'bg-1')))?.turnScope).toEqual({ kind: 'turn', turnItemId: providerTurnItemId('turn-1') }) diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-order.test.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-order.test.ts index 3c8ee4949fdf..9c7be28bd349 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-order.test.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-order.test.ts @@ -2,6 +2,8 @@ import { afterEach, describe, expect, it } from 'vitest' import { MAX_PROVIDER_TIMELINE_OPEN_ENTRIES } from './provider-timeline-budget' import { assistantText, + backgroundTask, + backgroundTaskState, closeProviderTimelineRigs, messageText, openProviderTimelineRig, @@ -217,25 +219,16 @@ describe('explicit provider attribution', () => { }) }) - it('keeps an item in the turn it opened in when its close arrives after the next turn opened', async () => { + it('keeps a background task in the turn it opened in when it settles after the next turn opened', async () => { const rig = await openProviderTimelineRig() rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) - rig.assembler.apply({ - type: 'item.open', - item: 'bg', - body: runningTool('task'), - outlivesTurn: true - }) + rig.assembler.apply({ type: 'item.open', item: 'bg', body: backgroundTask('bg', 'working') }) rig.assembler.apply({ type: 'turn.end', at: 2_000, state: 'completed' }) rig.assembler.apply({ type: 'turn.open', turn: 't2', at: 3_000 }) - rig.assembler.apply({ - type: 'item.close', - item: 'bg', - body: { ...runningTool('task'), state: 'completed' } - }) + rig.assembler.apply({ type: 'item.close', item: 'bg', body: backgroundTask('bg', 'done') }) const row = await rig.row(providerItemId('item', 'bg')) expect(row?.turnScope).toEqual({ kind: 'turn', turnItemId: providerTurnItemId('t1') }) - expect(row?.body).toMatchObject({ state: 'completed' }) + expect(await backgroundTaskState(rig, 'bg')).toBe('done') }) it('writes context facts onto the turn the provider names, not the open one', async () => { diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-recovery.test.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-recovery.test.ts index d1bcda1b390c..b63560978336 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-recovery.test.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-recovery.test.ts @@ -10,6 +10,7 @@ import { createProviderTimelineAssembler } from './provider-timeline-assembler' import { AGENT, assistantText, + backgroundTask, closeProviderTimelineRigs, messageText, NAMESPACE, @@ -119,14 +120,13 @@ describe('a forgotten join finds its original row', () => { expect(old.map((row) => row.itemId)).toEqual(['codex:root:t1:0']) }) - it('keeps a late tool close in the turn it opened in after its join was evicted', async () => { + it('keeps a late background task update in the turn it opened in after its join was evicted', async () => { const rig = await openProviderTimelineRig() rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) rig.assembler.apply({ type: 'item.open', item: 'background-tool', - body: runningTool('background'), - outlivesTurn: true + body: backgroundTask('background-tool', 'working') }) rig.assembler.apply({ type: 'turn.end', turn: 't1', at: 2_000, state: 'completed' }) rig.assembler.apply({ type: 'turn.open', turn: 't2', at: 3_000 }) @@ -139,7 +139,7 @@ describe('a forgotten join finds its original row', () => { rig.assembler.apply({ type: 'item.close', item: 'background-tool', - body: { ...runningTool('background'), state: 'completed' } + body: backgroundTask('background-tool', 'done') }) expect((await rig.row(providerItemId('item', 'background-tool')))?.turnScope).toEqual({ diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-streams.test.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-streams.test.ts new file mode 100644 index 000000000000..d2094d9a4399 --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-streams.test.ts @@ -0,0 +1,177 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { parseAgentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import { agentJournalTurnBody } from '../../../shared/agent-session-turn-record' +import { + closeProviderTimelineRigs, + messageText, + openProviderTimelineRig, + openUnboundProviderTimelineAssembler, + providerItemId, + providerTurnItemId, + type ProviderTimelineRig +} from './provider-timeline-assembler-test-support' + +afterEach(closeProviderTimelineRigs) + +/** A journal that already holds a settled `old` turn and a running `live` one. */ +async function oldAndLiveTurns(): Promise { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 'old', at: 1_000 }) + rig.assembler.apply({ type: 'turn.end', turn: 'old', at: 2_000, state: 'completed' }) + rig.assembler.apply({ type: 'turn.open', turn: 'live', at: 3_000 }) + await rig.rows() + return rig +} + +async function messages(rig: ProviderTimelineRig): Promise<(string | undefined)[]> { + return (await rig.rows()) + .filter((row) => row.body.kind === 'message') + .map((row) => messageText(row.body)) +} + +describe('a stream follows the row it resolved to, not the one planning expected', () => { + it('stops at the end of the turn its row landed in, though planning expected another', async () => { + const rig = await oldAndLiveTurns() + const { assembler, bind, drained } = openUnboundProviderTimelineAssembler(rig.journal) + // Planned before bind, so planning expects `old` to be open; it is a replay of a settled turn. + assembler.apply({ type: 'turn.open', turn: 'old', at: 1_000 }) + assembler.apply({ type: 'text.delta', item: { id: 'm' }, channel: 'assistant', text: 'Hel' }) + await bind() + assembler.flush() + await drained() + expect((await rig.row(providerItemId('item', 'm')))?.turnScope).toEqual({ + kind: 'turn', + turnItemId: providerTurnItemId('live') + }) + + assembler.apply({ type: 'turn.end', turn: 'live', at: 4_000, state: 'completed' }) + await drained() + expect(await rig.turn('live')).toMatchObject({ state: 'completed' }) + assembler.apply({ type: 'text.delta', item: { id: 'm' }, channel: 'assistant', text: 'lo' }) + assembler.flush() + await drained() + expect(messageText((await rig.row(providerItemId('item', 'm')))?.body)).toBe('Hel') + }) + + it('writes nothing more once another writer of the journal settled its turn', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ + type: 'text.delta', + item: { id: 'm' }, + channel: 'assistant', + text: 'Hel' + }) + const running = await rig.turn('t1') + const identity = parseAgentJournalItemKey(providerTurnItemId('t1')) + if (!running || !identity) { + throw new Error('the turn row is written') + } + // A person's Stop, settled by the host on the journal, with no word to the assembler. + await rig.journal.appendItem( + identity, + agentJournalTurnBody({ ...running, state: 'interrupted', completedAt: 2_000 }), + { fence: 1, turnScope: { kind: 'thread' } } + ) + rig.assembler.apply({ type: 'text.delta', item: { id: 'm' }, channel: 'assistant', text: 'lo' }) + rig.assembler.flush() + expect(messageText((await rig.row(providerItemId('item', 'm')))?.body)).toBe('Hel') + + // The open turn is over for the assembler too: new work is no longer that turn's. + expect(rig.assembler.openTurnId).toBeNull() + }) + + it('still takes the provider final full snapshot of a message whose turn settled', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ + type: 'text.delta', + item: { id: 'm' }, + channel: 'assistant', + text: 'Hel' + }) + rig.assembler.apply({ type: 'turn.end', at: 2_000, state: 'completed' }) + rig.assembler.apply({ + type: 'item.close', + item: 'm', + body: { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'Hello.' }] } + }) + expect(messageText((await rig.row(providerItemId('item', 'm')))?.body)).toBe('Hello.') + }) +}) + +describe('a message boundary is the journal decision', () => { + it('keeps an anonymous message whole across a queued open the journal held as a replay', async () => { + const rig = await oldAndLiveTurns() + const { assembler, bind } = openUnboundProviderTimelineAssembler(rig.journal) + assembler.apply({ type: 'turn.open', turn: 'live', at: 3_000 }) + assembler.apply({ + type: 'text.delta', + item: { stream: 'reply' }, + channel: 'assistant', + text: 'Hel' + }) + assembler.apply({ type: 'turn.open', turn: 'old', at: 1_000 }) + assembler.apply({ + type: 'text.delta', + item: { stream: 'reply' }, + channel: 'assistant', + text: 'lo' + }) + assembler.apply({ type: 'text.close', item: { stream: 'reply' } }) + await bind() + expect(await messages(rig)).toEqual(['Hello']) + }) + + it('still splits an anonymous message at a turn the journal really opened', async () => { + const rig = await oldAndLiveTurns() + const { assembler, bind } = openUnboundProviderTimelineAssembler(rig.journal) + assembler.apply({ + type: 'text.delta', + item: { stream: 'reply' }, + channel: 'assistant', + text: 'One' + }) + assembler.apply({ type: 'turn.open', turn: 'next', at: 4_000 }) + assembler.apply({ + type: 'text.delta', + item: { stream: 'reply' }, + channel: 'assistant', + text: 'Two' + }) + assembler.apply({ type: 'text.close', item: { stream: 'reply' } }) + await bind() + expect(await messages(rig)).toEqual(['One', 'Two']) + }) + + it('resumes a named message after a queued open the journal held as a replay', async () => { + const rig = await oldAndLiveTurns() + const { assembler, bind } = openUnboundProviderTimelineAssembler(rig.journal) + assembler.apply({ type: 'text.delta', item: { id: 'm' }, channel: 'assistant', text: 'Hel' }) + assembler.apply({ type: 'turn.open', turn: 'old', at: 1_000 }) + assembler.apply({ type: 'text.delta', item: { id: 'm' }, channel: 'assistant', text: 'lo' }) + assembler.apply({ type: 'text.close', item: { id: 'm' } }) + await bind() + expect(await messages(rig)).toEqual(['Hello']) + expect((await rig.row(providerItemId('item', 'm')))?.turnScope).toEqual({ + kind: 'turn', + turnItemId: providerTurnItemId('live') + }) + }) +}) + +describe('the open budget counts every provider string a stream keeps', () => { + it('refuses a stream whose thread alone is past the budget', async () => { + const rig = await openProviderTimelineRig({ schedule: () => () => {} }) + const thread = 't'.repeat(1024 * 1024 + 1) + expect( + rig.assembler.apply({ + type: 'text.delta', + item: { id: 'm' }, + channel: 'assistant', + text: 'x', + join: { thread } + }).admission + ).toEqual({ accepted: false, reason: 'failed' }) + }) +}) diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-test-support.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-test-support.ts index 0b1d20746c58..dbcacbe9e1dc 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-test-support.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-test-support.ts @@ -14,6 +14,11 @@ import type { AgentJournalTurnLifecycle } from '../../../shared/agent-session-journal-types' import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' +import { backgroundTaskFallbackText } from '../../../shared/native-chat-background-task-row' +import { + isBackgroundTaskBlock, + type NativeChatBackgroundTaskBlock +} from '../../../shared/native-chat-types' import { createTrackedJournalOpener } from '../agent-session-journal/journal-host-database-test-support' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { @@ -75,6 +80,35 @@ export function runningTool(name: string): AgentJournalToolCallItem { return { kind: 'tool-call', name, input: { name }, state: 'running' } } +/** A background task's row as every lane writes it: its plain-text twin, then its block. */ +export function backgroundTask( + taskId: string, + state: NativeChatBackgroundTaskBlock['state'] +): AgentJournalMessageItem { + const block: NativeChatBackgroundTaskBlock = { + type: 'background-task', + taskId, + kind: 'command', + label: taskId, + state + } + return { + kind: 'message', + role: 'system', + blocks: [{ type: 'text', text: backgroundTaskFallbackText(block) }, block] + } +} + +/** The run state of the background task in the row of provider item `item`. */ +export async function backgroundTaskState( + rig: ProviderTimelineRig, + item: string +): Promise { + const body = (await rig.row(providerItemId('item', item)))?.body + const block = body?.kind === 'message' ? body.blocks.find(isBackgroundTaskBlock) : undefined + return block?.state +} + export function assistantText(text: string): AgentJournalMessageItem { return { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text }] } } @@ -104,6 +138,47 @@ export async function closeProviderTimelineRigs(): Promise { await journals.closeAll() } +/** An assembler whose sink binds to the rig's journal only when `bind` is called, as a lane that + * starts before its journal opens: it plans without the journal's view. */ +export type UnboundProviderTimelineAssembler = { + assembler: ProviderTimelineAssembler + bind(): Promise + drained(): Promise +} + +export function openUnboundProviderTimelineAssembler( + journal: AgentSessionJournal, + overrides: Partial = {} +): UnboundProviderTimelineAssembler { + const deferred = createDeferredStructuredAgentSessionEventSink(testEventSinkLogging()) + const sink = providerTimelineSink(deferred.sink) + if (!sink) { + throw new Error('the deferred sink offers transitions') + } + const assembler = createProviderTimelineAssembler({ + sink, + sessionId: SESSION, + agent: AGENT, + generation: 'gen-unbound', + namespace: NAMESPACE, + // The window never fires on its own; `flush` writes what it holds. + schedule: () => () => {}, + ...overrides + }) + cleanups.push(async () => { + assembler.dispose() + deferred.close() + }) + return { + assembler, + bind: async () => { + deferred.bind({ journal, fence: 1, publish: () => {} }) + await deferred.drained() + }, + drained: () => deferred.drained() + } +} + export type ProviderTimelineRig = { journal: AgentSessionJournal assembler: ProviderTimelineAssembler diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler.ts index 4fcb4459190a..af1918c24b1c 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler.ts @@ -180,9 +180,11 @@ export function createProviderTimelineAssembler( return { admission: PROVIDER_TIMELINE_OVER_BUDGET } } const plan = new ProviderTimelinePlan() - planProviderTimelineBarrier(host, plan, event, decision) const entry: ProviderTimelineInFlight = { executed: false, commit: decision.commit } let executed: ProviderTimelineDecision | null = null + // A message boundary is the event's only when its own decision, at execution, wrote. + const landed = () => executed !== null && !executed.dropped && executed.write !== null + planProviderTimelineBarrier(host, plan, event, decision, landed) // The event's decision on the ledger, taken once, by its first slot to run. const decide = (at: StructuredAgentSessionTransitionJournal) => { if (!executed) { @@ -210,8 +212,8 @@ export function createProviderTimelineAssembler( const apply = (event: ProviderTimelineEvent): ProviderTimelineApplyResult => { const journal = deps.sink.journalItems() - if (journal && !forecast.hydrated) { - forecast.hydrate(journal) + if (journal) { + forecast.reconcile(journal) } switch (event.type) { case 'text.delta': diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-background-tasks.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-background-tasks.ts new file mode 100644 index 000000000000..3b4d004160fd --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-background-tasks.ts @@ -0,0 +1,68 @@ +// Work that outlives its turn is a background-task row: a message carrying a `background-task` +// block with its own run state, the row every lane writes for it. It is not open work a turn's +// end settles, so it outlives its turn by what the row is, whichever run of the assembler ends +// that turn; only its own updates, or the session's end, change its state. + +import type { AgentJournalItemBody } from '../../../shared/agent-session-journal-types' +import { + backgroundTaskFallbackText, + canReplaceBackgroundTaskState, + isSettledBackgroundTaskState +} from '../../../shared/native-chat-background-task-row' +import { isBackgroundTaskBlock } from '../../../shared/native-chat-types' + +/** The row once the host lost the session that ran its tasks: every task still in flight is + * `unverifiable` (nothing says it exited), with its plain-text twin restated to match. Null when + * no task in it is in flight. */ +export function lostProviderTimelineBackgroundTasks( + body: AgentJournalItemBody +): AgentJournalItemBody | null { + if (body.kind !== 'message') { + return null + } + const restated = new Map() + const blocks = body.blocks.map((block) => { + if (!isBackgroundTaskBlock(block) || isSettledBackgroundTaskState(block.state)) { + return block + } + const lost = { ...block, state: 'unverifiable' as const } + restated.set(backgroundTaskFallbackText(block), backgroundTaskFallbackText(lost)) + return lost + }) + if (restated.size === 0) { + return null + } + return { + ...body, + blocks: blocks.map((block) => + block.type === 'text' && restated.has(block.text) + ? { ...block, text: restated.get(block.text) ?? block.text } + : block + ) + } +} + +/** Whether `next` would move a task `held` already settled back to a state it may not take. */ +export function relightsProviderTimelineBackgroundTask( + held: AgentJournalItemBody | null, + next: AgentJournalItemBody +): boolean { + if (held?.kind !== 'message' || next.kind !== 'message') { + return false + } + const states = new Map() + for (const block of held.blocks) { + if (isBackgroundTaskBlock(block)) { + states.set(block.taskId, block.state) + } + } + return next.blocks.some((block) => { + const current = isBackgroundTaskBlock(block) ? states.get(block.taskId) : undefined + return ( + isBackgroundTaskBlock(block) && + current !== undefined && + current !== block.state && + !canReplaceBackgroundTaskState(current, block.state) + ) + }) +} diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-context.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-context.ts index b451973b6710..16cdee07da9e 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-context.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-context.ts @@ -21,14 +21,13 @@ export type ProviderTimelineContext = { settlementId(what: string): string } -/** The ledger, with its open turn taken from the journal before its first decision runs. */ +/** The ledger, with its open turn taken from the journal before its first decision runs, and + * ended when another writer of the journal ended it. */ export function providerTimelineLedger( context: ProviderTimelineContext, journal: StructuredAgentSessionTransitionJournal ): ProviderTimelineState { - if (!context.ledger.hydrated) { - context.ledger.hydrate(journal) - } + context.ledger.reconcile(journal) return context.ledger } @@ -55,13 +54,20 @@ export function providerTimelinePlacement( } } -/** What an entry the assembler holds open costs: every provider string it keeps, and its body. */ -export function providerTimelineEntryBytes( - key: string, - body?: unknown, - producer?: AgentJournalProducerLinkage -): number { +/** What an entry the assembler holds open costs: its body, its producer, and every provider + * string it keeps (its key and join), each twice: as given, and inside the identities spelled + * from it. */ +export function providerTimelineEntryBytes(input: { + key: string + join: ProviderTimelineJoin | undefined + body?: unknown + producer: AgentJournalProducerLinkage | undefined +}): number { const measure = (value: unknown) => value === undefined ? 0 : Buffer.byteLength(JSON.stringify(value) ?? '', 'utf8') - return Buffer.byteLength(key, 'utf8') + measure(body) + measure(producer) + 64 + const kept = [input.key, input.join?.thread ?? '', input.join?.turn ?? ''].reduce( + (total, part) => total + Buffer.byteLength(part, 'utf8'), + 0 + ) + return 2 * kept + measure(input.body) + measure(input.producer) + 64 } diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-decision.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-decision.ts index ae75f819f524..ee13faf44a36 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-decision.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-decision.ts @@ -97,6 +97,5 @@ export function settlementOf( if (!input.execute || !input.journal) { return undefined } - const scope = { turnItemId: turn.itemId, outlivesTurn: input.state.outliving(turn.itemId) } - return providerTimelineSettlement(input.journal, scope, [turn], end) + return providerTimelineSettlement(input.journal, { turnItemId: turn.itemId }, [turn], end) } diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-event.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-event.ts index 1a1ca6ad1746..b3f01ff9f7e4 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-event.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-event.ts @@ -87,14 +87,11 @@ export type ProviderTimelineEvent = /** The provider's own measured duration. */ durationMs?: number } - /** Work began. `outlivesTurn` keeps it open past its turn's end (a backgrounded task). */ - | ({ - type: 'item.open' - item: string - body: ProviderTimelineItemBody - outlivesTurn?: true - } & Produced & - Joined) + /** Work began. Work that outlives its turn (a backgrounded task) is a background-task row — a + * message carrying a `background-task` block with its own run state — beside the tool call that + * started it, which closes as usual: no turn's end settles that row; its own updates do, and the + * session's end leaves one still in flight `unverifiable`. */ + | ({ type: 'item.open'; item: string; body: ProviderTimelineItemBody } & Produced & Joined) /** The item's whole current body. Content may be replaced; a settled tool never runs again. */ | ({ type: 'item.update'; item: string; body: ProviderTimelineItemBody } & Produced & Joined) /** The item's whole terminal body; it replaces any text streamed into the same item. */ diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-item-decisions.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-item-decisions.ts index 90200b66bae7..d5eb0ffb62ca 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-item-decisions.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-item-decisions.ts @@ -3,6 +3,7 @@ import { requiresTerminalSettlement } from '../agent-session-journal/journal-terminal-settlement' import { cancelledJournalPromptBody } from '../agent-session-journal/journal-prompt-body-bounds' import { unhandledProviderFrameJournalItem } from '../agent-session-wire/unhandled-provider-frame' +import { relightsProviderTimelineBackgroundTask } from './provider-timeline-background-tasks' import { providerTimelineEntryBytes, providerTimelinePlacement } from './provider-timeline-context' import { pendingPrompt, @@ -43,23 +44,24 @@ export function decideItem( if (change === 'close' && (closed || settledTool(held))) { return { dropped: 'item-settled' } } - const known = state.obligations.get(ref) - const outlivesTurn = - (event.type === 'item.open' && event.outlivesTurn === true) || known?.outlivesTurn === true - const obligation = change !== 'close' && (requiresTerminalSettlement(event.body) || outlivesTurn) + if (change !== 'open' && relightsProviderTimelineBackgroundTask(held, event.body)) { + return { dropped: 'item-settled' } + } + const obligation = change !== 'close' && requiresTerminalSettlement(event.body) + const placement = providerTimelinePlacement(context, state, event.join) const row = found ?? (input.execute && journal - ? context.joins.place( - join, - providerTimelineItemClass(event.body), - providerTimelinePlacement(context, state, event.join), - journal - ) + ? context.joins.place(join, providerTimelineItemClass(event.body), placement, journal) : null) - const scope = row?.scope ?? providerTimelinePlacement(context, state, event.join).scope + const scope = row?.scope ?? placement.scope const turnItemId = turnOf(scope) - const bytes = providerTimelineEntryBytes(event.item, event.body, event.producer) + const bytes = providerTimelineEntryBytes({ + key: event.item, + join: event.join, + body: event.body, + producer: event.producer + }) return { ...(obligation ? { hold: { key: ref, bytes } } : {}), write: row && { @@ -74,7 +76,7 @@ export function decideItem( ...(change === 'close' ? { closes: ref } : {}), commit: (next) => { if (obligation) { - next.obligations.set(ref, { itemId: row?.itemId ?? null, turnItemId, outlivesTurn, bytes }) + next.obligations.set(ref, { itemId: row?.itemId ?? null, turnItemId, bytes }) } else { next.obligations.delete(ref) } @@ -107,20 +109,31 @@ export function decideRequest( if (pendingPrompt(held) || (state.obligations.has(ref) && held === null)) { return { dropped: 'request-duplicate' } } - const settledTurn = current ? turnOf(current.scope) : null + // The turn this open lands in decides whether it repeats its predecessor: the same key again + // in a turn that is over is that turn's replay; in another, live turn it is a new request. + const placement = providerTimelinePlacement(context, state, event.join) + const target = turnOf(placement.scope) + const previous = current ? turnOf(current.scope) : null if ( held && - settledTurn !== null && - state.status({ itemId: settledTurn }, journal) === 'settled' + previous !== null && + state.status({ itemId: previous }, journal) === 'settled' && + (target === null || + target === previous || + state.status({ itemId: target }, journal) === 'settled') ) { return { dropped: 'request-replayed' } } - const placement = providerTimelinePlacement(context, state, event.join) const row = input.execute && journal ? context.joins.nextRequest(key, event.body.kind, placement, journal) : null - const bytes = providerTimelineEntryBytes(event.request, event.body, event.producer) + const bytes = providerTimelineEntryBytes({ + key: event.request, + join: event.join, + body: event.body, + producer: event.producer + }) return { hold: { key: ref, bytes }, // A row already there is a client's answer or a replay; neither is overwritten. @@ -136,7 +149,6 @@ export function decideRequest( next.obligations.set(ref, { itemId: row?.itemId ?? null, turnItemId: turnOf(row?.scope ?? placement.scope), - outlivesTurn: false, bytes }) } diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-settlement.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-settlement.ts index 390160e99e1a..dd4e92166551 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-settlement.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-settlement.ts @@ -20,6 +20,7 @@ import { import { cancelledJournalPromptBody } from '../agent-session-journal/journal-prompt-body-bounds' import type { JournalLifecycleMutationInput } from '../agent-session-journal/journal-row-builders' import type { StructuredAgentSessionTransitionJournal } from '../agent-session-wire/structured-agent-session-transition' +import { lostProviderTimelineBackgroundTasks } from './provider-timeline-background-tasks' import { agentJournalTurnRowReservedBytes, resolveAgentJournalTurnRowWrite @@ -74,9 +75,7 @@ export function endedProviderTimelineTurn( } /** Which open rows a settlement covers: one turn's, or every row when the session ends. */ -export type ProviderTimelineSettlementScope = - | { turnItemId: string; outlivesTurn: ReadonlySet } - | 'session' +export type ProviderTimelineSettlementScope = { turnItemId: string } | 'session' /** The settled rows of `scope`, then each listed turn's end while the journal holds it running. */ export function providerTimelineSettlement( @@ -90,10 +89,12 @@ export function providerTimelineSettlement( const turnScope = attribution.turnScope ?? AGENT_JOURNAL_THREAD_SCOPE const covered = scope === 'session' || - (turnScope.kind === 'turn' && - turnScope.turnItemId === scope.turnItemId && - !scope.outlivesTurn.has(itemId)) - const settled = covered ? settledProviderTimelineBody(body) : null + (turnScope.kind === 'turn' && turnScope.turnItemId === scope.turnItemId) + // Background tasks outlive turns; only the session's end leaves them past seeing. + const settled = !covered + ? null + : (settledProviderTimelineBody(body) ?? + (scope === 'session' ? lostProviderTimelineBackgroundTasks(body) : null)) const identity = settled ? parseAgentJournalItemKey(itemId) : null if (settled && identity) { // No linkage: a revision keeps the row's own producer. diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-state.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-state.ts index d3daf3ef7056..4c1a6bde83c3 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-state.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-state.ts @@ -24,21 +24,31 @@ const MAX_CLOSED_ITEMS = 512 /** Turns remembered as settled without asking the journal. */ const MAX_SETTLED_TURNS = 256 /** Sends waiting for a turn; a provider that never opens one cannot grow this without bound. */ -export const MAX_PENDING_INPUTS = 64 +const MAX_PENDING_INPUTS = 64 +const MAX_PENDING_INPUT_BYTES = 64 * 1024 + +function bytes(value: string): number { + return Buffer.byteLength(value, 'utf8') +} export type ProviderTimelineOpenTurn = ProviderTimelineTurnRef & { running: AgentJournalTurnLifecycle } -export type ProviderTimelinePendingInput = { clientMessageId: string; requestedAt: number } +export type ProviderTimelinePendingInput = { + clientMessageId: string + requestedAt: number + /** The turn row the provider said the send opens; absent: the next turn to open. */ + turnItemId?: string +} /** Work still waiting on a row that settles it: a running tool, a backgrounded task, a pending * request. Keyed by the item's join reference (a request's by its key, whatever its incarnation). */ export type ProviderTimelineObligation = { /** The row, once a resolver placed it. */ itemId: string | null + /** The turn whose end settles it; null for a row in no turn. */ turnItemId: string | null - outlivesTurn: boolean bytes: number } @@ -50,8 +60,16 @@ export class ProviderTimelineState { latest: ProviderTimelineTurnRef | null = null inputs: ProviderTimelinePendingInput[] = [] /** Items this run closed, with the turn they closed in. */ - closed = new BoundedMap({ maxEntries: MAX_CLOSED_ITEMS }) - settled = new BoundedMap({ maxEntries: MAX_SETTLED_TURNS }) + closed = new BoundedMap({ + maxEntries: MAX_CLOSED_ITEMS, + maxBytes: MAX_CLOSED_ITEMS * 1024, + sizeOf: (turnItemId, ref) => bytes(ref) + bytes(turnItemId ?? '') + 16 + }) + settled = new BoundedMap({ + maxEntries: MAX_SETTLED_TURNS, + maxBytes: MAX_SETTLED_TURNS * 1024, + sizeOf: (_settled, turnItemId) => bytes(turnItemId) + 8 + }) obligations = new Map() /** Whether the open turn was taken from the journal yet (ledger only). */ hydrated = false @@ -79,29 +97,55 @@ export class ProviderTimelineState { return copy } + /** Settled once anything settled it: this run, or any writer of the journal (a person's Stop). + * Memory says only what the journal cannot yet: a turn opened by a write still queued. */ status( turn: { itemId: string }, journal: StructuredAgentSessionTransitionJournal | null ): ProviderTimelineTurnStatus { - if (this.open?.itemId === turn.itemId) { - return 'running' - } if (this.settled.has(turn.itemId)) { return 'settled' } const row = readAgentJournalTurn(journal?.itemBody(turn.itemId) ?? undefined) - return row === null ? 'absent' : row.state === 'running' ? 'running' : 'settled' + if (row) { + return row.state === 'running' ? 'running' : 'settled' + } + return this.open?.itemId === turn.itemId ? 'running' : 'absent' } - /** The obligations a turn's end leaves open: the ones that outlive it. */ - outliving(turnItemId: string): Set { - const outliving = new Set() - for (const obligation of this.obligations.values()) { - if (obligation.outlivesTurn && obligation.itemId && obligation.turnItemId === turnItemId) { - outliving.add(obligation.itemId) + /** Ends the open turn when the journal holds it settled: another writer ended it. */ + reconcile(journal: StructuredAgentSessionTransitionJournal): void { + if (!this.hydrated) { + this.hydrate(journal) + } + const row = this.open && readAgentJournalTurn(journal.itemBody(this.open.itemId) ?? undefined) + if (this.open && row && row.state !== 'running') { + this.endTurn(this.open) + } + } + + /** A send waiting for its turn; past the bounds the oldest is forgotten (its turn opens as the + * provider's own). */ + wait(pending: ProviderTimelinePendingInput): void { + this.inputs.push(pending) + const size = (input: ProviderTimelinePendingInput) => + bytes(input.clientMessageId) + bytes(input.turnItemId ?? '') + 16 + let held = this.inputs.reduce((total, input) => total + size(input), 0) + while (this.inputs.length > MAX_PENDING_INPUTS || held > MAX_PENDING_INPUT_BYTES) { + const forgotten = this.inputs.shift() + if (!forgotten) { + return } + held -= size(forgotten) } - return outliving + } + + /** The send that opens `turnItemId`: the one that named it, else the oldest that named none. */ + opener(turnItemId: string): ProviderTimelinePendingInput | undefined { + return ( + this.inputs.find((input) => input.turnItemId === turnItemId) ?? + this.inputs.find((input) => input.turnItemId === undefined) + ) } /** The turn is over, and so is everything its settlement covered. */ @@ -112,7 +156,7 @@ export class ProviderTimelineState { this.latest = turn this.settled.set(turn.itemId, true) for (const [key, obligation] of this.obligations) { - if (obligation.turnItemId === turn.itemId && !obligation.outlivesTurn) { + if (obligation.turnItemId === turn.itemId) { this.obligations.delete(key) } } diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-stream-rows.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-stream-rows.ts new file mode 100644 index 000000000000..7c49dc89e790 --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-stream-rows.ts @@ -0,0 +1,168 @@ +// The ledger of which row each text stream writes, changed only at its writes' turn in the queue. +// +// A stream's row is decided by its first write, like every other row: a provider-named message +// the journal already holds is one this run resumes (its turn still running: the stream adopts the +// row's text as its prefix) or a replay (its turn settled, or this run closed it: nothing is +// written). An anonymous stream continues the row of the one before it when the event that ended +// that one turned out a replay. Every later write checks again that the row is not over, against +// the journal, so a turn another writer ended stops the stream there too. + +import type { AgentJournalItemBody } from '../../../shared/agent-session-journal-types' +import type { StructuredAgentSessionTransitionJournal } from '../agent-session-wire/structured-agent-session-transition' +import { + providerTimelineLedger, + providerTimelinePlacement, + type ProviderTimelineContext +} from './provider-timeline-context' +import { turnOf } from './provider-timeline-decision' +import type { ProviderTimelineRow } from './provider-timeline-joins' +import type { ProviderTimelineState } from './provider-timeline-state' +import type { ProviderTimelineStream } from './provider-timeline-text-streams' + +/** The row a stream writes, and the text it held before this run streamed into it. */ +export type ProviderTimelineStreamRow = { row: ProviderTimelineRow; prefix: string } + +type Owned = ProviderTimelineStreamRow | 'replayed' + +function providerTimelineMessageText(body: AgentJournalItemBody | null): string { + return body?.kind === 'message' && body.blocks[0]?.type === 'text' ? body.blocks[0].text : '' +} + +export class ProviderTimelineStreamRows { + private readonly owned = new Map() + + constructor( + private readonly context: ProviderTimelineContext, + /** Drops a stream planning still holds open, so its next delta starts another. */ + private readonly forget: (stream: ProviderTimelineStream) => void + ) {} + + /** At a write: the row it lands on, or null when it writes nothing. */ + resolve( + stream: ProviderTimelineStream, + journal: StructuredAgentSessionTransitionJournal + ): ProviderTimelineStreamRow | null { + const ledger = providerTimelineLedger(this.context, journal) + let owned = this.owned.get(stream.id) + if (!owned) { + owned = this.own(stream, ledger, journal) + this.owned.set(stream.id, owned) + } + if (owned === 'replayed' || ledger.ended) { + return null + } + // Every write, not only the first: whoever ended the row's turn since, this run or another + // writer of the journal, the stream never writes into it again. + if (this.over(stream, owned.row, ledger, journal)) { + this.stop(stream) + return null + } + return owned + } + + /** The event that released `stream` ran: `held` when it really ended the stream's message. */ + release(stream: ProviderTimelineStream, held: boolean): void { + stream.boundary = held ? 'held' : 'void' + // A named stream's next delta finds its row again through the journal; an anonymous one is + // kept for the stream that follows it. + if (held || stream.named) { + this.owned.delete(stream.id) + } + } + + /** Stops every stream whose row is over now. */ + retire( + streams: readonly ProviderTimelineStream[], + journal: StructuredAgentSessionTransitionJournal + ): void { + const ledger = providerTimelineLedger(this.context, journal) + for (const stream of streams) { + const owned = this.owned.get(stream.id) + if (owned && owned !== 'replayed' && this.over(stream, owned.row, ledger, journal)) { + this.stop(stream) + } + } + } + + /** The turn the stream's row is in, once a write placed it; else the one planning expects. */ + turn(stream: ProviderTimelineStream): string | null { + const owned = this.owned.get(stream.id) + return owned && owned !== 'replayed' ? turnOf(owned.row.scope) : stream.turnItemId + } + + clear(): void { + this.owned.clear() + } + + private own( + stream: ProviderTimelineStream, + ledger: ProviderTimelineState, + journal: StructuredAgentSessionTransitionJournal + ): Owned { + const { joins } = this.context + if (stream.named && ledger.closed.has(stream.key)) { + return 'replayed' + } + const carried = this.carried(stream, journal) + if (carried) { + return carried + } + const found = stream.named ? joins.find(stream.join, journal) : null + if (found) { + const turnItemId = turnOf(found.scope) + if (turnItemId !== null && ledger.status({ itemId: turnItemId }, journal) === 'settled') { + return 'replayed' + } + return { row: found, prefix: providerTimelineMessageText(journal.itemBody(found.itemId)) } + } + const placement = providerTimelinePlacement(this.context, ledger, { + ...(stream.join.thread === null ? {} : { thread: stream.join.thread }), + ...(stream.turn === undefined ? {} : { turn: stream.turn }) + }) + const itemClass = stream.channel === 'assistant' ? 'message' : 'reasoning' + return { row: joins.place(stream.join, itemClass, placement, journal), prefix: '' } + } + + /** The row of the message this one continues: an earlier anonymous stream whose release the + * journal did not take (its event was a replay). */ + private carried( + stream: ProviderTimelineStream, + journal: StructuredAgentSessionTransitionJournal + ): Owned | null { + let previous = stream.follows + delete stream.follows + for (; previous?.boundary === 'void'; previous = previous.follows) { + const owned = this.owned.get(previous.id) + if (owned) { + this.owned.delete(previous.id) + return owned === 'replayed' + ? owned + : { + row: owned.row, + prefix: providerTimelineMessageText(journal.itemBody(owned.row.itemId)) + } + } + } + return null + } + + /** Whether the row is past streaming into: its item closed, or its turn settled. */ + private over( + stream: ProviderTimelineStream, + row: ProviderTimelineRow, + ledger: ProviderTimelineState, + journal: StructuredAgentSessionTransitionJournal + ): boolean { + const turn = turnOf(row.scope) + return ( + (stream.named && ledger.closed.has(stream.key)) || + (turn !== null && ledger.status({ itemId: turn }, journal) === 'settled') + ) + } + + /** The stream writes nothing more; a later delta under its key starts another. */ + private stop(stream: ProviderTimelineStream): void { + this.owned.set(stream.id, 'replayed') + this.forget(stream) + } +} diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-text-streams.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-text-streams.ts index 27c663b4239b..3d18328b9fa2 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-text-streams.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-text-streams.ts @@ -6,11 +6,7 @@ // message. Deltas accumulate in the shared coalescer, and a row is a snapshot of the text so far. // Text owed to the journal is written inside the next event's transition — every other event is // an ordering barrier — or by the coalescer's window, and is marked written once the sink takes it. -// -// Which row a stream writes is decided when its first write runs, like every other row: a -// provider-named message the journal already holds is a message this run is resuming (its turn -// still running: the stream adopts the row's text as its prefix) or a replay (its turn settled, or -// this run closed it: nothing is written). +// Which row each write lands on is the journal's call, at the write (`provider-timeline-stream-rows.ts`). import type { AgentJournalItemBody, @@ -32,7 +28,6 @@ import type { import type { StructuredAgentSessionTransitionJournal } from '../agent-session-wire/structured-agent-session-transition' import { providerTimelineEntryBytes, - providerTimelineLedger, providerTimelinePlacement, type ProviderTimelineContext } from './provider-timeline-context' @@ -44,6 +39,7 @@ import type { import { providerTimelineKeyPart } from './provider-timeline-identity' import type { ProviderTimelineItemJoin, ProviderTimelineRow } from './provider-timeline-joins' import { ProviderTimelinePlan, type ProviderTimelineSink } from './provider-timeline-plan' +import { ProviderTimelineStreamRows } from './provider-timeline-stream-rows' import type { ProviderTimelineState } from './provider-timeline-state' /** One open text stream, as planning knows it. */ @@ -63,26 +59,25 @@ export type ProviderTimelineStream = { /** Whether any text was written; a whitespace-only stream completes nothing. */ written: boolean bytes: number + /** The anonymous stream an event released just before this one began: this one continues its + * row unless that event's boundary landed. */ + follows?: ProviderTimelineStream + /** Execution: whether the event that released this stream ended its message. */ + boundary?: 'held' | 'void' } -/** The row a stream writes, and the text it held before this run streamed into it. */ -type Owned = { row: ProviderTimelineRow; prefix: string } | 'replayed' - type Resolved = { identity: ProviderTimelineRow['identity'] body: AgentJournalItemBody options: StructuredAgentSessionItemAppendOptions } -export function providerTimelineMessageText(body: AgentJournalItemBody | null): string { - return body?.kind === 'message' && body.blocks[0]?.type === 'text' ? body.blocks[0].text : '' -} - export class ProviderTimelineTextStreams { private readonly streams = new Map() private readonly byId = new Map() - /** Ledger: the row each stream writes, decided by its first write. */ - private readonly owned = new Map() + private readonly rows: ProviderTimelineStreamRows + /** Per anonymous slot, the stream a boundary not yet decided released last. */ + private readonly released = new Map() private readonly coalescer private serial = 0 @@ -95,6 +90,7 @@ export class ProviderTimelineTextStreams { schedule?: AgentSessionDeltaCoalescerDeps['schedule'] } ) { + this.rows = new ProviderTimelineStreamRows(deps.context, (stream) => this.forget(stream)) this.coalescer = createAgentSessionDeltaCoalescer({ ...(deps.coalesceMs === undefined ? {} : { windowMs: deps.coalesceMs }), ...(deps.schedule ? { schedule: deps.schedule } : {}), @@ -137,9 +133,13 @@ export class ProviderTimelineTextStreams { const join = 'id' in input.item ? this.itemJoin(input.item, input.join) : this.anonymousJoin(input.join) const placement = providerTimelinePlacement(this.deps.context, input.state, input.join) + const key = this.key(input.item, input.join, input.state) + const follows = named ? undefined : this.released.get(key) + this.released.delete(key) return { id: `${this.deps.generation}:s${this.serial}`, - key: this.key(input.item, input.join, input.state), + key, + ...(follows ? { follows } : {}), join, turn: input.join?.turn, turnItemId: placement.scope.kind === 'turn' ? placement.scope.turnItemId : null, @@ -147,11 +147,11 @@ export class ProviderTimelineTextStreams { channel: input.channel, producer: input.producer, written: false, - bytes: providerTimelineEntryBytes( - 'id' in input.item ? input.item.id : input.item.stream, - undefined, - input.producer - ) + bytes: providerTimelineEntryBytes({ + key: 'id' in input.item ? input.item.id : input.item.stream, + join: input.join, + producer: input.producer + }) } } @@ -185,17 +185,40 @@ export class ProviderTimelineTextStreams { } } - /** Ends the streams `which` selects; their text was flushed by the same event. */ + /** Ends the streams `which` selects; their text was flushed by the same event. Planning starts + * the next message at once; `landed`, asked at execution, says whether the event really ended + * them: when it did not (a replay the journal held), the next anonymous message continues + * this one's row, and a named stream's next delta resumes its row from the journal. */ planRelease( plan: ProviderTimelinePlan, - which: (stream: ProviderTimelineStream) => boolean + which: (stream: ProviderTimelineStream) => boolean, + landed?: () => boolean ): void { const released = [...this.streams.values()].filter(which) if (released.length === 0) { return } - plan.onAdmitted(() => released.forEach((stream) => this.forget(stream))) - plan.atExecution(() => released.forEach((stream) => this.owned.delete(stream.id))) + plan.onAdmitted(() => + released.forEach((stream) => { + this.forget(stream) + if (landed && !stream.named) { + this.released.set(stream.key, stream) + } + }) + ) + plan.atExecution(() => + released.forEach((stream) => this.rows.release(stream, !landed || landed())) + ) + } + + /** At a turn's or the session's end: every stream whose row is over now stops. */ + planRetire(plan: ProviderTimelinePlan): void { + plan.atExecution((journal) => this.rows.retire(this.open, journal)) + } + + /** The turn a stream's row is in, once a write placed it; else the one planning expects. */ + rowTurn(stream: ProviderTimelineStream): string | null { + return this.rows.turn(stream) } /** Ends one stream with the provider's final text, else what streamed; it settles the item. */ @@ -213,18 +236,13 @@ export class ProviderTimelineTextStreams { true ) } - this.planRelease(plan, (each) => each === stream) if (stream.named) { - // The close settles the item for full snapshots too. - plan.atExecution(() => { - const owned = this.owned.get(stream.id) - const row = owned && owned !== 'replayed' ? owned.row : null - this.deps.context.ledger.closed.set( - stream.key, - row?.scope.kind === 'turn' ? row.scope.turnItemId : null - ) - }) + // The close settles the item for full snapshots too, in the turn its row is in. + plan.atExecution(() => + this.deps.context.ledger.closed.set(stream.key, this.rows.turn(stream)) + ) } + this.planRelease(plan, (each) => each === stream) } flush(): void { @@ -233,7 +251,8 @@ export class ProviderTimelineTextStreams { dispose(): void { this.open.forEach((stream) => this.forget(stream)) - this.owned.clear() + this.rows.clear() + this.released.clear() this.coalescer.dispose() } @@ -285,13 +304,8 @@ export class ProviderTimelineTextStreams { providerFinal: boolean, journal: StructuredAgentSessionTransitionJournal ): Resolved | null { - const ledger = providerTimelineLedger(this.deps.context, journal) - let owned = this.owned.get(stream.id) + const owned = this.rows.resolve(stream, journal) if (!owned) { - owned = this.own(stream, ledger, journal) - this.owned.set(stream.id, owned) - } - if (owned === 'replayed' || ledger.ended) { return null } const { row, prefix } = owned @@ -306,31 +320,6 @@ export class ProviderTimelineTextStreams { } } - private own( - stream: ProviderTimelineStream, - ledger: ProviderTimelineState, - journal: StructuredAgentSessionTransitionJournal - ): Owned { - const { joins } = this.deps.context - if (stream.named && ledger.closed.has(stream.key)) { - return 'replayed' - } - const found = stream.named ? joins.find(stream.join, journal) : null - if (found) { - const turnItemId = found.scope.kind === 'turn' ? found.scope.turnItemId : null - if (turnItemId !== null && ledger.status({ itemId: turnItemId }, journal) === 'settled') { - return 'replayed' - } - return { row: found, prefix: providerTimelineMessageText(journal.itemBody(found.itemId)) } - } - const placement = providerTimelinePlacement(this.deps.context, ledger, { - ...(stream.join.thread === null ? {} : { thread: stream.join.thread }), - ...(stream.turn === undefined ? {} : { turn: stream.turn }) - }) - const itemClass = stream.channel === 'assistant' ? 'message' : 'reasoning' - return { row: joins.place(stream.join, itemClass, placement, journal), prefix: '' } - } - private message(stream: ProviderTimelineStream, text: string): AgentJournalItemBody { return { kind: 'message', diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-transition-layout.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-transition-layout.ts index 726070438901..35fd7abc14b5 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-transition-layout.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-transition-layout.ts @@ -23,7 +23,9 @@ export function planProviderTimelineBarrier( host: ProviderTimelineApplyHost, plan: ProviderTimelinePlan, event: ProviderTimelineDecidedEvent, - decision: ProviderTimelineDecision + decision: ProviderTimelineDecision, + /** Execution: whether the event's own decision wrote, so the boundary it draws is real. */ + landed: () => boolean ): void { const { streams, context } = host const forecast = host.forecast() @@ -31,10 +33,11 @@ export function planProviderTimelineBarrier( const replaced = decision.closes ? streams.get(decision.closes) : undefined streams.planFlush(plan, replaced) if (replaced) { - streams.planRelease(plan, (stream) => stream === replaced) + streams.planRelease(plan, (stream) => stream === replaced, landed) } if (event.type === 'session.ended' || event.type === 'session.reset') { - streams.planRelease(plan, () => true) + streams.planRelease(plan, () => true, landed) + streams.planRetire(plan) return } if (event.type === 'turn.end' || event.type === 'turn.open') { @@ -46,13 +49,20 @@ export function planProviderTimelineBarrier( plan, (stream) => (!stream.named && stream.producer?.agentId === undefined) || - (ending !== undefined && stream.turnItemId === ending) + (ending !== undefined && streams.rowTurn(stream) === ending), + landed ) + // The turn that really ended is the journal's: its streams stop wherever planning placed them. + streams.planRetire(plan) return } if (event.type !== 'input.accepted') { const agentId = 'producer' in event ? event.producer?.agentId : undefined - streams.planRelease(plan, (stream) => !stream.named && stream.producer?.agentId === agentId) + streams.planRelease( + plan, + (stream) => !stream.named && stream.producer?.agentId === agentId, + landed + ) } } diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-turn-decisions.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-turn-decisions.ts index 2ae960936e0c..a6ee99b15bf1 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-turn-decisions.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-turn-decisions.ts @@ -22,13 +22,14 @@ import { type ProviderTimelineDecisionInput, type ProviderTimelineResolvedWrite } from './provider-timeline-decision' +import { providerTimelinePlacement } from './provider-timeline-context' import type { ProviderTimelineTurnRef } from './provider-timeline-joins' import { providerTimelineSettlement, runningProviderTimelineTurns, type ProviderTimelineTurnEnd } from './provider-timeline-settlement' -import { MAX_PENDING_INPUTS, type ProviderTimelineState } from './provider-timeline-state' +import type { ProviderTimelineState } from './provider-timeline-state' export function decideTurnOpen( input: ProviderTimelineDecisionInput, @@ -47,7 +48,7 @@ export function decideTurnOpen( if (state.status(turn, input.journal) !== 'absent') { return { dropped: 'turn-replayed' } } - const pending = state.inputs[0] + const pending = state.opener(turn.itemId) const running: AgentJournalTurnLifecycle = { turnId: turn.turnId, state: 'running', @@ -80,7 +81,9 @@ export function decideTurnOpen( next.endTurn(next.open) } if (pending) { - next.inputs.shift() + next.inputs = next.inputs.filter( + (input) => input.clientMessageId !== pending.clientMessageId + ) } next.open = { ...turn, running } } @@ -117,32 +120,38 @@ export function decideInput( ): ProviderTimelineDecision { const { state, journal, context } = input const named = event.join?.turn - const turnKey = named === undefined ? (state.open?.address.key ?? null) : providerKey(named) - if (input.execute && journal && turnKey && event.join?.item !== undefined) { + const placement = providerTimelinePlacement(context, state, event.join) + if (input.execute && journal && placement.turn && event.join?.item !== undefined) { context.joins.reserveEcho( { family: 'item', key: providerKey(event.join.item), thread: event.join.thread ?? null }, - { thread: event.join.thread ?? null, turn: turnKey, scope: state.scope }, + placement, journal ) } const open = state.open + const namedTurn = + named === undefined ? null : context.joins.turn(providerKey(named), state.namespace) + const status = namedTurn ? state.status(namedTurn, journal) : null // A late echo of a turn already over names nothing. - if ( - named !== undefined && - context.joins.turn(providerKey(named), state.namespace).itemId !== open?.itemId - ) { + if (status === 'settled' || (status === 'running' && namedTurn?.itemId !== open?.itemId)) { return {} } - const pending = { clientMessageId: event.clientMessageId, requestedAt: event.requestedAt } - if (!open) { + const pending = { + clientMessageId: event.clientMessageId, + requestedAt: event.requestedAt, + ...(namedTurn && status === 'absent' ? { turnItemId: namedTurn.itemId } : {}) + } + // No turn open, or the send names one still to open: it waits for that turn. + if (!open || pending.turnItemId !== undefined) { return { - commit: (next) => { - next.inputs.push(pending) - next.inputs.splice(0, Math.max(0, next.inputs.length - MAX_PENDING_INPUTS)) - } + commit: (next) => next.wait(pending) } } - if (open.running.userItemId !== context.joins.turnOpener(open)) { + // Only a turn still naming its fallback opener takes the send; the journal's row says, once there. + const opener = + readAgentJournalTurn(journal?.itemBody(open.itemId) ?? undefined)?.userItemId ?? + open.running.userItemId + if (opener !== context.joins.turnOpener(open)) { return {} } const userItemId = agentJournalSubmissionKey(event.clientMessageId) @@ -158,8 +167,6 @@ export function decideInput( } } -/** Only the opener fields change; the rest are the row's as the journal holds it. */ - /** Only the opener fields change; the rest are the row's as the journal holds it. */ export function reviseOpener( journal: StructuredAgentSessionTransitionJournal, From a9dbaaa0ee0da42841c8f5a631c78c048d14dfc8 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 23:19:45 -0700 Subject: [PATCH 15/62] Type the unbound assembler helper's drain as the void it reports --- .../provider-timeline-assembler-test-support.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-test-support.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-test-support.ts index dbcacbe9e1dc..c7e66edb98bb 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-test-support.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-test-support.ts @@ -175,7 +175,9 @@ export function openUnboundProviderTimelineAssembler( deferred.bind({ journal, fence: 1, publish: () => {} }) await deferred.drained() }, - drained: () => deferred.drained() + drained: async () => { + await deferred.drained() + } } } From 50c93fb9f3c973e1cbf50e5af62c642c9654fca9 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 23:28:22 -0700 Subject: [PATCH 16/62] Bound the rows kept for a continued anonymous message and the stopped streams A row kept for the anonymous stream that may continue it, and the marker that a stopped stream's queued writes write nothing, lived in the live-stream map and were never removed when no stream followed. They now live in their own bounded maps, so the live map holds open streams only. --- .../provider-timeline-state.ts | 4 +- .../provider-timeline-stream-rows.ts | 49 +++++++++++++------ .../provider-timeline-text-streams.ts | 3 +- 3 files changed, 37 insertions(+), 19 deletions(-) diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-state.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-state.ts index 4c1a6bde83c3..a74fd943bcdd 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-state.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-state.ts @@ -42,8 +42,8 @@ export type ProviderTimelinePendingInput = { turnItemId?: string } -/** Work still waiting on a row that settles it: a running tool, a backgrounded task, a pending - * request. Keyed by the item's join reference (a request's by its key, whatever its incarnation). */ +/** Work still waiting on a row that settles it: a running tool or a pending request. Keyed by + * the item's join reference (a request's by its key, whatever its incarnation). */ export type ProviderTimelineObligation = { /** The row, once a resolver placed it. */ itemId: string | null diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-stream-rows.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-stream-rows.ts index 7c49dc89e790..b362062b25ca 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-stream-rows.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-stream-rows.ts @@ -8,6 +8,7 @@ // the journal, so a turn another writer ended stops the stream there too. import type { AgentJournalItemBody } from '../../../shared/agent-session-journal-types' +import { BoundedMap } from '../../../shared/bounded-map' import type { StructuredAgentSessionTransitionJournal } from '../agent-session-wire/structured-agent-session-transition' import { providerTimelineLedger, @@ -24,12 +25,22 @@ export type ProviderTimelineStreamRow = { row: ProviderTimelineRow; prefix: stri type Owned = ProviderTimelineStreamRow | 'replayed' +/** Rows kept for the anonymous stream that may continue them, and streams stopped mid-flight + * whose queued writes must still write nothing. */ +const MAX_CARRIED_ROWS = 64 +const MAX_STOPPED_STREAMS = 512 + function providerTimelineMessageText(body: AgentJournalItemBody | null): string { return body?.kind === 'message' && body.blocks[0]?.type === 'text' ? body.blocks[0].text : '' } export class ProviderTimelineStreamRows { + /** The streams planning still holds open. */ private readonly owned = new Map() + private readonly carried = new BoundedMap({ + maxEntries: MAX_CARRIED_ROWS + }) + private readonly stopped = new BoundedMap({ maxEntries: MAX_STOPPED_STREAMS }) constructor( private readonly context: ProviderTimelineContext, @@ -42,6 +53,9 @@ export class ProviderTimelineStreamRows { stream: ProviderTimelineStream, journal: StructuredAgentSessionTransitionJournal ): ProviderTimelineStreamRow | null { + if (this.stopped.has(stream.id)) { + return null + } const ledger = providerTimelineLedger(this.context, journal) let owned = this.owned.get(stream.id) if (!owned) { @@ -63,10 +77,12 @@ export class ProviderTimelineStreamRows { /** The event that released `stream` ran: `held` when it really ended the stream's message. */ release(stream: ProviderTimelineStream, held: boolean): void { stream.boundary = held ? 'held' : 'void' - // A named stream's next delta finds its row again through the journal; an anonymous one is - // kept for the stream that follows it. - if (held || stream.named) { - this.owned.delete(stream.id) + const owned = this.owned.get(stream.id) + this.owned.delete(stream.id) + // A named stream's next delta finds its row again through the journal; an anonymous one's row + // is kept for the stream that follows it. + if (!held && !stream.named && owned && owned !== 'replayed') { + this.carried.set(stream.id, owned) } } @@ -92,6 +108,8 @@ export class ProviderTimelineStreamRows { clear(): void { this.owned.clear() + this.carried.clear() + this.stopped.clear() } private own( @@ -103,7 +121,7 @@ export class ProviderTimelineStreamRows { if (stream.named && ledger.closed.has(stream.key)) { return 'replayed' } - const carried = this.carried(stream, journal) + const carried = this.continued(stream, journal) if (carried) { return carried } @@ -125,22 +143,20 @@ export class ProviderTimelineStreamRows { /** The row of the message this one continues: an earlier anonymous stream whose release the * journal did not take (its event was a replay). */ - private carried( + private continued( stream: ProviderTimelineStream, journal: StructuredAgentSessionTransitionJournal ): Owned | null { let previous = stream.follows delete stream.follows for (; previous?.boundary === 'void'; previous = previous.follows) { - const owned = this.owned.get(previous.id) - if (owned) { - this.owned.delete(previous.id) - return owned === 'replayed' - ? owned - : { - row: owned.row, - prefix: providerTimelineMessageText(journal.itemBody(owned.row.itemId)) - } + const kept = this.carried.get(previous.id) + if (kept) { + this.carried.delete(previous.id) + return { + row: kept.row, + prefix: providerTimelineMessageText(journal.itemBody(kept.row.itemId)) + } } } return null @@ -162,7 +178,8 @@ export class ProviderTimelineStreamRows { /** The stream writes nothing more; a later delta under its key starts another. */ private stop(stream: ProviderTimelineStream): void { - this.owned.set(stream.id, 'replayed') + this.owned.delete(stream.id) + this.stopped.set(stream.id, true) this.forget(stream) } } diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-text-streams.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-text-streams.ts index 3d18328b9fa2..1557873426b6 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-text-streams.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-text-streams.ts @@ -36,6 +36,7 @@ import type { ProviderTimelineTextChannel, ProviderTimelineTextItem } from './provider-timeline-event' +import { BoundedMap } from '../../../shared/bounded-map' import { providerTimelineKeyPart } from './provider-timeline-identity' import type { ProviderTimelineItemJoin, ProviderTimelineRow } from './provider-timeline-joins' import { ProviderTimelinePlan, type ProviderTimelineSink } from './provider-timeline-plan' @@ -77,7 +78,7 @@ export class ProviderTimelineTextStreams { private readonly byId = new Map() private readonly rows: ProviderTimelineStreamRows /** Per anonymous slot, the stream a boundary not yet decided released last. */ - private readonly released = new Map() + private readonly released = new BoundedMap({ maxEntries: 64 }) private readonly coalescer private serial = 0 From ca7f95ca98e4791bbc8da4efb5a09b0106f6cad2 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 22:46:52 -0700 Subject: [PATCH 17/62] Translate ACP session traffic into shared timeline events --- src/main/acp/acp-context-usage.ts | 57 ++++ src/main/acp/acp-dialects/acp-dialect.ts | 28 ++ src/main/acp/acp-dialects/grok-dialect.ts | 88 ++++++ src/main/acp/acp-dialects/grok-requests.ts | 124 ++++++++ src/main/acp/acp-session-update.ts | 87 ++++++ src/main/acp/acp-timeline-dialect.test.ts | 188 ++++++++++++ .../acp/acp-timeline-fixture.test-support.ts | 168 ++++++++++ src/main/acp/acp-timeline-generic.test.ts | 269 ++++++++++++++++ src/main/acp/acp-timeline-recordings.test.ts | 138 +++++++++ src/main/acp/acp-timeline-requests.ts | 39 +++ src/main/acp/acp-timeline-translator.ts | 286 ++++++++++++++++++ src/main/acp/acp-tool-timeline.ts | 170 +++++++++++ src/main/acp/fixtures/s1-basic.jsonl | 11 + src/main/acp/fixtures/s2-permission.jsonl | 20 ++ src/main/acp/fixtures/s3-cancel.jsonl | 13 + src/main/acp/fixtures/s4-resume.jsonl | 23 ++ src/main/acp/fixtures/s5-plan-approved.jsonl | 15 + src/main/acp/fixtures/s6-background.jsonl | 11 + 18 files changed, 1735 insertions(+) create mode 100644 src/main/acp/acp-context-usage.ts create mode 100644 src/main/acp/acp-dialects/acp-dialect.ts create mode 100644 src/main/acp/acp-dialects/grok-dialect.ts create mode 100644 src/main/acp/acp-dialects/grok-requests.ts create mode 100644 src/main/acp/acp-session-update.ts create mode 100644 src/main/acp/acp-timeline-dialect.test.ts create mode 100644 src/main/acp/acp-timeline-fixture.test-support.ts create mode 100644 src/main/acp/acp-timeline-generic.test.ts create mode 100644 src/main/acp/acp-timeline-recordings.test.ts create mode 100644 src/main/acp/acp-timeline-requests.ts create mode 100644 src/main/acp/acp-timeline-translator.ts create mode 100644 src/main/acp/acp-tool-timeline.ts create mode 100644 src/main/acp/fixtures/s1-basic.jsonl create mode 100644 src/main/acp/fixtures/s2-permission.jsonl create mode 100644 src/main/acp/fixtures/s3-cancel.jsonl create mode 100644 src/main/acp/fixtures/s4-resume.jsonl create mode 100644 src/main/acp/fixtures/s5-plan-approved.jsonl create mode 100644 src/main/acp/fixtures/s6-background.jsonl diff --git a/src/main/acp/acp-context-usage.ts b/src/main/acp/acp-context-usage.ts new file mode 100644 index 000000000000..e290d1056d05 --- /dev/null +++ b/src/main/acp/acp-context-usage.ts @@ -0,0 +1,57 @@ +import { z } from 'zod' +import type { AgentSessionContextUsage } from '../../shared/agent-session-context-usage' +import type { Usage, UsageUpdate } from './generated/acp-protocol.generated' + +export function acpWindowUsage(update: UsageUpdate, at: number): AgentSessionContextUsage { + return { + window: { tokens: update.size, capturedAt: at }, + used: { + kind: 'estimate', + capturedAt: at, + usage: { + inputTokens: update.used, + cacheReadInputTokens: 0, + cacheCreationInputTokens: 0, + outputTokens: 0 + } + } + } +} + +export function acpResponseUsage(usage: Usage, at: number): AgentSessionContextUsage { + return tokenEstimate( + usage.inputTokens, + usage.outputTokens, + usage.cachedReadTokens ?? 0, + usage.cachedWriteTokens ?? 0, + at + ) +} + +export function tokenEstimate( + input: number, + output: number, + cachedRead: number, + cachedWrite: number, + at: number +): AgentSessionContextUsage { + return { + used: { + kind: 'estimate', + capturedAt: at, + usage: { + // ACP input includes cached tokens; the shared estimate adds its cache fields. + inputTokens: Math.max(0, input - cachedRead - cachedWrite), + cacheReadInputTokens: cachedRead, + cacheCreationInputTokens: cachedWrite, + outputTokens: output + } + } + } +} + +export const acpNotificationEnvelopeSchema = z.looseObject({ + sessionId: z.string(), + update: z.looseObject({ sessionUpdate: z.string() }), + _meta: z.looseObject({ isReplay: z.boolean().optional() }).nullish() +}) diff --git a/src/main/acp/acp-dialects/acp-dialect.ts b/src/main/acp/acp-dialects/acp-dialect.ts new file mode 100644 index 000000000000..c4bcadc13232 --- /dev/null +++ b/src/main/acp/acp-dialects/acp-dialect.ts @@ -0,0 +1,28 @@ +import type { AgentSessionContextUsage } from '../../../shared/agent-session-context-usage' +import type { AgentSessionPromptResponse } from '../../../shared/agent-session-question-answer' +import type { ProviderTimelineRequestBody } from '../../native-chat/agent-session-timeline/provider-timeline-event' +import type { ToolCallUpdate } from '../generated/acp-protocol.generated' + +export type AcpRequestPresentation = { + body: ProviderTimelineRequestBody + reply(response: AgentSessionPromptResponse | null): unknown +} + +export type AcpDialectNotification = { + turn?: string + agentInitiated?: boolean + replay?: boolean + at?: number + end?: { stopReason: string; durationMs?: number } + usage?: AgentSessionContextUsage +} + +/** Hooks only interpret provider extensions; lifecycle and row identity stay shared. */ +export type AcpDialect = { + toolName?(update: ToolCallUpdate): string | undefined + notification?(method: string, params: unknown, at: number): AcpDialectNotification | undefined + promptUsage?(result: unknown, at: number): AgentSessionContextUsage | undefined + request?(method: string, params: unknown): AcpRequestPresentation | undefined +} + +export const GENERIC_ACP_DIALECT: AcpDialect = {} diff --git a/src/main/acp/acp-dialects/grok-dialect.ts b/src/main/acp/acp-dialects/grok-dialect.ts new file mode 100644 index 000000000000..48b1233d7f4e --- /dev/null +++ b/src/main/acp/acp-dialects/grok-dialect.ts @@ -0,0 +1,88 @@ +import { z } from 'zod' +import { acpNotificationEnvelopeSchema, tokenEstimate } from '../acp-context-usage' +import type { AcpDialect } from './acp-dialect' +import { grokRequest } from './grok-requests' + +const tokenCount = z.number().int().nonnegative() +const usageSchema = z.looseObject({ + inputTokens: tokenCount, + outputTokens: tokenCount.default(0), + cachedReadTokens: tokenCount.default(0), + cacheCreationTokens: tokenCount.default(0) +}) +const promptUsageSchema = z.object({ _meta: z.looseObject({ usage: usageSchema.optional() }) }) +const toolMetaSchema = z.object({ 'x.ai/tool': z.object({ name: z.string().min(1) }) }) +const turnMetaSchema = z.looseObject({ + promptId: z.string().optional(), + turnStartMs: tokenCount.optional() +}) +const completionSchema = z.looseObject({ + sessionUpdate: z.literal('turn_completed'), + prompt_id: z.string(), + stop_reason: z.string(), + usage: usageSchema.optional(), + elapsed_ms: tokenCount.optional() +}) + +function usageFrom(value: unknown, at: number) { + const parsed = usageSchema.safeParse(value) + if (!parsed.success) { + return undefined + } + const usage = parsed.data + return tokenEstimate( + usage.inputTokens, + usage.outputTokens, + usage.cachedReadTokens, + usage.cacheCreationTokens, + at + ) +} + +export const GROK_ACP_DIALECT: AcpDialect = { + toolName: (update) => { + const parsed = toolMetaSchema.safeParse(update._meta) + return parsed.success ? parsed.data['x.ai/tool'].name : undefined + }, + request: grokRequest, + promptUsage: (result, at) => { + const parsed = promptUsageSchema.safeParse(result) + return parsed.success ? usageFrom(parsed.data._meta.usage ?? parsed.data._meta, at) : undefined + }, + notification: (method, params, at) => { + if ( + !['session/update', '_x.ai/session_notification', '_x.ai/session/update'].includes(method) + ) { + return undefined + } + const parsed = acpNotificationEnvelopeSchema.safeParse(params) + if (!parsed.success) { + return undefined + } + const envelope = parsed.data + const meta = turnMetaSchema.safeParse(envelope._meta) + const completion = completionSchema.safeParse(envelope.update) + const turn = completion.success + ? completion.data.prompt_id + : meta.success + ? meta.data.promptId + : undefined + const usage = completion.success ? usageFrom(completion.data.usage, at) : undefined + return { + ...(turn === undefined ? {} : { turn, agentInitiated: turn.startsWith('task-completed-') }), + ...(meta.success && meta.data.turnStartMs !== undefined ? { at: meta.data.turnStartMs } : {}), + replay: envelope._meta?.isReplay === true, + ...(completion.success + ? { + end: { + stopReason: completion.data.stop_reason, + ...(completion.data.elapsed_ms === undefined + ? {} + : { durationMs: completion.data.elapsed_ms }) + } + } + : {}), + ...(usage === undefined ? {} : { usage }) + } + } +} diff --git a/src/main/acp/acp-dialects/grok-requests.ts b/src/main/acp/acp-dialects/grok-requests.ts new file mode 100644 index 000000000000..7f6c0d15f950 --- /dev/null +++ b/src/main/acp/acp-dialects/grok-requests.ts @@ -0,0 +1,124 @@ +import { z } from 'zod' +import { + isValidAgentSessionQuestionAnswers, + legacyAgentSessionQuestionAnswers +} from '../../../shared/agent-session-question-answer' +import { AcpRpcError } from '../acp-errors' +import { pendingAcpResolution } from '../acp-timeline-requests' +import type { AcpRequestPresentation } from './acp-dialect' + +const questionRequestSchema = z.object({ + sessionId: z.string(), + toolCallId: z.string(), + questions: z + .array( + z.object({ + question: z.string(), + multiSelect: z.boolean().nullish(), + options: z.array(z.object({ label: z.string(), description: z.string().optional() })) + }) + ) + .min(1) +}) +const planRequestSchema = z.object({ + sessionId: z.string(), + toolCallId: z.string(), + planContent: z.string() +}) + +function questionRequest(params: unknown): AcpRequestPresentation { + const parsed = questionRequestSchema.safeParse(params) + if (!parsed.success) { + throw new AcpRpcError(-32602, 'Invalid agent question request') + } + const questions = parsed.data.questions.map((question, index) => ({ + id: `q${index + 1}`, + question: question.question, + multiSelect: question.multiSelect ?? false, + options: question.options.map((option, optionIndex) => ({ + id: `o${optionIndex + 1}`, + label: option.label, + ...(option.description === undefined ? {} : { description: option.description }) + })), + freeTextQuestionId: `q${index + 1}` + })) + const body = { + kind: 'question' as const, + question: questions[0]!.question, + options: questions[0]!.options, + questions, + resolution: pendingAcpResolution + } + return { + body, + reply: (response) => { + if (response === null) { + return { outcome: 'cancelled' } + } + const answers = + response.kind === 'answers' + ? response.answers + : legacyAgentSessionQuestionAnswers(body, response.optionId) + if (!answers || !isValidAgentSessionQuestionAnswers(questions, answers)) { + throw new AcpRpcError(-32602, 'Question answer must answer every offered question') + } + return { + outcome: 'accepted', + answers: Object.fromEntries( + questions.map((question) => { + const answer = answers.find((entry) => entry.questionId === question.id)! + const values = answer.optionIds.map( + (id) => question.options.find((option) => option.id === id)!.label + ) + if (answer.other?.trim()) { + values.push(answer.other.trim()) + } + return [question.question, question.multiSelect ? values : values[0]] + }) + ) + } + } + } +} + +function planRequest(params: unknown): AcpRequestPresentation { + const parsed = planRequestSchema.safeParse(params) + if (!parsed.success) { + throw new AcpRpcError(-32602, 'Invalid agent plan approval request') + } + return { + body: { + kind: 'approval', + title: 'Approve plan', + detail: null, + subject: { kind: 'plan', text: parsed.data.planContent }, + options: [ + { id: 'approved', label: 'Approve plan' }, + { id: 'request_changes', label: 'Request changes' } + ], + resolution: pendingAcpResolution + }, + reply: (response) => { + if (response === null) { + return { outcome: 'abandoned' } + } + if ( + response.kind !== 'option' || + !['approved', 'request_changes'].includes(response.optionId) + ) { + throw new AcpRpcError(-32602, 'Plan answer must select an offered option') + } + return { outcome: response.optionId } + } + } +} + +export function grokRequest(method: string, params: unknown): AcpRequestPresentation | undefined { + if (method === '_x.ai/ask_user_question') { + return questionRequest(params) + } + if (method === '_x.ai/exit_plan_mode') { + return planRequest(params) + } + return undefined +} diff --git a/src/main/acp/acp-session-update.ts b/src/main/acp/acp-session-update.ts new file mode 100644 index 000000000000..516b95d465ad --- /dev/null +++ b/src/main/acp/acp-session-update.ts @@ -0,0 +1,87 @@ +import type { ProviderTimelineEvent } from '../native-chat/agent-session-timeline/provider-timeline-event' +import { + boundInlineText, + DEFAULT_JOURNAL_PAYLOAD_LIMITS +} from '../native-chat/agent-session-journal/journal-payload-bounds' +import { acpWindowUsage } from './acp-context-usage' +import type { AcpDialect } from './acp-dialects/acp-dialect' +import type { AcpToolTimeline } from './acp-tool-timeline' +import type { SessionNotification } from './generated/acp-protocol.generated' + +export function acpSessionUpdate( + notification: SessionNotification, + turn: string | undefined, + at: number, + replay: boolean, + tools: AcpToolTimeline, + dialect: AcpDialect +): ProviderTimelineEvent[] { + const update = notification.update + const join = turn === undefined ? {} : { join: { turn } } + switch (update.sessionUpdate) { + case 'agent_message_chunk': + case 'agent_thought_chunk': + return update.content.type === 'text' + ? [ + { + type: 'text.delta', + item: update.messageId + ? { id: `message:${update.messageId}` } + : { stream: update.sessionUpdate }, + channel: update.sessionUpdate === 'agent_thought_chunk' ? 'reasoning' : 'assistant', + text: update.content.text, + ...join + } + ] + : [{ type: 'provider.frame', frameKind: update.sessionUpdate, payload: update, ...join }] + case 'user_message_chunk': + return replay && update.content.type === 'text' + ? [ + { + type: 'item.update', + item: `replay-user:${turn}:${update.messageId ?? ''}`, + body: { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: update.content.text }] + }, + ...join + } + ] + : [] + case 'tool_call': + case 'tool_call_update': + return tools.translate(update, dialect, turn) + case 'plan': + return [ + { + type: 'item.update', + item: `plan:${turn ?? 'thread'}`, + body: { + kind: 'status', + presentation: 'plan-document', + text: boundInlineText( + update.entries + .map( + (entry) => + `- [${entry.status === 'completed' ? 'x' : entry.status === 'in_progress' ? '~' : ' '}] ${entry.content}` + ) + .join('\n'), + DEFAULT_JOURNAL_PAYLOAD_LIMITS + ).text + }, + ...join + } + ] + case 'usage_update': + return [{ type: 'context.usage', usage: acpWindowUsage(update, at), ...join }] + case 'available_commands_update': + case 'current_mode_update': + case 'config_option_update': + case 'session_info_update': + // These feed commands, options and the session title, outside the timeline. + return [] + default: + return [{ type: 'provider.frame', frameKind: update.sessionUpdate, payload: update, ...join }] + } +} diff --git a/src/main/acp/acp-timeline-dialect.test.ts b/src/main/acp/acp-timeline-dialect.test.ts new file mode 100644 index 000000000000..c5b3b59087de --- /dev/null +++ b/src/main/acp/acp-timeline-dialect.test.ts @@ -0,0 +1,188 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { + closeProviderTimelineRigs, + messageText, + openProviderTimelineRig +} from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import type { ProviderTimelineEvent } from '../native-chat/agent-session-timeline/provider-timeline-event' +import { GROK_ACP_DIALECT } from './acp-dialects/grok-dialect' +import { AcpTimelineTranslator } from './acp-timeline-translator' + +afterEach(closeProviderTimelineRigs) + +async function dialectRig() { + const rig = await openProviderTimelineRig() + const translator = new AcpTimelineTranslator({ + sessionId: 'provider-1', + journalItems: () => rig.journal.snapshot().items, + dialect: GROK_ACP_DIALECT + }) + const apply = (events: ProviderTimelineEvent[]) => { + for (const event of events) { + expect(rig.assembler.apply(event).admission.accepted).toBe(true) + } + } + const notification = (method: string, params: unknown) => + apply(translator.notification(method, params, 1100)) + return { rig, translator, apply, notification } +} + +const question = { + sessionId: 'provider-1', + toolCallId: 'ask-1', + questions: [ + { + question: 'Choose a test?', + multiSelect: null, + options: [{ label: 'Unit', description: 'Fast tests' }, { label: 'Integration' }] + } + ] +} + +describe('ACP dialect request and turn boundaries', () => { + it('preserves provider permission labels, validates selected ids, and cancels unanswered rows on turn end', async () => { + const { rig, translator, apply } = await dialectRig() + apply(translator.openPrompt('send-1', 1000)) + const request = translator.request( + 'session/request_permission', + { + sessionId: 'provider-1', + toolCall: { toolCallId: 'call-1', title: 'Write example' }, + options: [ + { optionId: 'custom-yes', name: 'Yes, once', kind: 'allow_once' }, + { optionId: 'custom-no', name: 'Keep the file', kind: 'reject_once' } + ] + }, + 0 + ) + apply(request.events) + expect(request.presentation?.body).toMatchObject({ + options: [ + { id: 'custom-yes', label: 'Yes, once' }, + { id: 'custom-no', label: 'Keep the file' } + ] + }) + expect(request.presentation?.reply(null)).toEqual({ outcome: { outcome: 'cancelled' } }) + expect(() => request.presentation?.reply({ kind: 'option', optionId: 'allow-once' })).toThrow( + 'offered option' + ) + apply(translator.promptResult('send-1', { stopReason: 'cancelled' }, 1200)) + expect((await rig.rows()).find((row) => row.body.kind === 'approval')?.body).toMatchObject({ + resolution: { state: 'cancelled' } + }) + }) + + it('encodes labels and free text under the question text, validates grouped choices, and gives exact plan replies', async () => { + const { translator } = await dialectRig() + const ask = translator.request('_x.ai/ask_user_question', question, 0).presentation! + expect( + ask.reply({ kind: 'answers', answers: [{ questionId: 'q1', optionIds: ['o2'] }] }) + ).toEqual({ outcome: 'accepted', answers: { 'Choose a test?': 'Integration' } }) + expect( + ask.reply({ kind: 'answers', answers: [{ questionId: 'q1', optionIds: [], other: 'Smoke' }] }) + ).toEqual({ outcome: 'accepted', answers: { 'Choose a test?': 'Smoke' } }) + expect(ask.reply(null)).toEqual({ outcome: 'cancelled' }) + expect(() => ask.reply({ kind: 'answers', answers: [] })).toThrow('every offered question') + expect(() => + ask.reply({ kind: 'answers', answers: [{ questionId: 'q1', optionIds: ['o1', 'o2'] }] }) + ).toThrow('every offered question') + const grouped = translator.request( + '_x.ai/ask_user_question', + { + ...question, + questions: [ + { ...question.questions[0], multiSelect: true }, + { question: 'Another?', options: [{ label: 'Yes' }] } + ] + }, + 1 + ).presentation! + expect( + grouped.reply({ + kind: 'answers', + answers: [ + { questionId: 'q1', optionIds: ['o1', 'o2'] }, + { questionId: 'q2', optionIds: ['o1'] } + ] + }) + ).toEqual({ + outcome: 'accepted', + answers: { 'Choose a test?': ['Unit', 'Integration'], 'Another?': 'Yes' } + }) + const plan = translator.request( + '_x.ai/exit_plan_mode', + { sessionId: 'provider-1', toolCallId: 'plan-1', planContent: 'Plan' }, + 2 + ).presentation! + expect(plan.reply({ kind: 'option', optionId: 'approved' })).toEqual({ outcome: 'approved' }) + expect(plan.reply({ kind: 'option', optionId: 'request_changes' })).toEqual({ + outcome: 'request_changes' + }) + expect(plan.reply(null)).toEqual({ outcome: 'abandoned' }) + expect(() => plan.reply({ kind: 'option', optionId: 'allow' })).toThrow('offered option') + }) + + it('keeps client prompt settlement on the response, and never gives an autonomous turn invented success', async () => { + const { rig, translator, apply, notification } = await dialectRig() + apply(translator.openPrompt('send-1', 1000)) + notification('_x.ai/session_notification', { + sessionId: 'provider-1', + update: { sessionUpdate: 'turn_completed', prompt_id: 'regular', stop_reason: 'end_turn' } + }) + expect((await rig.turns())[0]!.state).toBe('running') + apply(translator.promptResult('send-1', { stopReason: 'cancelled' }, 1200)) + notification('session/update', { + sessionId: 'provider-1', + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'Autonomous' } + }, + _meta: { promptId: 'task-completed-1' } + }) + notification('_x.ai/session_notification', { + sessionId: 'provider-1', + update: { + sessionUpdate: 'turn_completed', + prompt_id: 'task-completed-1', + stop_reason: 'future_stop' + } + }) + const turns = await rig.turns() + expect(turns[0]!.outcome).toBe('cancellation') + expect(turns[1]).toMatchObject({ state: 'completed' }) + expect(turns[1]).not.toHaveProperty('outcome') + }) + + it('keeps live autonomous output while suppressing marked replay during load', async () => { + const { rig, translator, apply, notification } = await dialectRig() + apply(translator.openPrompt('send-1', 1000)) + apply(translator.promptResult('send-1', { stopReason: 'end_turn' }, 1200)) + await rig.rows() + translator.beginLoad() + notification('session/update', { + sessionId: 'provider-1', + update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'Old' } }, + _meta: { isReplay: true, promptId: 'old' } + }) + notification('session/update', { + sessionId: 'provider-1', + update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'Live' } }, + _meta: { promptId: 'task-completed-live' } + }) + notification('_x.ai/session_notification', { + sessionId: 'provider-1', + update: { + sessionUpdate: 'turn_completed', + prompt_id: 'task-completed-live', + stop_reason: 'end_turn' + } + }) + apply(translator.finishLoad(1300)) + expect( + (await rig.rows()).flatMap((row) => + row.body.kind === 'message' ? [messageText(row.body)] : [] + ) + ).toEqual(['Live']) + expect(await rig.turns()).toHaveLength(2) + }) +}) diff --git a/src/main/acp/acp-timeline-fixture.test-support.ts b/src/main/acp/acp-timeline-fixture.test-support.ts new file mode 100644 index 000000000000..a019328a9373 --- /dev/null +++ b/src/main/acp/acp-timeline-fixture.test-support.ts @@ -0,0 +1,168 @@ +import { readFile } from 'node:fs/promises' +import { z } from 'zod' +import { expect } from 'vitest' +import { parseAgentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import { + legacyAgentSessionSelectedOptionId, + type AgentSessionPromptResponse +} from '../../shared/agent-session-question-answer' +import type { ProviderTimelineEvent } from '../native-chat/agent-session-timeline/provider-timeline-event' +import { openProviderTimelineRig } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import { GROK_ACP_DIALECT } from './acp-dialects/grok-dialect' +import type { AcpRequestPresentation } from './acp-dialects/acp-dialect' +import { AcpTimelineTranslator } from './acp-timeline-translator' +import { PromptResponseSchema } from './generated/acp-protocol.generated' + +const frameSchema = z.object({ + direction: z.enum(['in', 'out']), + process: z.string(), + message: z.object({ + id: z.union([z.string(), z.number()]).optional(), + method: z.string().optional(), + params: z.unknown().optional(), + result: z.unknown().optional() + }) +}) +export type AcpFixtureFrame = z.infer +const replySchema = z.object({ + outcome: z.union([ + z.string(), + z.object({ outcome: z.string(), optionId: z.string().optional() }) + ]), + answers: z.record(z.string(), z.string()).optional() +}) + +export async function readAcpFixture(name: string): Promise { + const text = await readFile(new URL(`./fixtures/${name}.jsonl`, import.meta.url), 'utf8') + return text + .trim() + .split('\n') + .map((line) => frameSchema.parse(JSON.parse(line))) +} + +function fixtureAnswer( + presentation: AcpRequestPresentation, + result: unknown +): AgentSessionPromptResponse | null { + const reply = replySchema.parse(result) + if (typeof reply.outcome === 'object') { + return reply.outcome.optionId ? { kind: 'option', optionId: reply.outcome.optionId } : null + } + if (presentation.body.kind === 'approval') { + return { kind: 'option', optionId: reply.outcome } + } + return { + kind: 'answers', + answers: (presentation.body.questions ?? []).map((question) => { + const value = reply.answers?.[question.question] + return { + questionId: question.id, + optionIds: question.options + .filter((option) => option.label === value) + .map((option) => option.id) + } + }) + } +} + +/** Captured frames go through translation, assembly and the real serialized journal. */ +export async function openAcpFixtureRig() { + const rig = await openProviderTimelineRig() + let providerSessionId = 'session-1' + const translator = () => + new AcpTimelineTranslator({ + sessionId: providerSessionId, + journalItems: () => rig.journal.snapshot().items, + dialect: GROK_ACP_DIALECT + }) + let lane = translator() + const requests = new Map< + string | number, + { request: string; presentation: AcpRequestPresentation } + >() + const apply = (events: ProviderTimelineEvent[]) => { + for (const event of events) { + expect(rig.assembler.apply(event).admission.accepted).toBe(true) + } + } + const feed = async (frames: AcpFixtureFrame[]) => { + for (const [index, frame] of frames.entries()) { + const { message, direction } = frame + const at = 1000 + index + if (direction === 'out' && message.method === 'session/load') { + await rig.rows() + lane.beginLoad() + } else if (direction === 'out' && message.method === 'session/prompt') { + const session = z.object({ sessionId: z.string() }).parse(message.params) + if (providerSessionId !== session.sessionId) { + providerSessionId = session.sessionId + lane = translator() + } + apply(lane.openPrompt(`${frame.process}:${message.id}`, at)) + } else if (direction === 'in' && message.method && message.id !== undefined) { + const request = lane.request(message.method, message.params, message.id) + apply(request.events) + const opened = request.events.find((event) => event.type === 'request.open') + if (opened && request.presentation) { + requests.set(message.id, { request: opened.request, presentation: request.presentation }) + } + } else if (direction === 'out' && message.id !== undefined && !message.method) { + const request = requests.get(message.id) + if (!request) { + continue + } + const response = fixtureAnswer(request.presentation, message.result) + expect(request.presentation.reply(response)).toEqual(message.result) + const row = (await rig.rows()).find( + (item) => + item.body.kind === request.presentation.body.kind && + (item.body.kind === 'approval' || item.body.kind === 'question') && + item.body.resolution.state === 'pending' + ) + const identity = row ? parseAgentJournalItemKey(row.itemId) : null + if (!row || !identity || (row.body.kind !== 'approval' && row.body.kind !== 'question')) { + throw new Error('Missing request row') + } + await rig.journal.appendItem( + identity, + { + ...row.body, + resolution: { + state: response ? 'resolved' : 'cancelled', + selectedOptionId: + response?.kind === 'option' + ? response.optionId + : row.body.kind === 'question' && response?.kind === 'answers' + ? legacyAgentSessionSelectedOptionId(row.body, response.answers) + : null, + ...(response?.kind === 'answers' ? { answers: response.answers } : {}), + resolvedBy: 'fixture-client', + resolvedAt: at + } + }, + { fence: 1 } + ) + requests.delete(message.id) + } else if (direction === 'in' && message.method) { + apply(lane.notification(message.method, message.params, at)) + } else if (direction === 'in') { + const result = PromptResponseSchema.safeParse(message.result) + if (result.success) { + apply(lane.promptResult(`${frame.process}:${message.id}`, result.data, at)) + } + } + } + return rig.rows() + } + return { + rig, + apply, + feed, + lane: () => lane, + restart: () => { + rig.assembler = rig.restart({ generation: 'gen-2' }) + lane = translator() + }, + finishLoad: () => apply(lane.finishLoad(2000)) + } +} diff --git a/src/main/acp/acp-timeline-generic.test.ts b/src/main/acp/acp-timeline-generic.test.ts new file mode 100644 index 000000000000..ab316b6504db --- /dev/null +++ b/src/main/acp/acp-timeline-generic.test.ts @@ -0,0 +1,269 @@ +import { applyPatch } from 'diff' +import { afterEach, describe, expect, it } from 'vitest' +import { contextTokensFromUsage } from '../../shared/agent-session-context-usage' +import { + closeProviderTimelineRigs, + messageText, + openProviderTimelineRig, + providerItemId +} from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import type { ProviderTimelineEvent } from '../native-chat/agent-session-timeline/provider-timeline-event' +import { AcpTimelineTranslator, acpTurnEnd } from './acp-timeline-translator' + +afterEach(closeProviderTimelineRigs) + +async function genericRig() { + const rig = await openProviderTimelineRig() + const translator = new AcpTimelineTranslator({ + sessionId: 'provider-1', + journalItems: () => rig.journal.snapshot().items + }) + const apply = (events: ProviderTimelineEvent[]) => { + for (const event of events) { + expect(rig.assembler.apply(event).admission.accepted).toBe(true) + } + } + const update = (update: unknown) => + apply(translator.notification('session/update', { sessionId: 'provider-1', update }, 1100)) + return { rig, translator, apply, update } +} + +describe('generic ACP translation', () => { + it('keeps named text across non-text barriers, splits anonymous text, and assembles reasoning', async () => { + const { rig, translator, apply, update } = await genericRig() + apply(translator.openPrompt('send-1', 1000)) + update({ + sessionUpdate: 'agent_message_chunk', + messageId: 'm1', + content: { type: 'text', text: 'First ' } + }) + update({ + sessionUpdate: 'plan', + entries: [{ content: 'Read', status: 'pending', priority: 'medium' }] + }) + update({ + sessionUpdate: 'agent_message_chunk', + messageId: 'm1', + content: { type: 'text', text: 'reply' } + }) + update({ sessionUpdate: 'agent_thought_chunk', content: { type: 'text', text: 'Thinking' } }) + update({ sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'Before' } }) + update({ + sessionUpdate: 'plan', + entries: [{ content: 'Read', status: 'completed', priority: 'medium' }] + }) + update({ sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'After' } }) + apply(translator.promptResult('send-1', { stopReason: 'end_turn' }, 1200)) + const rows = await rig.rows() + expect( + rows.flatMap((row) => (row.body.kind === 'message' ? [messageText(row.body)] : [])) + ).toEqual(['First reply', 'Thinking', 'Before', 'After']) + expect(rows.find((row) => messageText(row.body) === 'Thinking')?.body).toMatchObject({ + role: 'reasoning' + }) + expect( + rows.flatMap((row) => + row.body.kind === 'status' && row.body.presentation === 'plan-document' + ? [row.body.text] + : [] + ) + ).toEqual(['- [x] Read']) + }) + + it('merges tool snapshots, bounds input/output and emits a separately keyed valid diff', async () => { + const { rig, translator, apply, update } = await genericRig() + apply(translator.openPrompt('send-1', 1000)) + const oldText = 'same\nbefore\n' + const newText = 'same\nafter\n' + update({ + sessionUpdate: 'tool_call', + toolCallId: 'tool-1', + title: 'Edit', + name: 'edit_file', + rawInput: { text: 'x'.repeat(40000) }, + status: 'pending' + }) + update({ + sessionUpdate: 'tool_call_update', + toolCallId: 'tool-1', + status: 'in_progress', + content: [{ type: 'content', content: { type: 'text', text: 'progress' } }] + }) + update({ + sessionUpdate: 'tool_call_update', + toolCallId: 'tool-1', + status: 'completed', + content: [ + { type: 'content', content: { type: 'text', text: 'y'.repeat(40000) } }, + { type: 'diff', path: 'file.ts', oldText, newText } + ] + }) + // A settled journal row refuses a late provider update that says it is running again. + update({ sessionUpdate: 'tool_call_update', toolCallId: 'tool-1', status: 'in_progress' }) + apply(translator.promptResult('send-1', { stopReason: 'end_turn' }, 1200)) + const body = (await rig.row(providerItemId('item', 'tool:tool-1')))?.body + expect(body).toMatchObject({ + name: 'edit_file', + state: 'completed', + input: { truncated: true }, + output: { truncated: true, byteLength: 40000 } + }) + const diff = (await rig.rows()).find((row) => row.body.kind === 'diff')?.body + if (diff?.kind !== 'diff') { + throw new Error('Missing diff') + } + expect(diff.path).toBe('file.ts') + expect(applyPatch(oldText, diff.patch.head)).toBe(newText) + }) + + it('records context window facts and does not double-count cached tokens in prompt usage', async () => { + const { rig, translator, apply, update } = await genericRig() + apply(translator.openPrompt('send-1', 1000)) + update({ sessionUpdate: 'usage_update', used: 42, size: 100 }) + apply( + translator.promptResult( + 'send-1', + { + stopReason: 'end_turn', + usage: { + inputTokens: 50, + outputTokens: 5, + totalTokens: 55, + cachedReadTokens: 20, + cachedWriteTokens: 10 + } + }, + 1200 + ) + ) + const turn = (await rig.turns())[0]! + expect(turn.contextUsage?.window).toEqual({ tokens: 100, capturedAt: 1100 }) + expect(turn.contextUsage?.used).toMatchObject({ + kind: 'estimate', + usage: { + inputTokens: 20, + cacheReadInputTokens: 20, + cacheCreationInputTokens: 10, + outputTokens: 5 + } + }) + if (turn.contextUsage?.used?.kind !== 'estimate') { + throw new Error('Missing usage') + } + expect(contextTokensFromUsage(turn.contextUsage.used.usage)).toBe(50) + }) + + it.each(['refusal', 'max_tokens', 'max_turn_requests'] as const)( + 'records %s as provider failure with the original stop reason', + async (stopReason) => { + const { rig, translator, apply } = await genericRig() + apply(translator.openPrompt('send-1', 1000)) + apply(translator.promptResult('send-1', { stopReason }, 1200)) + expect((await rig.turns())[0]).toMatchObject({ state: 'completed', outcome: 'failure' }) + expect( + (await rig.rows()).some( + (row) => + row.body.kind === 'status' && row.body.providerFrame?.kind === `prompt:${stopReason}` + ) + ).toBe(true) + } + ) + + it('keeps unknown verdicts unknown and preserves unknown frames without opening a turn', async () => { + const { rig, translator, apply } = await genericRig() + expect(acpTurnEnd('turn', 'future_stop', 1200)).not.toHaveProperty('outcome') + apply( + translator.sessionEvent( + { + kind: 'unrecognized', + sessionId: 'provider-1', + raw: { + sessionId: 'provider-1', + update: { sessionUpdate: 'future_event', secret: 'z'.repeat(40000) } + } + }, + 1100 + ) + ) + const rows = await rig.rows() + expect(rows).toHaveLength(1) + expect(rows[0]!.body).toMatchObject({ + kind: 'status', + providerFrame: { payload: { truncated: true } } + }) + expect(rows[0]!.turnScope).toEqual({ kind: 'thread' }) + expect(await rig.turns()).toEqual([]) + }) + + it('ignores live user echoes and options metadata, and rejects cross-session requests', async () => { + const { rig, translator, update } = await genericRig() + update({ sessionUpdate: 'user_message_chunk', content: { type: 'text', text: 'echo' } }) + update({ sessionUpdate: 'available_commands_update', availableCommands: [] }) + expect(await rig.rows()).toEqual([]) + expect( + translator.notification( + 'session/update', + { + sessionId: 'other', + update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'other' } } + }, + 1100 + ) + ).toEqual([]) + expect(() => + translator.request('session/request_permission', { sessionId: 'other' }, 0) + ).toThrow('unknown session') + expect(translator.request('future/request', { sessionId: 'provider-1' }, 0)).toMatchObject({ + events: [{ type: 'provider.frame' }] + }) + }) + + it('adopts generic unmarked load history and never invents a success verdict', async () => { + const { rig, translator, apply, update } = await genericRig() + translator.beginLoad() + update({ sessionUpdate: 'user_message_chunk', content: { type: 'text', text: 'User' } }) + update({ sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'History' } }) + apply(translator.finishLoad(1200)) + expect((await rig.turns())[0]).toMatchObject({ state: 'completed' }) + expect((await rig.turns())[0]).not.toHaveProperty('outcome') + expect( + (await rig.rows()).flatMap((row) => (row.body.kind === 'message' ? [row.body.role] : [])) + ).toEqual(['user', 'assistant']) + }) + + it('requires an explicit journal decision for marked replay and uses typed standard events', async () => { + const { rig, translator, apply } = await genericRig() + expect(() => + translator.notification( + 'session/update', + { + sessionId: 'provider-1', + _meta: { isReplay: true }, + update: { + sessionUpdate: 'user_message_chunk', + content: { type: 'text', text: 'History' } + } + }, + 1100 + ) + ).toThrow('beginLoad') + apply(translator.openPrompt('send-1', 1000)) + apply( + translator.sessionEvent( + { + kind: 'known', + notification: { + sessionId: 'provider-1', + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'typed' } + } + } + }, + 1100 + ) + ) + apply(translator.promptResult('send-1', { stopReason: 'end_turn' }, 1200)) + expect((await rig.rows()).some((row) => messageText(row.body) === 'typed')).toBe(true) + }) +}) diff --git a/src/main/acp/acp-timeline-recordings.test.ts b/src/main/acp/acp-timeline-recordings.test.ts new file mode 100644 index 000000000000..4dccaac7ee8a --- /dev/null +++ b/src/main/acp/acp-timeline-recordings.test.ts @@ -0,0 +1,138 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { + closeProviderTimelineRigs, + messageText +} from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import { openAcpFixtureRig, readAcpFixture } from './acp-timeline-fixture.test-support' + +afterEach(closeProviderTimelineRigs) + +describe('recorded ACP traffic through the journal', () => { + it('assembles basic chunks and partial tool calls with stable identities and usage', async () => { + const fixture = await openAcpFixtureRig() + const rows = await fixture.feed(await readAcpFixture('s1-basic')) + const tools = rows.filter((row) => row.body.kind === 'tool-call') + expect(tools).toHaveLength(2) + expect(tools.map((row) => row.body)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: 'list_dir', + callId: 'call-1', + state: 'completed', + output: expect.objectContaining({ truncated: false }) + }), + expect.objectContaining({ name: 'read_file', callId: 'call-2', state: 'completed' }) + ]) + ) + expect(rows.filter((row) => row.body.kind === 'message')).toHaveLength(1) + expect((await fixture.rig.turns())[0]).toMatchObject({ + state: 'completed', + outcome: 'success', + contextUsage: { used: { kind: 'estimate' } } + }) + expect(tools.every((tool) => tool.turnScope?.kind === 'turn')).toBe(true) + }) + + it('shows permission options verbatim and keeps allow and reject answers after turn settlement', async () => { + const fixture = await openAcpFixtureRig() + const rows = await fixture.feed(await readAcpFixture('s2-permission')) + const approvals = rows.flatMap((row) => (row.body.kind === 'approval' ? [row.body] : [])) + expect(approvals).toHaveLength(2) + expect(approvals[0]!.options.map((option) => option.id)).toEqual([ + 'allow-edits-session', + 'allow-once', + 'reject-once' + ]) + expect(approvals.map((body) => body.resolution)).toMatchObject([ + { state: 'resolved', selectedOptionId: 'allow-once' }, + { state: 'resolved', selectedOptionId: 'reject-once' } + ]) + expect((await fixture.rig.turns()).map((turn) => turn.outcome)).toEqual([ + 'success', + 'cancellation' + ]) + expect(rows.flatMap((row) => (row.body.kind === 'tool-call' ? [row.body.state] : []))).toEqual([ + 'completed', + 'failed' + ]) + }) + + it('settles the abandoned in_progress tool on cancel and accepts the next prompt', async () => { + const fixture = await openAcpFixtureRig() + const rows = await fixture.feed(await readAcpFixture('s3-cancel')) + expect(rows.flatMap((row) => (row.body.kind === 'tool-call' ? [row.body.state] : []))).toEqual([ + 'failed' + ]) + expect((await fixture.rig.turns()).map((turn) => [turn.state, turn.outcome])).toEqual([ + ['interrupted', 'cancellation'], + ['completed', 'success'] + ]) + expect(rows.some((row) => messageText(row.body)?.includes('ok'))).toBe(true) + }) + + it('suppresses load replay from the existing journal after an assembler restart', async () => { + const fixture = await openAcpFixtureRig() + const frames = await readAcpFixture('s4-resume') + await fixture.feed(frames.filter((frame) => frame.process === 'A')) + const before = await fixture.rig.rows() + fixture.restart() + const loading = frames.filter((frame) => frame.process === 'B') + const nextPrompt = loading.findIndex((frame) => frame.message.method === 'session/prompt') + await fixture.feed(loading.slice(0, nextPrompt)) + fixture.finishLoad() + expect(await fixture.rig.rows()).toEqual(before) + await fixture.feed(loading.slice(nextPrompt)) + expect(await fixture.rig.turns()).toHaveLength(2) + }) + + it('adopts coalesced load replay into an empty journal, including user and completed tools', async () => { + const fixture = await openAcpFixtureRig() + const frames = (await readAcpFixture('s4-resume')).filter((frame) => frame.process === 'B') + const nextPrompt = frames.findIndex((frame) => frame.message.method === 'session/prompt') + await fixture.feed(frames.slice(0, nextPrompt)) + fixture.finishLoad() + const rows = await fixture.rig.rows() + expect(rows.flatMap((row) => (row.body.kind === 'tool-call' ? [row.body] : []))).toHaveLength(2) + expect( + rows.filter((row) => row.body.kind === 'message' && row.body.role === 'user') + ).toHaveLength(1) + expect(rows.some((row) => messageText(row.body)?.includes('Read notes.txt'))).toBe(true) + expect((await fixture.rig.turns())[0]).toMatchObject({ state: 'completed', outcome: 'success' }) + expect(rows.every((row) => row.body.kind === 'turn' || row.turnScope?.kind === 'turn')).toBe( + true + ) + await fixture.feed(frames.slice(nextPrompt)) + expect(await fixture.rig.turns()).toHaveLength(2) + }) + + it('answers questions and plan approval with the recorded exact reply shapes', async () => { + const fixture = await openAcpFixtureRig() + const rows = await fixture.feed(await readAcpFixture('s5-plan-approved')) + expect(rows.flatMap((row) => (row.body.kind === 'question' ? [row.body] : []))).toMatchObject([ + { resolution: { state: 'resolved', answers: [{ questionId: 'q1', optionIds: ['o1'] }] } } + ]) + expect(rows.flatMap((row) => (row.body.kind === 'approval' ? [row.body] : []))).toMatchObject([ + { + subject: { kind: 'plan', text: '# Plan\n\nUpdate the example and run its tests.' }, + resolution: { state: 'resolved', selectedOptionId: 'approved' } + } + ]) + expect((await fixture.rig.turns())[0]!.outcome).toBe('success') + }) + + it('opens and ends the background completion turn without a client prompt', async () => { + const fixture = await openAcpFixtureRig() + const rows = await fixture.feed(await readAcpFixture('s6-background')) + expect((await fixture.rig.turns()).map((turn) => [turn.state, turn.outcome])).toEqual([ + ['completed', 'success'], + ['completed', 'success'] + ]) + const messages = rows.filter((row) => row.body.kind === 'message') + expect(messages).toHaveLength(2) + expect(messageText(messages[1]!.body)).toBe('The background') + expect(messages[0]!.turnScope).not.toEqual(messages[1]!.turnScope) + expect(rows.flatMap((row) => (row.body.kind === 'tool-call' ? [row.body.state] : []))).toEqual([ + 'completed' + ]) + }) +}) diff --git a/src/main/acp/acp-timeline-requests.ts b/src/main/acp/acp-timeline-requests.ts new file mode 100644 index 000000000000..1756521d965a --- /dev/null +++ b/src/main/acp/acp-timeline-requests.ts @@ -0,0 +1,39 @@ +import { AcpRpcError } from './acp-errors' +import type { AcpRequestPresentation } from './acp-dialects/acp-dialect' +import { RequestPermissionRequestSchema } from './generated/acp-protocol.generated' + +export const pendingAcpResolution = { + state: 'pending', + selectedOptionId: null, + resolvedBy: null, + resolvedAt: null +} as const + +export function acpPermissionPresentation(params: unknown): AcpRequestPresentation { + const parsed = RequestPermissionRequestSchema.safeParse(params) + if (!parsed.success) { + throw new AcpRpcError(-32602, 'Invalid ACP permission request') + } + const { toolCall, options } = parsed.data + return { + body: { + kind: 'approval', + title: toolCall.title ?? 'Permission requested', + detail: null, + options: options.map((option) => ({ id: option.optionId, label: option.name })), + resolution: pendingAcpResolution + }, + reply: (response) => { + if (response === null) { + return { outcome: { outcome: 'cancelled' } } + } + if ( + response.kind !== 'option' || + !options.some((option) => option.optionId === response.optionId) + ) { + throw new AcpRpcError(-32602, 'Permission answer must select an offered option') + } + return { outcome: { outcome: 'selected', optionId: response.optionId } } + } + } +} diff --git a/src/main/acp/acp-timeline-translator.ts b/src/main/acp/acp-timeline-translator.ts new file mode 100644 index 000000000000..04d629e3a0db --- /dev/null +++ b/src/main/acp/acp-timeline-translator.ts @@ -0,0 +1,286 @@ +import { z } from 'zod' +import type { AgentJournalRenderItem } from '../../shared/agent-session-journal-types' +import type { ProviderTimelineEvent } from '../native-chat/agent-session-timeline/provider-timeline-event' +import { acpNotificationEnvelopeSchema, acpResponseUsage } from './acp-context-usage' +import { + GENERIC_ACP_DIALECT, + type AcpDialect, + type AcpDialectNotification, + type AcpRequestPresentation +} from './acp-dialects/acp-dialect' +import { AcpRpcError } from './acp-errors' +import type { AcpSessionEvent } from './acp-session-runtime' +import { acpPermissionPresentation } from './acp-timeline-requests' +import { acpSessionUpdate } from './acp-session-update' +import { AcpToolTimeline } from './acp-tool-timeline' +import { SessionNotificationSchema, type PromptResponse } from './generated/acp-protocol.generated' + +const requestSessionSchema = z.object({ sessionId: z.string() }) + +export function acpTurnEnd( + turn: string, + stopReason: string, + at: number, + durationMs?: number +): ProviderTimelineEvent { + return { + type: 'turn.end', + turn, + at, + state: stopReason === 'cancelled' ? 'interrupted' : 'completed', + ...(stopReason === 'end_turn' + ? { outcome: 'success' as const } + : stopReason === 'cancelled' + ? { outcome: 'cancellation' as const } + : ['refusal', 'max_tokens', 'max_turn_requests'].includes(stopReason) + ? { outcome: 'failure' as const } + : {}), + ...(durationMs === undefined ? {} : { durationMs }) + } +} + +type PromptTurn = { turn: string; providerTurn?: string } +type LoadReplay = { adopt: boolean; turn?: string; serial: number } + +export type AcpTimelineTranslatorOptions = { + sessionId: string + /** The host supplies the committed journal after draining earlier writes, before session/load. */ + journalItems(): readonly AgentJournalRenderItem[] + dialect?: AcpDialect +} + +/** Each method consumes one provider frame once; the lane retries returned grammar events. */ +export class AcpTimelineTranslator { + private readonly dialect: AcpDialect + private readonly tools = new AcpToolTimeline() + private readonly aliases = new Map() + private prompt?: PromptTurn + private replay?: LoadReplay + + constructor(private readonly options: AcpTimelineTranslatorOptions) { + this.dialect = options.dialect ?? GENERIC_ACP_DIALECT + } + + openPrompt(clientMessageId: string, at: number): ProviderTimelineEvent[] { + if (this.prompt || this.replay) { + throw new Error('ACP prompt overlaps a prompt or load') + } + const turn = `prompt:${clientMessageId}` + this.prompt = { turn } + return [ + { type: 'input.accepted', clientMessageId, requestedAt: at, join: { turn } }, + { type: 'turn.open', turn, at } + ] + } + + promptResult( + clientMessageId: string, + result: PromptResponse, + at: number + ): ProviderTimelineEvent[] { + const turn = `prompt:${clientMessageId}` + const usage = + this.dialect.promptUsage?.(result, at) ?? + (result.usage ? acpResponseUsage(result.usage, at) : undefined) + const events: ProviderTimelineEvent[] = usage + ? [{ type: 'context.usage', usage, join: { turn } }] + : [] + if (!['end_turn', 'cancelled'].includes(result.stopReason)) { + events.push({ + type: 'provider.frame', + frameKind: `prompt:${result.stopReason}`, + payload: result, + join: { turn } + }) + } + events.push(acpTurnEnd(turn, result.stopReason, at)) + this.tools.end(turn) + if (this.prompt?.turn === turn) { + this.prompt = undefined + } + return events + } + + beginLoad(): void { + if (this.prompt || this.replay) { + throw new Error('ACP load overlaps a prompt or load') + } + this.replay = { adopt: this.options.journalItems().length === 0, serial: 0 } + } + + finishLoad(at: number): ProviderTimelineEvent[] { + const turn = this.replay?.turn + this.replay = undefined + if (!turn) { + return [] + } + this.tools.end(turn) + // Generic ACP history has no stop verdict; load completion must not invent success. + return [{ type: 'turn.end', turn, at, state: 'completed' }] + } + + sessionEvent(event: AcpSessionEvent, at: number): ProviderTimelineEvent[] { + return this.notification( + 'session/update', + event.kind === 'known' ? event.notification : event.raw, + at + ) + } + + notification(method: string, params: unknown, at: number): ProviderTimelineEvent[] { + const envelope = acpNotificationEnvelopeSchema.safeParse(params) + const session = requestSessionSchema.safeParse(params) + if (session.success && session.data.sessionId !== this.options.sessionId) { + return [] + } + const extension = this.dialect.notification?.(method, params, at) + const markedReplay = + (envelope.success && envelope.data._meta?.isReplay === true) || extension?.replay === true + if (markedReplay && !this.replay) { + throw new Error('ACP replay requires beginLoad journal decision') + } + const isReplay = extension?.replay ?? (markedReplay || this.replay !== undefined) + if (isReplay && this.replay && !this.replay.adopt) { + return [] + } + const standard = + method === 'session/update' ? SessionNotificationSchema.safeParse(params) : undefined + const events: ProviderTimelineEvent[] = [] + const substantive = + standard?.success && + [ + 'user_message_chunk', + 'agent_message_chunk', + 'agent_thought_chunk', + 'tool_call', + 'tool_call_update', + 'plan' + ].includes(standard.data.update.sessionUpdate) + if ( + isReplay && + this.replay?.adopt && + this.replay.turn && + standard?.success && + standard.data.update.sessionUpdate === 'user_message_chunk' + ) { + events.push({ type: 'turn.end', turn: this.replay.turn, at, state: 'completed' }) + this.tools.end(this.replay.turn) + this.replay.turn = undefined + } + const turn = this.routeTurn( + extension, + events, + at, + substantive || extension?.end !== undefined, + isReplay + ) + const join = turn === undefined ? {} : { join: { turn } } + if (extension?.usage) { + events.push({ type: 'context.usage', usage: extension.usage, ...join }) + } + if (extension?.end && turn) { + // Client prompts settle on their own response; extension completions own autonomous turns. + if (this.prompt?.turn !== turn) { + events.push(acpTurnEnd(turn, extension.end.stopReason, at, extension.end.durationMs)) + this.tools.end(turn) + if (this.replay?.turn === turn) { + this.replay.turn = undefined + } + } + return events + } + if (standard?.success) { + return [ + ...events, + ...acpSessionUpdate( + standard.data, + turn, + at, + isReplay && (this.replay?.adopt ?? false), + this.tools, + this.dialect + ) + ] + } + events.push({ type: 'provider.frame', frameKind: method, payload: params, ...join }) + return events + } + + request( + method: string, + params: unknown, + id: string | number + ): { + events: ProviderTimelineEvent[] + presentation?: AcpRequestPresentation + } { + const session = requestSessionSchema.safeParse(params) + if (!session.success || session.data.sessionId !== this.options.sessionId) { + throw new AcpRpcError(-32602, 'ACP request belongs to an unknown session') + } + const presentation = + method === 'session/request_permission' + ? acpPermissionPresentation(params) + : this.dialect.request?.(method, params) + if (!presentation) { + return { + events: [{ type: 'provider.frame', frameKind: `request:${method}`, payload: params }] + } + } + return { + presentation, + events: [ + { + type: 'request.open', + request: `${method}:${JSON.stringify(id)}`, + body: presentation.body + } + ] + } + } + + private alias(providerTurn: string, turn: string): void { + if (this.aliases.size >= 128) { + this.aliases.delete(this.aliases.keys().next().value ?? '') + } + this.aliases.set(providerTurn, turn) + } + + private routeTurn( + extension: AcpDialectNotification | undefined, + events: ProviderTimelineEvent[], + at: number, + substantive: boolean, + isReplay: boolean + ): string | undefined { + const providerTurn = extension?.turn + const known = providerTurn ? this.aliases.get(providerTurn) : undefined + if (known) { + return known + } + if (isReplay && this.replay?.adopt && substantive) { + if (!this.replay.turn) { + this.replay.turn = providerTurn ?? `replay:${this.replay.serial++}` + events.push({ type: 'turn.open', turn: this.replay.turn, at: extension?.at ?? at }) + } + if (providerTurn) { + this.alias(providerTurn, this.replay.turn) + } + return this.replay.turn + } + if (extension?.agentInitiated && providerTurn && substantive) { + this.alias(providerTurn, providerTurn) + events.push({ type: 'turn.open', turn: providerTurn, at: extension.at ?? at }) + return providerTurn + } + if (providerTurn && this.prompt && !this.prompt.providerTurn) { + this.prompt.providerTurn = providerTurn + this.alias(providerTurn, this.prompt.turn) + } + return ( + (providerTurn ? (this.aliases.get(providerTurn) ?? providerTurn) : undefined) ?? + this.replay?.turn ?? + this.prompt?.turn + ) + } +} diff --git a/src/main/acp/acp-tool-timeline.ts b/src/main/acp/acp-tool-timeline.ts new file mode 100644 index 000000000000..84f643a2ec63 --- /dev/null +++ b/src/main/acp/acp-tool-timeline.ts @@ -0,0 +1,170 @@ +import { createPatch } from 'diff' +import { z } from 'zod' +import type { AgentJournalToolCallItem } from '../../shared/agent-session-journal-types' +import { + boundPayload, + boundToolInput, + DEFAULT_JOURNAL_PAYLOAD_LIMITS +} from '../native-chat/agent-session-journal/journal-payload-bounds' +import type { ProviderTimelineEvent } from '../native-chat/agent-session-timeline/provider-timeline-event' +import type { AcpDialect } from './acp-dialects/acp-dialect' +import type { ToolCallUpdate } from './generated/acp-protocol.generated' + +const rawStreamsSchema = z.object({ stdout: z.string().optional(), stderr: z.string().optional() }) + +function outputText(update: ToolCallUpdate): string | undefined { + const text = update.content?.flatMap((block) => + block.type === 'content' && block.content.type === 'text' ? [block.content.text] : [] + ) + if (text?.length) { + return text.join('\n') + } + if (update.rawOutput === undefined) { + return undefined + } + if (typeof update.rawOutput === 'string') { + return update.rawOutput + } + const streams = rawStreamsSchema.safeParse(update.rawOutput) + if (streams.success && (streams.data.stdout !== undefined || streams.data.stderr !== undefined)) { + return [streams.data.stdout, streams.data.stderr] + .filter((value) => value !== undefined) + .join('\n') + } + return JSON.stringify(update.rawOutput) ?? '' +} + +/** A whole-file replacement is a linear-time unified patch, even for unrelated large files. */ +function replacementPatch(path: string, oldText: string, newText: string): string { + const lines = (text: string, prefix: string): { count: number; text: string } => { + if (!text) { + return { count: 0, text: '' } + } + const entries = text.split('\n') + if (entries.at(-1) === '') { + entries.pop() + } + return { + count: entries.length, + text: + entries.map((line) => `${prefix}${line}\n`).join('') + + (text.endsWith('\n') ? '' : '\\ No newline at end of file\n') + } + } + const oldLines = lines(oldText, '-') + const newLines = lines(newText, '+') + return `--- ${path}\n+++ ${path}\n@@ -${oldLines.count ? 1 : 0},${oldLines.count} +${newLines.count ? 1 : 0},${newLines.count} @@\n${oldLines.text}${newLines.text}` +} + +function boundedDiffPatch(path: string, oldText: string, newText: string): string { + const patch = + oldText.length + newText.length <= 256 * 1024 + ? createPatch(path, oldText, newText, undefined, undefined, { maxEditLength: 1024 }) + : undefined + return patch ?? replacementPatch(path, oldText, newText) +} + +type ToolSnapshot = { body: AgentJournalToolCallItem; turn?: string } + +/** Holds only bounded snapshots needed to fill ACP's partial tool updates. */ +export class AcpToolTimeline { + private readonly tools = new Map() + private bytes = 0 + + translate(update: ToolCallUpdate, dialect: AcpDialect, turn?: string): ProviderTimelineEvent[] { + const previous = this.tools.get(update.toolCallId) + const output = outputText(update) + const state = + update.status === 'completed' + ? 'completed' + : update.status === 'failed' + ? 'failed' + : update.status === 'pending' || update.status === 'in_progress' + ? 'running' + : (previous?.body.state ?? 'running') + const body: AgentJournalToolCallItem = { + kind: 'tool-call', + callId: update.toolCallId, + name: + dialect.toolName?.(update) ?? + update.name ?? + previous?.body.name ?? + update.title ?? + update.kind ?? + 'tool', + input: + update.rawInput === undefined + ? (previous?.body.input ?? null) + : boundToolInput(update.rawInput, DEFAULT_JOURNAL_PAYLOAD_LIMITS), + state, + ...(output === undefined + ? previous?.body.output + ? { output: previous.body.output } + : {} + : { output: boundPayload(output, DEFAULT_JOURNAL_PAYLOAD_LIMITS) }) + } + const joinedTurn = previous?.turn ?? turn + const snapshot: ToolSnapshot = { + body, + ...(joinedTurn === undefined ? {} : { turn: joinedTurn }) + } + this.remember(update.toolCallId, snapshot) + const join = joinedTurn === undefined ? {} : { join: { turn: joinedTurn } } + const events: ProviderTimelineEvent[] = [ + { + type: state === 'running' ? (previous ? 'item.update' : 'item.open') : 'item.close', + item: `tool:${update.toolCallId}`, + body, + ...join + } + ] + for (const content of update.content ?? []) { + if (content.type === 'diff') { + events.push({ + type: 'item.update', + item: `diff:${JSON.stringify([update.toolCallId, content.path])}`, + body: { + kind: 'diff', + path: content.path, + patch: boundPayload( + boundedDiffPatch(content.path, content.oldText ?? '', content.newText), + DEFAULT_JOURNAL_PAYLOAD_LIMITS + ) + }, + ...join + }) + } else if (content.type === 'terminal' || content.content.type !== 'text') { + events.push({ + type: 'provider.frame', + frameKind: `tool-content:${content.type}`, + payload: content, + ...join + }) + } + } + return events + } + + end(turn: string): void { + for (const [key, snapshot] of this.tools) { + if (snapshot.turn === turn) { + this.bytes -= this.size(key, snapshot) + this.tools.delete(key) + } + } + } + + private size(key: string, snapshot: ToolSnapshot): number { + return Buffer.byteLength(key) + Buffer.byteLength(JSON.stringify(snapshot)) + } + + private remember(key: string, snapshot: ToolSnapshot): void { + const previous = this.tools.get(key) + const bytes = this.bytes - (previous ? this.size(key, previous) : 0) + this.size(key, snapshot) + if ((!previous && this.tools.size >= 128) || bytes > 1024 * 1024) { + throw new Error('ACP tool snapshot budget exceeded') + } + this.tools.set(key, snapshot) + this.bytes = bytes + } +} diff --git a/src/main/acp/fixtures/s1-basic.jsonl b/src/main/acp/fixtures/s1-basic.jsonl new file mode 100644 index 000000000000..e15324dcc61a --- /dev/null +++ b/src/main/acp/fixtures/s1-basic.jsonl @@ -0,0 +1,11 @@ +{"direction":"out","process":"p1","message":{"jsonrpc":"2.0","id":3,"method":"session/prompt","params":{"sessionId":"session-1","prompt":[{"type":"text","text":"Fixture prompt"}]}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"I'll"}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" list"}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call","toolCallId":"call-1","title":"list_dir","rawInput":{"target_directory":"/workspace/file-1"},"_meta":{"x.ai/tool":{"name":"list_dir","kind":"list"}}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-1","kind":"other","title":"List `/workspace/file-1`","locations":[{"path":"/workspace/file-1"}],"rawInput":{"variant":"ListDir","target_directory":"/workspace/file-1"},"_meta":{"x.ai/tool":{"name":"list_dir","kind":"list"}}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call","toolCallId":"call-2","title":"read_file","rawInput":{"target_file":"/workspace/file-2"},"_meta":{"x.ai/tool":{"name":"read_file","kind":"read"}}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-2","kind":"read","title":"Read `/workspace/file-2`","locations":[{"path":"/workspace/file-2"}],"rawInput":{"variant":"ReadFile","target_file":"/workspace/file-2"},"_meta":{"x.ai/tool":{"name":"read_file","kind":"read"}}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-2","status":"completed","content":[{"type":"content","content":{"type":"text","text":"1\u2192# Scratch project 1\n\nA tiny throwaway project used to record Grok ACP traffic.\n"}}],"rawOutput":{"type":"ReadFile","FileContent":{"content":"1\u2192# Scratch project 1\n\nA tiny throwaway project used to record Grok ACP traffic.\n","content_concise":"1\u2192# Scratch project 1\n\nA tiny throwaway project used to record Grok ACP traffic.\n","absolute_path":"/workspace/file-2","offset":null,"raw_output":"# Scratch project 1\n\nA tiny throwaway project used to record Grok ACP traffic.\n","total_lines":4}}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-1","status":"completed","rawOutput":{"type":"ListDir","Content":{"content":"- /workspace/file-3\n - math.js\n - notes.txt\n - README.md","absolute_root_path":"/workspace/file-1"}}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"turn_completed","prompt_id":"turn-1","stop_reason":"end_turn","usage":{"inputTokens":68922,"outputTokens":195,"cachedReadTokens":35840,"cacheCreationTokens":0},"elapsed_ms":5289}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn","_meta":{"usage":{"inputTokens":68922,"outputTokens":195,"cachedReadTokens":35840,"cacheCreationTokens":0}}}}} diff --git a/src/main/acp/fixtures/s2-permission.jsonl b/src/main/acp/fixtures/s2-permission.jsonl new file mode 100644 index 000000000000..fe5198b52458 --- /dev/null +++ b/src/main/acp/fixtures/s2-permission.jsonl @@ -0,0 +1,20 @@ +{"direction":"out","process":"p1","message":{"jsonrpc":"2.0","id":3,"method":"session/prompt","params":{"sessionId":"session-1","prompt":[{"type":"text","text":"Fixture prompt"}]}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"I'll"}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" create"}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call","toolCallId":"call-1","title":"write","rawInput":{"file_path":"/workspace/file-1","content":"hello from grok\n"},"_meta":{"x.ai/tool":{"name":"write","kind":"write"}}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-1","kind":"edit","title":"Write `/workspace/file-1`","content":[{"type":"diff","path":"/workspace/file-1","oldText":"","newText":"hello from grok\n"}],"locations":[{"path":"/workspace/file-1"}],"rawInput":{"variant":"Write","file_path":"/workspace/file-1","content":"hello from grok\n"},"_meta":{"x.ai/tool":{"name":"write","kind":"write"}}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","id":0,"method":"session/request_permission","params":{"sessionId":"session-1","toolCall":{"toolCallId":"call-1","kind":"edit","title":"Write `/workspace/file-1` \u2014 hook 'plugin/capture-force-ask/hooks:pre_tool_use[0].hooks[0]' asks: capture harness forces a permission prompt","rawInput":{"variant":"Write","file_path":"/workspace/file-1","content":"hello from grok\n"},"_meta":{"x.ai/tool":{"name":"write","kind":"write"}}},"options":[{"optionId":"allow-edits-session","name":"Yes, allow all edits during this session","kind":"allow_always"},{"optionId":"allow-once","name":"Yes","kind":"allow_once"},{"optionId":"reject-once","name":"No, and tell Grok what to do differently","kind":"reject_once"}]}}} +{"direction":"out","process":"p1","message":{"jsonrpc":"2.0","id":0,"result":{"outcome":{"outcome":"selected","optionId":"allow-once"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-1","status":"completed","content":[{"type":"diff","path":"/workspace/file-1","oldText":"","newText":"hello from grok\n"}],"rawOutput":{"type":"SearchReplace","EditsApplied":{"old_string":"","new_string":"hello from grok\n","tool_output_for_prompt":"The file /workspace/file-1 has been created.","tool_output_for_prompt_concise":"The file /workspace/file-1 has been created.","absolute_path":"/workspace/file-1","edits":{"details":[{"old_string":"","old_line":1,"new_string":"hello from grok\n","new_line":1,"context_before":"","context_after":"","line_prefix":""}]}}}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"turn_completed","prompt_id":"turn-1","stop_reason":"end_turn","usage":{"inputTokens":69120,"outputTokens":260,"cachedReadTokens":36096,"cacheCreationTokens":0},"elapsed_ms":8673}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn","_meta":{"usage":{"inputTokens":69120,"outputTokens":260,"cachedReadTokens":36096,"cacheCreationTokens":0}}}}} +{"direction":"out","process":"p1","message":{"jsonrpc":"2.0","id":5,"method":"session/prompt","params":{"sessionId":"session-2","prompt":[{"type":"text","text":"Fixture prompt"}]}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-2","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"I'll"}},"_meta":{"promptId":"turn-2","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-2","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" create"}},"_meta":{"promptId":"turn-2","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-2","update":{"sessionUpdate":"tool_call","toolCallId":"call-2","title":"write","rawInput":{"file_path":"/workspace/file-2","content":"hello from grok\n"},"_meta":{"x.ai/tool":{"name":"write","kind":"write"}}},"_meta":{"promptId":"turn-2","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-2","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-2","kind":"edit","title":"Write `/workspace/file-2`","content":[{"type":"diff","path":"/workspace/file-2","oldText":"","newText":"hello from grok\n"}],"locations":[{"path":"/workspace/file-2"}],"rawInput":{"variant":"Write","file_path":"/workspace/file-2","content":"hello from grok\n"},"_meta":{"x.ai/tool":{"name":"write","kind":"write"}}},"_meta":{"promptId":"turn-2","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","id":1,"method":"session/request_permission","params":{"sessionId":"session-2","toolCall":{"toolCallId":"call-2","kind":"edit","title":"Write `/workspace/file-2` \u2014 hook 'plugin/capture-force-ask/hooks:pre_tool_use[0].hooks[0]' asks: capture harness forces a permission prompt","rawInput":{"variant":"Write","file_path":"/workspace/file-2","content":"hello from grok\n"},"_meta":{"x.ai/tool":{"name":"write","kind":"write"}}},"options":[{"optionId":"allow-edits-session","name":"Yes, allow all edits during this session","kind":"allow_always"},{"optionId":"allow-once","name":"Yes","kind":"allow_once"},{"optionId":"reject-once","name":"No, and tell Grok what to do differently","kind":"reject_once"}]}}} +{"direction":"out","process":"p1","message":{"jsonrpc":"2.0","id":1,"result":{"outcome":{"outcome":"selected","optionId":"reject-once"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-2","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-2","status":"failed","content":[{"type":"content","content":{"type":"text","text":"User rejected the execution for tool `write`"}}]},"_meta":{"promptId":"turn-2","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-2","update":{"sessionUpdate":"turn_completed","prompt_id":"turn-2","stop_reason":"cancelled","usage":{"inputTokens":34448,"outputTokens":163,"cachedReadTokens":1664,"cacheCreationTokens":0},"elapsed_ms":4475}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","id":5,"result":{"stopReason":"cancelled","_meta":{"usage":{"inputTokens":34448,"outputTokens":163,"cachedReadTokens":1664,"cacheCreationTokens":0}}}}} diff --git a/src/main/acp/fixtures/s3-cancel.jsonl b/src/main/acp/fixtures/s3-cancel.jsonl new file mode 100644 index 000000000000..a8828e5ef760 --- /dev/null +++ b/src/main/acp/fixtures/s3-cancel.jsonl @@ -0,0 +1,13 @@ +{"direction":"out","process":"p1","message":{"jsonrpc":"2.0","id":3,"method":"session/prompt","params":{"sessionId":"session-1","prompt":[{"type":"text","text":"Fixture prompt"}]}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"I'll"}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" run"}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call","toolCallId":"call-1","title":"run_terminal_command","rawInput":{"command":"sleep 20; echo finished > cancel-marker.txt","description":"Sleep 20s then write cancel marker","block_until_ms":35000},"_meta":{"x.ai/tool":{"name":"run_terminal_command","kind":"execute"}}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-1","kind":"execute","title":"Execute `sleep 20; echo finished > cancel-marker.txt`","content":[{"type":"content","content":{"type":"text","text":"Sleep 20s then write cancel marker"}}],"locations":[],"rawInput":{"variant":"Bash","command":"sleep 20; echo finished > cancel-marker.txt","description":"Sleep 20s then write cancel marker","is_background":false,"block_until_ms":35000},"_meta":{"x.ai/tool":{"name":"run_terminal_command","kind":"execute"}}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-1","status":"in_progress","content":[{"type":"content","content":{"type":"text","text":""}}],"rawOutput":{"type":"Bash","output":[],"output_for_prompt":"","exit_code":0,"command":"sleep 20; echo finished > cancel-marker.txt","truncated":false,"signal":null,"timed_out":false,"description":null,"current_dir":"/workspace/file-1","output_file":"","total_bytes":0}}}}} +{"direction":"out","process":"p1","message":{"jsonrpc":"2.0","method":"session/cancel","params":{"sessionId":"session-1"}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"turn_completed","prompt_id":"turn-1","stop_reason":"cancelled","usage":{"inputTokens":34344,"outputTokens":318,"cachedReadTokens":1664,"cacheCreationTokens":0},"elapsed_ms":7773}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","id":3,"result":{"stopReason":"cancelled","_meta":{"usage":{"inputTokens":34344,"outputTokens":318,"cachedReadTokens":1664,"cacheCreationTokens":0}}}}} +{"direction":"out","process":"p1","message":{"jsonrpc":"2.0","id":4,"method":"session/prompt","params":{"sessionId":"session-1","prompt":[{"type":"text","text":"Fixture prompt"}]}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"ok"}},"_meta":{"promptId":"turn-2","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"turn_completed","prompt_id":"turn-2","stop_reason":"end_turn","usage":{"inputTokens":34876,"outputTokens":189,"cachedReadTokens":34304,"cacheCreationTokens":0},"elapsed_ms":3373}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","id":4,"result":{"stopReason":"end_turn","_meta":{"usage":{"inputTokens":34876,"outputTokens":189,"cachedReadTokens":34304,"cacheCreationTokens":0}}}}} diff --git a/src/main/acp/fixtures/s4-resume.jsonl b/src/main/acp/fixtures/s4-resume.jsonl new file mode 100644 index 000000000000..479e13236172 --- /dev/null +++ b/src/main/acp/fixtures/s4-resume.jsonl @@ -0,0 +1,23 @@ +{"direction":"out","process":"A","message":{"jsonrpc":"2.0","id":3,"method":"session/prompt","params":{"sessionId":"session-1","prompt":[{"type":"text","text":"Fixture prompt"}]}}} +{"direction":"in","process":"A","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"I'll"}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} +{"direction":"in","process":"A","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" read"}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} +{"direction":"in","process":"A","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call","toolCallId":"call-1","title":"grep","rawInput":{"pattern":".","glob":"notes.txt"},"_meta":{"x.ai/tool":{"name":"grep","kind":"search"}}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} +{"direction":"in","process":"A","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-1","kind":"search","title":".","locations":[],"rawInput":{"variant":"Grep","pattern":".","path":null,"glob":"notes.txt","-i":false,"type":null,"multiline":false},"_meta":{"x.ai/tool":{"name":"grep","kind":"search"}}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} +{"direction":"in","process":"A","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call","toolCallId":"call-2","title":"list_dir","rawInput":{"target_directory":"/workspace/file-1"},"_meta":{"x.ai/tool":{"name":"list_dir","kind":"list"}}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} +{"direction":"in","process":"A","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-2","kind":"other","title":"List `/workspace/file-1`","locations":[{"path":"/workspace/file-1"}],"rawInput":{"variant":"ListDir","target_directory":"/workspace/file-1"},"_meta":{"x.ai/tool":{"name":"list_dir","kind":"list"}}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} +{"direction":"in","process":"A","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-2","status":"completed","rawOutput":{"type":"ListDir","Content":{"content":"- /workspace/file-2\n - math.js\n - notes.txt\n - README.md","absolute_root_path":"/workspace/file-1"}}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} +{"direction":"in","process":"A","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-1","status":"completed","content":[{"type":"content","content":{"type":"text","text":"found 3 matches"}}],"rawOutput":{"type":"GrepSearch","stdout":[60,119,111,114,107,115,112,97,99,101,95,114,101,115,117,108,116,32,119,111,114,107,115,112,97,99,101,95,112,97,116,104,61,34,47,119,111,114,107,115,112,97,99,101,47,112,114,111,106,101,99,116,34,62,10,47,119,111,114,107,115,112,97,99,101,47,112,114,111,106,101,99,116,47,110,111,116,101,115,46,116,120,116,58,101,120,97,109,112,108,101,10,60,47,119,111,114,107,115,112,97,99,101,95,114,101,115,117,108,116,62],"stderr":[],"exit_code":0,"match_count":3,"file_matches":[{"path":"/workspace/file-3","matches":[{"line_number":1,"content":"apples"},{"line_number":2,"content":"bananas"},{"line_number":3,"content":"cherries"}]}]}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} +{"direction":"in","process":"A","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"turn_completed","prompt_id":"turn-1","stop_reason":"end_turn","usage":{"inputTokens":68864,"outputTokens":121,"cachedReadTokens":35968,"cacheCreationTokens":0},"elapsed_ms":5043}}}} +{"direction":"in","process":"A","message":{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn","_meta":{"usage":{"inputTokens":68864,"outputTokens":121,"cachedReadTokens":35968,"cacheCreationTokens":0}}}}} +{"direction":"out","process":"B","message":{"jsonrpc":"2.0","id":2,"method":"session/load","params":{"sessionId":"session-1"}}} +{"direction":"in","process":"B","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"user_message_chunk","content":{"type":"text","text":"Read notes.txt and tell me its second line."},"_meta":{"promptIndex":0}},"_meta":{"isReplay":true}}}} +{"direction":"in","process":"B","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"I'll read `notes.txt` and report the second line."}},"_meta":{"promptId":"turn-1","turnStartMs":1000,"isReplay":true}}}} +{"direction":"in","process":"B","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call","toolCallId":"call-2","title":"List `/workspace/file-1`","status":"completed","locations":[{"path":"/workspace/file-1"}],"rawInput":{"variant":"ListDir","target_directory":"/workspace/file-1"},"rawOutput":{"type":"ListDir","Content":{"content":"- /workspace/file-2\n - math.js\n - notes.txt\n - README.md","absolute_root_path":"/workspace/file-1"}},"_meta":{"x.ai/tool":{"name":"list_dir","kind":"list"}}},"_meta":{"promptId":"turn-1","turnStartMs":1000,"isReplay":true}}}} +{"direction":"in","process":"B","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call","toolCallId":"call-1","title":".","kind":"search","status":"completed","content":[{"type":"content","content":{"type":"text","text":"found 3 matches"}}],"rawInput":{"variant":"Grep","pattern":".","path":null,"glob":"notes.txt","-i":false,"type":null,"multiline":false},"rawOutput":{"type":"GrepSearch","stdout":[60,119,111,114,107,115,112,97,99,101,95,114,101,115,117,108,116,32,119,111,114,107,115,112,97,99,101,95,112,97,116,104,61,34,47,119,111,114,107,115,112,97,99,101,47,112,114,111,106,101,99,116,34,62,10,47,119,111,114,107,115,112,97,99,101,47,112,114,111,106,101,99,116,47,110,111,116,101,115,46,116,120,116,58,101,120,97,109,112,108,101,10,60,47,119,111,114,107,115,112,97,99,101,95,114,101,115,117,108,116,62],"stderr":[],"exit_code":0,"match_count":3,"file_matches":[{"path":"/workspace/file-3","matches":[{"line_number":1,"content":"apples"},{"line_number":2,"content":"bananas"},{"line_number":3,"content":"cherries"}]}]},"_meta":{"x.ai/tool":{"name":"grep","kind":"search"}}},"_meta":{"promptId":"turn-1","turnStartMs":1000,"isReplay":true}}}} +{"direction":"in","process":"B","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"The second line of `notes.txt` is `bananas`."}},"_meta":{"promptId":"turn-1","turnStartMs":1000,"isReplay":true}}}} +{"direction":"in","process":"B","message":{"jsonrpc":"2.0","method":"_x.ai/session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"turn_completed","prompt_id":"turn-1","stop_reason":"end_turn","usage":{"inputTokens":68864,"outputTokens":121,"cachedReadTokens":35968,"cacheCreationTokens":0},"elapsed_ms":5043},"_meta":{"isReplay":true}}}} +{"direction":"out","process":"B","message":{"jsonrpc":"2.0","id":3,"method":"session/prompt","params":{"sessionId":"session-1","prompt":[{"type":"text","text":"Fixture prompt"}]}}} +{"direction":"in","process":"B","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"The"}},"_meta":{"promptId":"turn-2","turnStartMs":1000}}}} +{"direction":"in","process":"B","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" second"}},"_meta":{"promptId":"turn-2","turnStartMs":1000}}}} +{"direction":"in","process":"B","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"turn_completed","prompt_id":"turn-2","stop_reason":"end_turn","usage":{"inputTokens":34621,"outputTokens":36,"cachedReadTokens":34432,"cacheCreationTokens":0},"elapsed_ms":1829}}}} +{"direction":"in","process":"B","message":{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn","_meta":{"usage":{"inputTokens":34621,"outputTokens":36,"cachedReadTokens":34432,"cacheCreationTokens":0}}}}} diff --git a/src/main/acp/fixtures/s5-plan-approved.jsonl b/src/main/acp/fixtures/s5-plan-approved.jsonl new file mode 100644 index 000000000000..43b2e30a194e --- /dev/null +++ b/src/main/acp/fixtures/s5-plan-approved.jsonl @@ -0,0 +1,15 @@ +{"direction":"out","process":"p1","message":{"jsonrpc":"2.0","id":4,"method":"session/prompt","params":{"sessionId":"session-1","prompt":[{"type":"text","text":"Fixture prompt"}]}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"I'll"}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" look"}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call","toolCallId":"call-1","title":"ask_user_question","rawInput":{"questions":[{"question":"Which test approach do you want for the subtract function?","options":[{"label":"node:test (Recommended)","description":"A math.test.js file using Node's built-in test runner. Run it with node --test. No new dependencies or package.json."},{"label":"Plain assert script","description":"A small math.test.js that uses node:assert and exits non-zero on failure. Run it with node math.test.js. No test framework."},{"label":"Add a package.json and a test script","description":"Add package.json with a test script that runs the same Node built-in tests, so npm test works."}]}]},"_meta":{"x.ai/tool":{"name":"ask_user_question","kind":"ask_user"}}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-1","kind":"other","title":"Ask: Which test approach do you want for the subtract function?","locations":[],"rawInput":{"variant":"AskUserQuestion","questions":[{"question":"Which test approach do you want for the subtract function?","options":[{"label":"node:test (Recommended)","description":"A math.test.js file using Node's built-in test runner. Run it with node --test. No new dependencies or package.json."},{"label":"Plain assert script","description":"A small math.test.js that uses node:assert and exits non-zero on failure. Run it with node math.test.js. No test framework."},{"label":"Add a package.json and a test script","description":"Add package.json with a test script that runs the same Node built-in tests, so npm test works."}],"multiSelect":null}]},"_meta":{"x.ai/tool":{"name":"ask_user_question","kind":"ask_user"}}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","id":0,"method":"_x.ai/ask_user_question","params":{"sessionId":"session-1","toolCallId":"call-1","questions":[{"question":"Which test approach do you want for the subtract function?","options":[{"label":"node:test (Recommended)","description":"A math.test.js file using Node's built-in test runner. Run it with node --test. No new dependencies or package.json."},{"label":"Plain assert script","description":"A small math.test.js that uses node:assert and exits non-zero on failure. Run it with node math.test.js. No test framework."},{"label":"Add a package.json and a test script","description":"Add package.json with a test script that runs the same Node built-in tests, so npm test works."}],"multiSelect":null}],"mode":"default"}}} +{"direction":"out","process":"p1","message":{"jsonrpc":"2.0","id":0,"result":{"outcome":"accepted","answers":{"Which test approach do you want for the subtract function?":"node:test (Recommended)"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-1","status":"completed","content":[{"type":"content","content":{"type":"text","text":"User has answered your questions: \"Which test approach do you want for the subtract function?\"=\"node:test (Recommended)\". You can now continue with the user's answers in mind."}}],"rawOutput":{"type":"AskUserQuestion","UserAnswered":{"message":"User has answered your questions: \"Which test approach do you want for the subtract function?\"=\"node:test (Recommended)\". You can now continue with the user's answers in mind."}}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call","toolCallId":"call-2","title":"exit_plan_mode","rawInput":{},"_meta":{"x.ai/tool":{"name":"exit_plan_mode","kind":"exit_plan"}}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-2","kind":"other","title":"Plan: Exit","locations":[],"rawInput":{"variant":"ExitPlanMode"},"_meta":{"x.ai/tool":{"name":"exit_plan_mode","kind":"exit_plan"}}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","id":1,"method":"_x.ai/exit_plan_mode","params":{"sessionId":"session-1","toolCallId":"call-2","planContent":"# Plan\n\nUpdate the example and run its tests."}}} +{"direction":"out","process":"p1","message":{"jsonrpc":"2.0","id":1,"result":{"outcome":"approved"}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-2","status":"completed","title":"Plan mode exited","content":[{"type":"content","content":{"type":"text","text":"Plan file: /workspace/file-1"}}],"rawOutput":{"type":"ExitPlanMode","PlanReady":{"message":"Your plan has been approved. You can now start coding.","plan_content":"# Add subtract and a node:test\n\n## Problem\n\n`math.js` only exports `add(a, b)`. There is no `subtract`, and nothing in this folder can check that the math functions return the right values.\n\n## User-facing change\n\nCallers can import `subtract` from `math.js` and get `a - b`. A new test file checks that behavior and can be run with Node's built-in test runner.\n\n## Approach\n\nStay inside this folder. No new dependencies and no `package.json`. You chose `node:test`.\n\n1. In `math.js`, add `subtract(a, b)` next to `add`, using the same style: a named export that returns `a - b`.\n2. Add `math.test.js` that imports `subtract` and uses `node:test` plus `node:assert/strict`. Cover a positive difference, a negative difference, and zero.\n3. Run `node --test math.test.js` from this folder and confirm the tests pass.\n\n## Out of scope\n\n- Changing `add`\n- Editing `README.md` or `notes.txt`\n- Adding a package manager, test script, or extra files\n","plan_file_path":"/workspace/file-1"}}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"turn_completed","prompt_id":"turn-1","stop_reason":"end_turn","usage":{"inputTokens":331949,"outputTokens":3858,"cachedReadTokens":294144,"cacheCreationTokens":0},"elapsed_ms":64864}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","id":4,"result":{"stopReason":"end_turn","_meta":{"usage":{"inputTokens":331949,"outputTokens":3858,"cachedReadTokens":294144,"cacheCreationTokens":0}}}}} diff --git a/src/main/acp/fixtures/s6-background.jsonl b/src/main/acp/fixtures/s6-background.jsonl new file mode 100644 index 000000000000..e63527fd9d23 --- /dev/null +++ b/src/main/acp/fixtures/s6-background.jsonl @@ -0,0 +1,11 @@ +{"direction":"out","process":"p1","message":{"jsonrpc":"2.0","id":3,"method":"session/prompt","params":{"sessionId":"session-1","prompt":[{"type":"text","text":"Fixture prompt"}]}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"I'll"}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" start"}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call","toolCallId":"call-1","title":"run_terminal_command","rawInput":{"command":"sleep 15; echo bg-done > bg-marker.txt","description":"Sleep then write bg-marker.txt","block_until_ms":0},"_meta":{"x.ai/tool":{"name":"run_terminal_command","kind":"execute"}}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-1","kind":"execute","title":"Execute `sleep 15; echo bg-done > bg-marker.txt`","content":[{"type":"content","content":{"type":"text","text":"Sleep then write bg-marker.txt"}}],"locations":[],"rawInput":{"variant":"Bash","command":"sleep 15; echo bg-done > bg-marker.txt","description":"Sleep then write bg-marker.txt","is_background":false,"block_until_ms":0},"_meta":{"x.ai/tool":{"name":"run_terminal_command","kind":"execute"}}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-1","status":"completed","title":"[bg] sleep 15; echo bg-done > bg-marker.txt (01a10366)","content":[{"type":"content","content":{"type":"text","text":"Background task 01a10366-1e7f-7191-bcd1-fac24585851f started"}}],"rawOutput":{"type":"BackgroundTaskStarted","task_id":"01a10366-1e7f-7191-bcd1-fac24585851f","task_type":"bash","output_file":"/workspace/file-1","status":"running","command":"sleep 15; echo bg-done > bg-marker.txt","summary":"Background task 01a10366-1e7f-7191-bcd1-fac24585851f started","retrieval_hint":"Use get_command_or_subagent_output with task_ids=[\"01a10366-1e7f-7191-bcd1-fac24585851f\"] when you need the output."}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"turn_completed","prompt_id":"turn-1","stop_reason":"end_turn","usage":{"inputTokens":69127,"outputTokens":207,"cachedReadTokens":35968,"cacheCreationTokens":0},"elapsed_ms":5774}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn","_meta":{"usage":{"inputTokens":69127,"outputTokens":207,"cachedReadTokens":35968,"cacheCreationTokens":0}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"The"}},"_meta":{"promptId":"task-completed-2","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" background"}},"_meta":{"promptId":"task-completed-2","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"turn_completed","prompt_id":"task-completed-2","stop_reason":"end_turn","usage":{"inputTokens":34928,"outputTokens":219,"cachedReadTokens":34688,"cacheCreationTokens":0},"elapsed_ms":4520}}}} From 99138f10844e766bda1e7deb8f75b4c6fb9d9fb3 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 22:46:52 -0700 Subject: [PATCH 18/62] Preserve fixture answer linkage and handle all typed ACP updates --- src/main/acp/acp-session-update.ts | 5 ++++- src/main/acp/acp-timeline-fixture.test-support.ts | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/main/acp/acp-session-update.ts b/src/main/acp/acp-session-update.ts index 516b95d465ad..51040467551c 100644 --- a/src/main/acp/acp-session-update.ts +++ b/src/main/acp/acp-session-update.ts @@ -81,7 +81,10 @@ export function acpSessionUpdate( case 'session_info_update': // These feed commands, options and the session title, outside the timeline. return [] - default: + case 'plan_update': + case 'plan_removed': + case 'compaction_update': + case 'compaction_summary_chunk': return [{ type: 'provider.frame', frameKind: update.sessionUpdate, payload: update, ...join }] } } diff --git a/src/main/acp/acp-timeline-fixture.test-support.ts b/src/main/acp/acp-timeline-fixture.test-support.ts index a019328a9373..ffe89fcc17b5 100644 --- a/src/main/acp/acp-timeline-fixture.test-support.ts +++ b/src/main/acp/acp-timeline-fixture.test-support.ts @@ -140,7 +140,7 @@ export async function openAcpFixtureRig() { resolvedAt: at } }, - { fence: 1 } + { fence: 1, turnScope: row.turnScope ?? { kind: 'thread' } } ) requests.delete(message.id) } else if (direction === 'in' && message.method) { From c484c9bc3adfd2e5f9bc0510df85de6d89f3ab77 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 22:46:53 -0700 Subject: [PATCH 19/62] Keep replay chunks and long-turn tool snapshots bounded --- src/main/acp/acp-replay-user-messages.ts | 43 ++++++++++ src/main/acp/acp-session-update.ts | 6 +- src/main/acp/acp-timeline-generic.test.ts | 87 ++++++++++++++++++++ src/main/acp/acp-timeline-recordings.test.ts | 1 + src/main/acp/acp-timeline-translator.ts | 47 ++++++++--- src/main/acp/acp-tool-timeline.ts | 20 ++++- 6 files changed, 187 insertions(+), 17 deletions(-) create mode 100644 src/main/acp/acp-replay-user-messages.ts diff --git a/src/main/acp/acp-replay-user-messages.ts b/src/main/acp/acp-replay-user-messages.ts new file mode 100644 index 000000000000..189d2e6aead5 --- /dev/null +++ b/src/main/acp/acp-replay-user-messages.ts @@ -0,0 +1,43 @@ +import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types' +import { + boundInlineText, + DEFAULT_JOURNAL_PAYLOAD_LIMITS +} from '../native-chat/agent-session-journal/journal-payload-bounds' +import type { SessionUpdate } from './generated/acp-protocol.generated' + +/** User text has no grammar stream channel; adopted history needs a bounded whole-body snapshot. */ +export class AcpReplayUserMessages { + private current?: { id: string | null; text: string } + + observe(update: SessionUpdate): { startsMessage: boolean; body?: AgentJournalMessageItem } { + if (update.sessionUpdate !== 'user_message_chunk') { + if ( + [ + 'agent_message_chunk', + 'agent_thought_chunk', + 'tool_call', + 'tool_call_update', + 'plan' + ].includes(update.sessionUpdate) + ) { + this.current = undefined + } + return { startsMessage: false } + } + const id = update.messageId ?? null + const startsMessage = !this.current || this.current.id !== id + const previous = startsMessage ? '' : this.current!.text + if (update.content.type !== 'text') { + return { startsMessage } + } + const text = boundInlineText( + previous + update.content.text, + DEFAULT_JOURNAL_PAYLOAD_LIMITS + ).text + this.current = { id, text } + return { + startsMessage, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text }] } + } + } +} diff --git a/src/main/acp/acp-session-update.ts b/src/main/acp/acp-session-update.ts index 51040467551c..d314e72c1297 100644 --- a/src/main/acp/acp-session-update.ts +++ b/src/main/acp/acp-session-update.ts @@ -1,3 +1,4 @@ +import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types' import type { ProviderTimelineEvent } from '../native-chat/agent-session-timeline/provider-timeline-event' import { boundInlineText, @@ -14,7 +15,8 @@ export function acpSessionUpdate( at: number, replay: boolean, tools: AcpToolTimeline, - dialect: AcpDialect + dialect: AcpDialect, + replayUserBody?: AgentJournalMessageItem ): ProviderTimelineEvent[] { const update = notification.update const join = turn === undefined ? {} : { join: { turn } } @@ -40,7 +42,7 @@ export function acpSessionUpdate( { type: 'item.update', item: `replay-user:${turn}:${update.messageId ?? ''}`, - body: { + body: replayUserBody ?? { kind: 'message', role: 'user', blocks: [{ type: 'text', text: update.content.text }] diff --git a/src/main/acp/acp-timeline-generic.test.ts b/src/main/acp/acp-timeline-generic.test.ts index ab316b6504db..56de41ec7762 100644 --- a/src/main/acp/acp-timeline-generic.test.ts +++ b/src/main/acp/acp-timeline-generic.test.ts @@ -266,4 +266,91 @@ describe('generic ACP translation', () => { apply(translator.promptResult('send-1', { stopReason: 'end_turn' }, 1200)) expect((await rig.rows()).some((row) => messageText(row.body) === 'typed')).toBe(true) }) + it('adopts streamed user history across multiple turns without replacing earlier users', async () => { + const { rig, translator, apply, update } = await genericRig() + translator.beginLoad() + update({ + sessionUpdate: 'user_message_chunk', + messageId: 'user-1', + content: { type: 'text', text: 'First ' } + }) + update({ + sessionUpdate: 'user_message_chunk', + messageId: 'user-1', + content: { type: 'text', text: 'question' } + }) + update({ + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'First answer' } + }) + update({ sessionUpdate: 'user_message_chunk', content: { type: 'text', text: 'Second ' } }) + update({ sessionUpdate: 'user_message_chunk', content: { type: 'text', text: 'question' } }) + update({ + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'Second answer' } + }) + apply(translator.finishLoad(1200)) + expect( + (await rig.rows()).flatMap((row) => + row.body.kind === 'message' ? [messageText(row.body)] : [] + ) + ).toEqual(['First question', 'First answer', 'Second question', 'Second answer']) + expect(await rig.turns()).toHaveLength(2) + }) + + it('evicts completed snapshots during long turns while preserving active tool input', async () => { + const { rig, translator, apply, update } = await genericRig() + apply(translator.openPrompt('send-1', 1000)) + update({ + sessionUpdate: 'tool_call', + toolCallId: 'active', + title: 'Active', + rawInput: { important: true } + }) + for (let index = 0; index < 140; index += 1) { + update({ + sessionUpdate: 'tool_call', + toolCallId: `done-${index}`, + title: 'Read', + status: 'pending' + }) + update({ + sessionUpdate: 'tool_call_update', + toolCallId: `done-${index}`, + status: 'completed' + }) + await rig.rows() + } + update({ sessionUpdate: 'tool_call_update', toolCallId: 'active', status: 'failed' }) + apply(translator.promptResult('send-1', { stopReason: 'cancelled' }, 1200)) + expect((await rig.row(providerItemId('item', 'tool:active')))?.body).toMatchObject({ + name: 'Active', + input: { important: true }, + state: 'failed' + }) + expect( + (await rig.rows()).filter( + (row) => row.body.kind === 'tool-call' && row.body.state === 'completed' + ) + ).toHaveLength(140) + }) + + it('keeps a valid replacement patch when a diff exceeds the edit-search budget', async () => { + const { rig, update } = await genericRig() + const oldText = Array.from({ length: 520 }, (_, index) => `old-${index}\n`).join('') + const newText = Array.from({ length: 520 }, (_, index) => `new-${index}\n`).join('') + update({ + sessionUpdate: 'tool_call', + toolCallId: 'edit', + title: 'Edit', + status: 'completed', + content: [{ type: 'diff', path: 'file.ts', oldText, newText }] + }) + const body = (await rig.rows()).find((row) => row.body.kind === 'diff')?.body + if (body?.kind !== 'diff') { + throw new Error('Missing diff') + } + expect(body.patch.truncated).toBe(false) + expect(applyPatch(oldText, body.patch.head)).toBe(newText) + }) }) diff --git a/src/main/acp/acp-timeline-recordings.test.ts b/src/main/acp/acp-timeline-recordings.test.ts index 4dccaac7ee8a..7155e690f69d 100644 --- a/src/main/acp/acp-timeline-recordings.test.ts +++ b/src/main/acp/acp-timeline-recordings.test.ts @@ -28,6 +28,7 @@ describe('recorded ACP traffic through the journal', () => { expect((await fixture.rig.turns())[0]).toMatchObject({ state: 'completed', outcome: 'success', + durationMs: 5289, contextUsage: { used: { kind: 'estimate' } } }) expect(tools.every((tool) => tool.turnScope?.kind === 'turn')).toBe(true) diff --git a/src/main/acp/acp-timeline-translator.ts b/src/main/acp/acp-timeline-translator.ts index 04d629e3a0db..74a8c2f19335 100644 --- a/src/main/acp/acp-timeline-translator.ts +++ b/src/main/acp/acp-timeline-translator.ts @@ -8,6 +8,7 @@ import { type AcpDialectNotification, type AcpRequestPresentation } from './acp-dialects/acp-dialect' +import { AcpReplayUserMessages } from './acp-replay-user-messages' import { AcpRpcError } from './acp-errors' import type { AcpSessionEvent } from './acp-session-runtime' import { acpPermissionPresentation } from './acp-timeline-requests' @@ -39,8 +40,8 @@ export function acpTurnEnd( } } -type PromptTurn = { turn: string; providerTurn?: string } -type LoadReplay = { adopt: boolean; turn?: string; serial: number } +type PromptTurn = { turn: string; providerTurn?: string; durationMs?: number } +type LoadReplay = { adopt: boolean; turn?: string; serial: number; users: AcpReplayUserMessages } export type AcpTimelineTranslatorOptions = { sessionId: string @@ -93,7 +94,14 @@ export class AcpTimelineTranslator { join: { turn } }) } - events.push(acpTurnEnd(turn, result.stopReason, at)) + events.push( + acpTurnEnd( + turn, + result.stopReason, + at, + this.prompt?.turn === turn ? this.prompt.durationMs : undefined + ) + ) this.tools.end(turn) if (this.prompt?.turn === turn) { this.prompt = undefined @@ -105,7 +113,11 @@ export class AcpTimelineTranslator { if (this.prompt || this.replay) { throw new Error('ACP load overlaps a prompt or load') } - this.replay = { adopt: this.options.journalItems().length === 0, serial: 0 } + this.replay = { + adopt: this.options.journalItems().length === 0, + serial: 0, + users: new AcpReplayUserMessages() + } } finishLoad(at: number): ProviderTimelineEvent[] { @@ -156,13 +168,11 @@ export class AcpTimelineTranslator { 'tool_call_update', 'plan' ].includes(standard.data.update.sessionUpdate) - if ( - isReplay && - this.replay?.adopt && - this.replay.turn && - standard?.success && - standard.data.update.sessionUpdate === 'user_message_chunk' - ) { + const replayUser = + isReplay && this.replay?.adopt && standard?.success + ? this.replay.users.observe(standard.data.update) + : undefined + if (replayUser?.startsMessage && this.replay?.turn) { events.push({ type: 'turn.end', turn: this.replay.turn, at, state: 'completed' }) this.tools.end(this.replay.turn) this.replay.turn = undefined @@ -180,7 +190,17 @@ export class AcpTimelineTranslator { } if (extension?.end && turn) { // Client prompts settle on their own response; extension completions own autonomous turns. - if (this.prompt?.turn !== turn) { + if (this.prompt?.turn === turn) { + this.prompt.durationMs = extension.end.durationMs + } else { + if (!['end_turn', 'cancelled'].includes(extension.end.stopReason)) { + events.push({ + type: 'provider.frame', + frameKind: `turn:${extension.end.stopReason}`, + payload: params, + ...join + }) + } events.push(acpTurnEnd(turn, extension.end.stopReason, at, extension.end.durationMs)) this.tools.end(turn) if (this.replay?.turn === turn) { @@ -198,7 +218,8 @@ export class AcpTimelineTranslator { at, isReplay && (this.replay?.adopt ?? false), this.tools, - this.dialect + this.dialect, + replayUser?.body ) ] } diff --git a/src/main/acp/acp-tool-timeline.ts b/src/main/acp/acp-tool-timeline.ts index 84f643a2ec63..71ce27647e42 100644 --- a/src/main/acp/acp-tool-timeline.ts +++ b/src/main/acp/acp-tool-timeline.ts @@ -160,10 +160,26 @@ export class AcpToolTimeline { private remember(key: string, snapshot: ToolSnapshot): void { const previous = this.tools.get(key) - const bytes = this.bytes - (previous ? this.size(key, previous) : 0) + this.size(key, snapshot) - if ((!previous && this.tools.size >= 128) || bytes > 1024 * 1024) { + let bytes = this.bytes - (previous ? this.size(key, previous) : 0) + this.size(key, snapshot) + let count = this.tools.size + (previous ? 0 : 1) + const evicted: string[] = [] + for (const [candidate, stored] of this.tools) { + if (count <= 128 && bytes <= 1024 * 1024) { + break + } + if (candidate !== key && stored.body.state !== 'running') { + evicted.push(candidate) + count -= 1 + bytes -= this.size(candidate, stored) + } + } + if (count > 128 || bytes > 1024 * 1024) { throw new Error('ACP tool snapshot budget exceeded') } + for (const candidate of evicted) { + this.tools.delete(candidate) + } + this.tools.delete(key) this.tools.set(key, snapshot) this.bytes = bytes } From fa85fc30c389374f8568992ed54eb392d03b6277 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 22:46:54 -0700 Subject: [PATCH 20/62] Forward ACP joins to the journal-derived timeline assembler --- src/main/acp/acp-session-update.ts | 4 +- src/main/acp/acp-timeline-generic.test.ts | 13 ++- src/main/acp/acp-timeline-joins.test.ts | 134 ++++++++++++++++++++++ src/main/acp/acp-timeline-translator.ts | 21 +++- src/main/acp/acp-tool-timeline.ts | 21 ++-- 5 files changed, 173 insertions(+), 20 deletions(-) create mode 100644 src/main/acp/acp-timeline-joins.test.ts diff --git a/src/main/acp/acp-session-update.ts b/src/main/acp/acp-session-update.ts index d314e72c1297..57eb2db9881c 100644 --- a/src/main/acp/acp-session-update.ts +++ b/src/main/acp/acp-session-update.ts @@ -19,7 +19,7 @@ export function acpSessionUpdate( replayUserBody?: AgentJournalMessageItem ): ProviderTimelineEvent[] { const update = notification.update - const join = turn === undefined ? {} : { join: { turn } } + const join = { join: { thread: notification.sessionId, ...(turn === undefined ? {} : { turn }) } } switch (update.sessionUpdate) { case 'agent_message_chunk': case 'agent_thought_chunk': @@ -53,7 +53,7 @@ export function acpSessionUpdate( : [] case 'tool_call': case 'tool_call_update': - return tools.translate(update, dialect, turn) + return tools.translate(update, dialect, join.join) case 'plan': return [ { diff --git a/src/main/acp/acp-timeline-generic.test.ts b/src/main/acp/acp-timeline-generic.test.ts index 56de41ec7762..8cfdc3c38dec 100644 --- a/src/main/acp/acp-timeline-generic.test.ts +++ b/src/main/acp/acp-timeline-generic.test.ts @@ -4,8 +4,7 @@ import { contextTokensFromUsage } from '../../shared/agent-session-context-usage import { closeProviderTimelineRigs, messageText, - openProviderTimelineRig, - providerItemId + openProviderTimelineRig } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' import type { ProviderTimelineEvent } from '../native-chat/agent-session-timeline/provider-timeline-event' import { AcpTimelineTranslator, acpTurnEnd } from './acp-timeline-translator' @@ -101,7 +100,9 @@ describe('generic ACP translation', () => { // A settled journal row refuses a late provider update that says it is running again. update({ sessionUpdate: 'tool_call_update', toolCallId: 'tool-1', status: 'in_progress' }) apply(translator.promptResult('send-1', { stopReason: 'end_turn' }, 1200)) - const body = (await rig.row(providerItemId('item', 'tool:tool-1')))?.body + const body = (await rig.rows()).find( + (row) => row.body.kind === 'tool-call' && row.body.callId === 'tool-1' + )?.body expect(body).toMatchObject({ name: 'edit_file', state: 'completed', @@ -323,7 +324,11 @@ describe('generic ACP translation', () => { } update({ sessionUpdate: 'tool_call_update', toolCallId: 'active', status: 'failed' }) apply(translator.promptResult('send-1', { stopReason: 'cancelled' }, 1200)) - expect((await rig.row(providerItemId('item', 'tool:active')))?.body).toMatchObject({ + expect( + (await rig.rows()).find( + (row) => row.body.kind === 'tool-call' && row.body.callId === 'active' + )?.body + ).toMatchObject({ name: 'Active', input: { important: true }, state: 'failed' diff --git a/src/main/acp/acp-timeline-joins.test.ts b/src/main/acp/acp-timeline-joins.test.ts new file mode 100644 index 000000000000..2b9dafaff228 --- /dev/null +++ b/src/main/acp/acp-timeline-joins.test.ts @@ -0,0 +1,134 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { createCodexProviderTimelineIdentityScheme } from '../codex/codex-provider-timeline-identity' +import { + closeProviderTimelineRigs, + messageText, + openProviderTimelineRig +} from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import type { ProviderTimelineEvent } from '../native-chat/agent-session-timeline/provider-timeline-event' +import { AcpTimelineTranslator } from './acp-timeline-translator' + +afterEach(closeProviderTimelineRigs) + +describe('ACP joins through the v3 assembler', () => { + it('keeps provider message and tool ids apart across ACP sessions', async () => { + const rig = await openProviderTimelineRig() + for (const sessionId of ['session-a', 'session-b']) { + const translator = new AcpTimelineTranslator({ + sessionId, + journalItems: () => rig.journal.snapshot().items + }) + for (const update of [ + { + sessionUpdate: 'agent_message_chunk', + messageId: 'same-message', + content: { type: 'text', text: sessionId } + }, + { + sessionUpdate: 'tool_call', + toolCallId: 'same-tool', + title: sessionId, + status: 'completed' + } + ]) { + for (const event of translator.notification( + 'session/update', + { sessionId, update }, + 1100 + )) { + expect(rig.assembler.apply(event).admission.accepted).toBe(true) + } + } + } + const rows = await rig.rows() + expect( + rows.filter((row) => row.body.kind === 'message').map((row) => messageText(row.body)) + ).toEqual(['session-a', 'session-b']) + expect(rows.flatMap((row) => (row.body.kind === 'tool-call' ? [row.body.name] : []))).toEqual([ + 'session-a', + 'session-b' + ]) + }) + + it('resumes named text in its existing ordinal row and closes the same joined stream', async () => { + const rig = await openProviderTimelineRig({ + scheme: createCodexProviderTimelineIdentityScheme({ + sessionId: 'session-timeline', + primaryThreadId: () => 'provider-1' + }), + ownThread: () => 'provider-1' + }) + const translator = new AcpTimelineTranslator({ + sessionId: 'provider-1', + journalItems: () => rig.journal.snapshot().items + }) + const apply = (events: ProviderTimelineEvent[]) => { + for (const event of events) { + expect(rig.assembler.apply(event).admission.accepted).toBe(true) + } + } + const chunk = (text: string) => + translator.notification( + 'session/update', + { + sessionId: 'provider-1', + update: { + sessionUpdate: 'agent_message_chunk', + messageId: 'm1', + content: { type: 'text', text } + } + }, + 1100 + ) + apply(translator.openPrompt('send-1', 1000)) + apply(chunk('Hel')) + const before = (await rig.rows()).find((row) => row.body.kind === 'message')! + rig.assembler = rig.restart({ generation: 'gen-2' }) + const delta = chunk('lo')[0]! + if (delta.type !== 'text.delta') { + throw new Error('Expected a translated text delta') + } + expect(delta.join).toEqual({ thread: 'provider-1', turn: 'prompt:send-1' }) + apply([delta, { type: 'text.close', item: delta.item, join: delta.join }]) + expect(rig.assembler.apply(delta).dropped).toBe('item-settled') + const messages = (await rig.rows()).filter((row) => row.body.kind === 'message') + expect(messages).toHaveLength(1) + expect(messages[0]!.itemId).toBe(before.itemId) + expect(messageText(messages[0]!.body)).toBe('Hello') + }) + + it('lets the assembler recover request incarnations and the open turn after a restart', async () => { + const rig = await openProviderTimelineRig() + const build = () => + new AcpTimelineTranslator({ + sessionId: 'provider-1', + journalItems: () => rig.journal.snapshot().items + }) + const params = { + sessionId: 'provider-1', + toolCall: { toolCallId: 'call-1', title: 'Run?' }, + options: [{ optionId: 'allow', name: 'Allow', kind: 'allow_once' }] + } + const translator = build() + for (const event of translator.openPrompt('send-1', 1000)) { + rig.assembler.apply(event) + } + const event = translator.request('session/request_permission', params, 0).events[0]! + if (event.type !== 'request.open') { + throw new Error('Expected a translated request') + } + expect(event.join).toEqual({ thread: 'provider-1' }) + rig.assembler.apply(event) + rig.assembler.apply({ type: 'request.withdrawn', request: event.request }) + const first = (await rig.rows()).find((row) => row.body.kind === 'approval')! + rig.assembler = rig.restart({ generation: 'gen-2' }) + for (const reopened of build().request('session/request_permission', params, 0).events) { + expect(rig.assembler.apply(reopened).admission.accepted).toBe(true) + } + const approvals = (await rig.rows()).filter((row) => row.body.kind === 'approval') + expect(approvals).toHaveLength(2) + expect(approvals[1]!.itemId).not.toBe(first.itemId) + expect(approvals[1]!.turnScope).toEqual(first.turnScope) + expect(approvals[1]!.body).toMatchObject({ resolution: { state: 'pending' } }) + }) +}) diff --git a/src/main/acp/acp-timeline-translator.ts b/src/main/acp/acp-timeline-translator.ts index 74a8c2f19335..fc764b0797eb 100644 --- a/src/main/acp/acp-timeline-translator.ts +++ b/src/main/acp/acp-timeline-translator.ts @@ -69,7 +69,12 @@ export class AcpTimelineTranslator { const turn = `prompt:${clientMessageId}` this.prompt = { turn } return [ - { type: 'input.accepted', clientMessageId, requestedAt: at, join: { turn } }, + { + type: 'input.accepted', + clientMessageId, + requestedAt: at, + join: { thread: this.options.sessionId, turn } + }, { type: 'turn.open', turn, at } ] } @@ -84,14 +89,14 @@ export class AcpTimelineTranslator { this.dialect.promptUsage?.(result, at) ?? (result.usage ? acpResponseUsage(result.usage, at) : undefined) const events: ProviderTimelineEvent[] = usage - ? [{ type: 'context.usage', usage, join: { turn } }] + ? [{ type: 'context.usage', usage, join: { thread: this.options.sessionId, turn } }] : [] if (!['end_turn', 'cancelled'].includes(result.stopReason)) { events.push({ type: 'provider.frame', frameKind: `prompt:${result.stopReason}`, payload: result, - join: { turn } + join: { thread: this.options.sessionId, turn } }) } events.push( @@ -184,7 +189,9 @@ export class AcpTimelineTranslator { substantive || extension?.end !== undefined, isReplay ) - const join = turn === undefined ? {} : { join: { turn } } + const join = { + join: { thread: this.options.sessionId, ...(turn === undefined ? {} : { turn }) } + } if (extension?.usage) { events.push({ type: 'context.usage', usage: extension.usage, ...join }) } @@ -243,9 +250,10 @@ export class AcpTimelineTranslator { method === 'session/request_permission' ? acpPermissionPresentation(params) : this.dialect.request?.(method, params) + const join = { thread: this.options.sessionId } if (!presentation) { return { - events: [{ type: 'provider.frame', frameKind: `request:${method}`, payload: params }] + events: [{ type: 'provider.frame', frameKind: `request:${method}`, payload: params, join }] } } return { @@ -254,7 +262,8 @@ export class AcpTimelineTranslator { { type: 'request.open', request: `${method}:${JSON.stringify(id)}`, - body: presentation.body + body: presentation.body, + join } ] } diff --git a/src/main/acp/acp-tool-timeline.ts b/src/main/acp/acp-tool-timeline.ts index 71ce27647e42..b1c72d6d063e 100644 --- a/src/main/acp/acp-tool-timeline.ts +++ b/src/main/acp/acp-tool-timeline.ts @@ -6,7 +6,10 @@ import { boundToolInput, DEFAULT_JOURNAL_PAYLOAD_LIMITS } from '../native-chat/agent-session-journal/journal-payload-bounds' -import type { ProviderTimelineEvent } from '../native-chat/agent-session-timeline/provider-timeline-event' +import type { + ProviderTimelineEvent, + ProviderTimelineJoin +} from '../native-chat/agent-session-timeline/provider-timeline-event' import type { AcpDialect } from './acp-dialects/acp-dialect' import type { ToolCallUpdate } from './generated/acp-protocol.generated' @@ -71,7 +74,11 @@ export class AcpToolTimeline { private readonly tools = new Map() private bytes = 0 - translate(update: ToolCallUpdate, dialect: AcpDialect, turn?: string): ProviderTimelineEvent[] { + translate( + update: ToolCallUpdate, + dialect: AcpDialect, + join: ProviderTimelineJoin + ): ProviderTimelineEvent[] { const previous = this.tools.get(update.toolCallId) const output = outputText(update) const state = @@ -103,19 +110,17 @@ export class AcpToolTimeline { : {} : { output: boundPayload(output, DEFAULT_JOURNAL_PAYLOAD_LIMITS) }) } - const joinedTurn = previous?.turn ?? turn const snapshot: ToolSnapshot = { body, - ...(joinedTurn === undefined ? {} : { turn: joinedTurn }) + ...(join.turn === undefined ? {} : { turn: join.turn }) } this.remember(update.toolCallId, snapshot) - const join = joinedTurn === undefined ? {} : { join: { turn: joinedTurn } } const events: ProviderTimelineEvent[] = [ { type: state === 'running' ? (previous ? 'item.update' : 'item.open') : 'item.close', item: `tool:${update.toolCallId}`, body, - ...join + join } ] for (const content of update.content ?? []) { @@ -131,14 +136,14 @@ export class AcpToolTimeline { DEFAULT_JOURNAL_PAYLOAD_LIMITS ) }, - ...join + join }) } else if (content.type === 'terminal' || content.content.type !== 'text') { events.push({ type: 'provider.frame', frameKind: `tool-content:${content.type}`, payload: content, - ...join + join }) } } From c4a072db6c32307db4584c8fd6d438358418d3e6 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 23:05:55 -0700 Subject: [PATCH 21/62] Use exact ACP prompt identity and recover incomplete timeline replay --- src/main/acp/acp-context-usage.ts | 34 +- src/main/acp/acp-dialects/acp-dialect.ts | 24 +- src/main/acp/acp-dialects/grok-dialect.ts | 160 +++++--- src/main/acp/acp-dialects/grok-requests.ts | 36 +- src/main/acp/acp-fixture-privacy.test.ts | 89 +++++ src/main/acp/acp-journal-turns.ts | 52 +++ src/main/acp/acp-prompt-turns.ts | 67 ++++ src/main/acp/acp-session-update.ts | 25 +- src/main/acp/acp-timeline-dialect.test.ts | 42 +- .../acp/acp-timeline-fixture.test-support.ts | 6 +- src/main/acp/acp-timeline-generic.test.ts | 32 +- src/main/acp/acp-timeline-joins.test.ts | 4 +- src/main/acp/acp-timeline-recovery.test.ts | 187 +++++++++ src/main/acp/acp-timeline-requests.ts | 64 ++- src/main/acp/acp-timeline-routing.test.ts | 122 ++++++ src/main/acp/acp-timeline-translator.ts | 365 +++++++++--------- src/main/acp/acp-tool-timeline.ts | 22 +- src/main/acp/acp-turn-messages.ts | 35 ++ src/main/acp/fixtures/s1-basic.jsonl | 21 +- .../acp/fixtures/s1-full-notifications.jsonl | 142 +++++++ src/main/acp/fixtures/s2-permission.jsonl | 28 +- src/main/acp/fixtures/s3-cancel.jsonl | 18 +- src/main/acp/fixtures/s4-resume.jsonl | 38 +- src/main/acp/fixtures/s5-plan-approved.jsonl | 21 +- src/main/acp/fixtures/s6-background.jsonl | 14 +- 25 files changed, 1255 insertions(+), 393 deletions(-) create mode 100644 src/main/acp/acp-fixture-privacy.test.ts create mode 100644 src/main/acp/acp-journal-turns.ts create mode 100644 src/main/acp/acp-prompt-turns.ts create mode 100644 src/main/acp/acp-timeline-recovery.test.ts create mode 100644 src/main/acp/acp-timeline-routing.test.ts create mode 100644 src/main/acp/acp-turn-messages.ts create mode 100644 src/main/acp/fixtures/s1-full-notifications.jsonl diff --git a/src/main/acp/acp-context-usage.ts b/src/main/acp/acp-context-usage.ts index e290d1056d05..000b915295bc 100644 --- a/src/main/acp/acp-context-usage.ts +++ b/src/main/acp/acp-context-usage.ts @@ -1,6 +1,6 @@ import { z } from 'zod' import type { AgentSessionContextUsage } from '../../shared/agent-session-context-usage' -import type { Usage, UsageUpdate } from './generated/acp-protocol.generated' +import type { UsageUpdate } from './generated/acp-protocol.generated' export function acpWindowUsage(update: UsageUpdate, at: number): AgentSessionContextUsage { return { @@ -18,38 +18,6 @@ export function acpWindowUsage(update: UsageUpdate, at: number): AgentSessionCon } } -export function acpResponseUsage(usage: Usage, at: number): AgentSessionContextUsage { - return tokenEstimate( - usage.inputTokens, - usage.outputTokens, - usage.cachedReadTokens ?? 0, - usage.cachedWriteTokens ?? 0, - at - ) -} - -export function tokenEstimate( - input: number, - output: number, - cachedRead: number, - cachedWrite: number, - at: number -): AgentSessionContextUsage { - return { - used: { - kind: 'estimate', - capturedAt: at, - usage: { - // ACP input includes cached tokens; the shared estimate adds its cache fields. - inputTokens: Math.max(0, input - cachedRead - cachedWrite), - cacheReadInputTokens: cachedRead, - cacheCreationInputTokens: cachedWrite, - outputTokens: output - } - } - } -} - export const acpNotificationEnvelopeSchema = z.looseObject({ sessionId: z.string(), update: z.looseObject({ sessionUpdate: z.string() }), diff --git a/src/main/acp/acp-dialects/acp-dialect.ts b/src/main/acp/acp-dialects/acp-dialect.ts index c4bcadc13232..04f9c4c3997a 100644 --- a/src/main/acp/acp-dialects/acp-dialect.ts +++ b/src/main/acp/acp-dialects/acp-dialect.ts @@ -8,20 +8,24 @@ export type AcpRequestPresentation = { reply(response: AgentSessionPromptResponse | null): unknown } -export type AcpDialectNotification = { - turn?: string - agentInitiated?: boolean - replay?: boolean - at?: number - end?: { stopReason: string; durationMs?: number } - usage?: AgentSessionContextUsage -} +export type AcpDialectNotification = + | { disposition: 'ignore' } + | { + disposition: 'map' + turn?: string + replay?: boolean + at?: number + started?: boolean + end?: { stopReason: string; durationMs?: number } + usage?: AgentSessionContextUsage + } -/** Hooks only interpret provider extensions; lifecycle and row identity stay shared. */ +/** Hooks interpret extensions; lifecycle and row identity stay shared. */ export type AcpDialect = { + injectedPromptIdentity?: true toolName?(update: ToolCallUpdate): string | undefined notification?(method: string, params: unknown, at: number): AcpDialectNotification | undefined - promptUsage?(result: unknown, at: number): AgentSessionContextUsage | undefined + contextWindow?(models: unknown): number | undefined request?(method: string, params: unknown): AcpRequestPresentation | undefined } diff --git a/src/main/acp/acp-dialects/grok-dialect.ts b/src/main/acp/acp-dialects/grok-dialect.ts index 48b1233d7f4e..700676d6b581 100644 --- a/src/main/acp/acp-dialects/grok-dialect.ts +++ b/src/main/acp/acp-dialects/grok-dialect.ts @@ -1,16 +1,9 @@ import { z } from 'zod' -import { acpNotificationEnvelopeSchema, tokenEstimate } from '../acp-context-usage' -import type { AcpDialect } from './acp-dialect' +import { acpNotificationEnvelopeSchema } from '../acp-context-usage' +import type { AcpDialect, AcpDialectNotification } from './acp-dialect' import { grokRequest } from './grok-requests' const tokenCount = z.number().int().nonnegative() -const usageSchema = z.looseObject({ - inputTokens: tokenCount, - outputTokens: tokenCount.default(0), - cachedReadTokens: tokenCount.default(0), - cacheCreationTokens: tokenCount.default(0) -}) -const promptUsageSchema = z.object({ _meta: z.looseObject({ usage: usageSchema.optional() }) }) const toolMetaSchema = z.object({ 'x.ai/tool': z.object({ name: z.string().min(1) }) }) const turnMetaSchema = z.looseObject({ promptId: z.string().optional(), @@ -20,69 +13,118 @@ const completionSchema = z.looseObject({ sessionUpdate: z.literal('turn_completed'), prompt_id: z.string(), stop_reason: z.string(), - usage: usageSchema.optional(), elapsed_ms: tokenCount.optional() }) +const responseSchema = z.object({ + sessionUpdate: z.literal('response_completed'), + usage: z.object({ + input_tokens: tokenCount, + output_tokens: tokenCount.default(0), + cache_read_input_tokens: tokenCount.default(0), + cache_creation_input_tokens: tokenCount.default(0) + }) +}) +const queueSchema = z.object({ sessionId: z.string(), runningPromptId: z.string().nullish() }) +const modelsSchema = z.object({ + currentModelId: z.string().optional(), + availableModels: z + .array( + z.object({ + modelId: z.string(), + _meta: z.object({ totalContextTokens: z.number().int().positive() }).optional() + }) + ) + .optional() +}) -function usageFrom(value: unknown, at: number) { - const parsed = usageSchema.safeParse(value) +function contextWindow(models: unknown): number | undefined { + const parsed = modelsSchema.safeParse(models) if (!parsed.success) { return undefined } - const usage = parsed.data - return tokenEstimate( - usage.inputTokens, - usage.outputTokens, - usage.cachedReadTokens, - usage.cacheCreationTokens, - at + const { currentModelId, availableModels = [] } = parsed.data + return ( + availableModels.find((model) => model.modelId === currentModelId)?._meta?.totalContextTokens ?? + availableModels.find((model) => model._meta)?._meta?.totalContextTokens ) } +function notification( + method: string, + params: unknown, + at: number +): AcpDialectNotification | undefined { + const canonical = method.startsWith('_') ? method.slice(1) : method + if (canonical === 'x.ai/models/update') { + const tokens = contextWindow(params) + return tokens === undefined + ? { disposition: 'ignore' } + : { disposition: 'map', usage: { window: { tokens, capturedAt: at } } } + } + if (canonical === 'x.ai/queue/changed') { + const parsed = queueSchema.safeParse(params) + return parsed.success && parsed.data.runningPromptId + ? { disposition: 'map', turn: parsed.data.runningPromptId, started: true } + : { disposition: 'ignore' } + } + if ( + method !== 'session/update' && + !['x.ai/session_notification', 'x.ai/session/update'].includes(canonical) + ) { + return canonical.startsWith('x.ai/') ? { disposition: 'ignore' } : undefined + } + const parsed = acpNotificationEnvelopeSchema.safeParse(params) + if (!parsed.success) { + return method === 'session/update' ? undefined : { disposition: 'ignore' } + } + const envelope = parsed.data + const meta = turnMetaSchema.safeParse(envelope._meta) + const completion = completionSchema.safeParse(envelope.update) + const response = responseSchema.safeParse(envelope.update) + if (method !== 'session/update' && !completion.success && !response.success) { + return { disposition: 'ignore' } + } + const turn = completion.success + ? completion.data.prompt_id + : meta.success + ? meta.data.promptId + : undefined + return { + disposition: 'map', + ...(turn === undefined ? {} : { turn }), + ...(meta.success && meta.data.turnStartMs !== undefined ? { at: meta.data.turnStartMs } : {}), + replay: envelope._meta?.isReplay === true, + ...(completion.success + ? { + end: { stopReason: completion.data.stop_reason, durationMs: completion.data.elapsed_ms } + } + : {}), + ...(response.success + ? { + usage: { + used: { + kind: 'estimate', + capturedAt: at, + usage: { + inputTokens: response.data.usage.input_tokens, + outputTokens: response.data.usage.output_tokens, + cacheReadInputTokens: response.data.usage.cache_read_input_tokens, + cacheCreationInputTokens: response.data.usage.cache_creation_input_tokens + } + } + } + } + : {}) + } +} + export const GROK_ACP_DIALECT: AcpDialect = { + injectedPromptIdentity: true, toolName: (update) => { const parsed = toolMetaSchema.safeParse(update._meta) return parsed.success ? parsed.data['x.ai/tool'].name : undefined }, request: grokRequest, - promptUsage: (result, at) => { - const parsed = promptUsageSchema.safeParse(result) - return parsed.success ? usageFrom(parsed.data._meta.usage ?? parsed.data._meta, at) : undefined - }, - notification: (method, params, at) => { - if ( - !['session/update', '_x.ai/session_notification', '_x.ai/session/update'].includes(method) - ) { - return undefined - } - const parsed = acpNotificationEnvelopeSchema.safeParse(params) - if (!parsed.success) { - return undefined - } - const envelope = parsed.data - const meta = turnMetaSchema.safeParse(envelope._meta) - const completion = completionSchema.safeParse(envelope.update) - const turn = completion.success - ? completion.data.prompt_id - : meta.success - ? meta.data.promptId - : undefined - const usage = completion.success ? usageFrom(completion.data.usage, at) : undefined - return { - ...(turn === undefined ? {} : { turn, agentInitiated: turn.startsWith('task-completed-') }), - ...(meta.success && meta.data.turnStartMs !== undefined ? { at: meta.data.turnStartMs } : {}), - replay: envelope._meta?.isReplay === true, - ...(completion.success - ? { - end: { - stopReason: completion.data.stop_reason, - ...(completion.data.elapsed_ms === undefined - ? {} - : { durationMs: completion.data.elapsed_ms }) - } - } - : {}), - ...(usage === undefined ? {} : { usage }) - } - } + notification, + contextWindow } diff --git a/src/main/acp/acp-dialects/grok-requests.ts b/src/main/acp/acp-dialects/grok-requests.ts index 7f6c0d15f950..26dbee0fae25 100644 --- a/src/main/acp/acp-dialects/grok-requests.ts +++ b/src/main/acp/acp-dialects/grok-requests.ts @@ -23,7 +23,7 @@ const questionRequestSchema = z.object({ const planRequestSchema = z.object({ sessionId: z.string(), toolCallId: z.string(), - planContent: z.string() + planContent: z.string().nullish() }) function questionRequest(params: unknown): AcpRequestPresentation { @@ -59,7 +59,12 @@ function questionRequest(params: unknown): AcpRequestPresentation { response.kind === 'answers' ? response.answers : legacyAgentSessionQuestionAnswers(body, response.optionId) - if (!answers || !isValidAgentSessionQuestionAnswers(questions, answers)) { + const choices = answers?.map((answer) => + answer.optionIds.length > 0 + ? { questionId: answer.questionId, optionIds: answer.optionIds } + : answer + ) + if (!answers || !choices || !isValidAgentSessionQuestionAnswers(questions, choices)) { throw new AcpRpcError(-32602, 'Question answer must answer every offered question') } return { @@ -70,12 +75,24 @@ function questionRequest(params: unknown): AcpRequestPresentation { const values = answer.optionIds.map( (id) => question.options.find((option) => option.id === id)!.label ) - if (answer.other?.trim()) { - values.push(answer.other.trim()) + if (values.length === 0 && answer.other?.trim()) { + values.push('Other') } return [question.question, question.multiSelect ? values : values[0]] }) - ) + ), + ...(answers.some((answer) => answer.other?.trim()) + ? { + annotations: Object.fromEntries( + questions.flatMap((question) => { + const notes = answers + .find((answer) => answer.questionId === question.id) + ?.other?.trim() + return notes ? [[question.question, { notes }]] : [] + }) + ) + } + : {}) } } } @@ -91,7 +108,10 @@ function planRequest(params: unknown): AcpRequestPresentation { kind: 'approval', title: 'Approve plan', detail: null, - subject: { kind: 'plan', text: parsed.data.planContent }, + subject: { + kind: 'plan', + text: parsed.data.planContent ?? 'The agent exited plan mode without writing a plan.' + }, options: [ { id: 'approved', label: 'Approve plan' }, { id: 'request_changes', label: 'Request changes' } @@ -114,10 +134,10 @@ function planRequest(params: unknown): AcpRequestPresentation { } export function grokRequest(method: string, params: unknown): AcpRequestPresentation | undefined { - if (method === '_x.ai/ask_user_question') { + if (['_x.ai/ask_user_question', 'x.ai/ask_user_question'].includes(method)) { return questionRequest(params) } - if (method === '_x.ai/exit_plan_mode') { + if (['_x.ai/exit_plan_mode', 'x.ai/exit_plan_mode'].includes(method)) { return planRequest(params) } return undefined diff --git a/src/main/acp/acp-fixture-privacy.test.ts b/src/main/acp/acp-fixture-privacy.test.ts new file mode 100644 index 000000000000..b683ec1eeb1e --- /dev/null +++ b/src/main/acp/acp-fixture-privacy.test.ts @@ -0,0 +1,89 @@ +import { readFile, readdir } from 'node:fs/promises' +import { describe, expect, it } from 'vitest' + +const privateText = + /\/Users\/|\/home\/|[a-z]:[\\/]+Users[\\/]|brennan|orca-qa|native-chat-other-agents|[\w.+-]+@[\w.-]+\.[a-z]{2,}|\bhostname\b|\b[\w-]+\.(?:local|lan)\b|signature|subscription_tier|skills?/i + +function decodedStrings(value: unknown, depth = 0): string[] { + if (depth > 8) { + return [] + } + if (Array.isArray(value)) { + if ( + value.length > 0 && + value.every( + (entry) => + typeof entry === 'number' && Number.isInteger(entry) && entry >= 0 && entry <= 255 + ) + ) { + return decodedStrings(Buffer.from(value).toString('utf8'), depth + 1) + } + return value.flatMap((entry) => decodedStrings(entry, depth + 1)) + } + if (typeof value === 'object' && value !== null) { + return Object.entries(value).flatMap(([key, entry]) => [ + key, + ...decodedStrings(entry, depth + 1) + ]) + } + if (typeof value !== 'string') { + return [] + } + const texts = [value] + try { + const decoded = decodeURIComponent(value) + if (decoded !== value) { + texts.push(...decodedStrings(decoded, depth + 1)) + } + } catch { + /* Provider text need not be URI encoded. */ + } + try { + const decoded: unknown = JSON.parse(value) + if (decoded !== value) { + texts.push(...decodedStrings(decoded, depth + 1)) + } + } catch { + /* Ordinary text is not JSON. */ + } + for (const token of value.match(/[A-Za-z0-9+/_-]{16,}={0,2}/g) ?? []) { + const decoded = Buffer.from(token, 'base64url').toString('utf8') + if (decoded !== token && /^[\x20-\x7e\r\n\t]+$/.test(decoded)) { + texts.push(...decodedStrings(decoded, depth + 1)) + } + } + return texts +} + +describe('ACP recording privacy', () => { + it.each(['/Users/private/project', '/home/private/project', 'C:\\Users\\private\\project'])( + 'detects home paths after nested JSON, URL, base64 and byte encoding: %s', + (path) => { + for (const value of [ + path, + encodeURIComponent(path), + Buffer.from(path).toString('base64'), + [...Buffer.from(path)], + JSON.stringify({ nested: JSON.stringify(path) }) + ]) { + expect(decodedStrings(value).some((text) => privateText.test(text))).toBe(true) + } + } + ) + + it('contains no personal metadata or home-directory paths in any decoded fixture', async () => { + const directory = new URL('./fixtures/', import.meta.url) + const names = (await readdir(directory)).filter((name) => name.endsWith('.jsonl')) + expect(names.length).toBeGreaterThanOrEqual(6) + for (const name of names) { + const content = await readFile(new URL(name, directory), 'utf8') + for (const [index, line] of content.trim().split('\n').entries()) { + const value: unknown = JSON.parse(line) + expect( + decodedStrings(value).some((text) => privateText.test(text)), + `${name}:${index + 1}` + ).toBe(false) + } + } + }) +}) diff --git a/src/main/acp/acp-journal-turns.ts b/src/main/acp/acp-journal-turns.ts new file mode 100644 index 000000000000..15d48e78e8c4 --- /dev/null +++ b/src/main/acp/acp-journal-turns.ts @@ -0,0 +1,52 @@ +import type { AgentJournalRenderItem } from '../../shared/agent-session-journal-types' +import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' +import { providerTimelineKeyPart } from '../native-chat/agent-session-timeline/provider-timeline-identity' + +/** Read provider ids back from the assembler's existing turn records. */ +export function acpJournalTurnKey(row: AgentJournalRenderItem): string | undefined { + const turn = readAgentJournalTurn(row.body) + if (!turn) { + return undefined + } + if (!turn.turnId.startsWith('p:')) { + return turn.turnId + } + const value = turn.turnId.slice(turn.turnId.lastIndexOf(':') + 1) + try { + return decodeURIComponent(value) + } catch { + return undefined + } +} + +export function acpJournalTurnIsSettled( + rows: readonly AgentJournalRenderItem[], + key: string +): boolean { + return rows.some((row) => { + const turn = readAgentJournalTurn(row.body) + return ( + turn !== null && + turn.state !== 'running' && + (turn.turnId === key || turn.turnId.endsWith(`:${providerTimelineKeyPart(key)}`)) + ) + }) +} + +export function acpJournalTurnHasUser( + rows: readonly AgentJournalRenderItem[], + key: string +): boolean { + const record = rows.find((row) => acpJournalTurnKey(row) === key) + if (!record) { + return false + } + const turn = readAgentJournalTurn(record.body) + return rows.some( + (row) => + row.body.kind === 'message' && + row.body.role === 'user' && + (row.itemId === turn?.userItemId || + (row.turnScope?.kind === 'turn' && row.turnScope.turnItemId === record.itemId)) + ) +} diff --git a/src/main/acp/acp-prompt-turns.ts b/src/main/acp/acp-prompt-turns.ts new file mode 100644 index 000000000000..6dd867688c87 --- /dev/null +++ b/src/main/acp/acp-prompt-turns.ts @@ -0,0 +1,67 @@ +import type { ProviderTimelineEvent } from '../native-chat/agent-session-timeline/provider-timeline-event' + +export type AcpPromptTurn = { + clientMessageId: string + turn: string + requestedAt: number + opened: boolean + durationMs?: number +} + +/** An injected identity is known before any provider output arrives. */ +export class AcpPromptTurns { + current?: AcpPromptTurn + + constructor( + private readonly sessionId: string, + private readonly injected: boolean + ) {} + + open(clientMessageId: string, at: number): { promptId: string; events: ProviderTimelineEvent[] } { + if (this.current) { + throw new Error('ACP prompt overlaps a prompt or load') + } + const turn = `prompt:${clientMessageId}` + this.current = { clientMessageId, turn, requestedAt: at, opened: false } + return { promptId: turn, events: this.injected ? [] : this.start(turn, at) } + } + + start(turn: string, at: number): ProviderTimelineEvent[] { + const prompt = this.current + if (prompt?.turn !== turn || prompt.opened) { + return [] + } + prompt.opened = true + return [ + { type: 'turn.open', turn, at }, + { + type: 'input.accepted', + clientMessageId: prompt.clientMessageId, + requestedAt: prompt.requestedAt, + join: { thread: this.sessionId, turn } + } + ] + } +} + +export function acpTurnEnd( + turn: string, + stopReason: string, + at: number, + durationMs?: number +): ProviderTimelineEvent { + return { + type: 'turn.end', + turn, + at, + state: stopReason === 'cancelled' ? 'interrupted' : 'completed', + ...(stopReason === 'end_turn' + ? { outcome: 'success' as const } + : stopReason === 'cancelled' + ? { outcome: 'cancellation' as const } + : ['refusal', 'max_tokens', 'max_turn_requests', 'error', 'rate_limit'].includes(stopReason) + ? { outcome: 'failure' as const } + : {}), + ...(durationMs === undefined ? {} : { durationMs }) + } +} diff --git a/src/main/acp/acp-session-update.ts b/src/main/acp/acp-session-update.ts index 57eb2db9881c..b9fd57e93736 100644 --- a/src/main/acp/acp-session-update.ts +++ b/src/main/acp/acp-session-update.ts @@ -16,20 +16,37 @@ export function acpSessionUpdate( replay: boolean, tools: AcpToolTimeline, dialect: AcpDialect, - replayUserBody?: AgentJournalMessageItem + replayUserBody?: AgentJournalMessageItem, + messageKey?: string ): ProviderTimelineEvent[] { const update = notification.update const join = { join: { thread: notification.sessionId, ...(turn === undefined ? {} : { turn }) } } switch (update.sessionUpdate) { case 'agent_message_chunk': case 'agent_thought_chunk': + if (replay && messageKey && update.content.type === 'text') { + return [ + { + type: 'item.update', + item: messageKey, + body: { + kind: 'message', + role: update.sessionUpdate === 'agent_thought_chunk' ? 'reasoning' : 'assistant', + blocks: [{ type: 'text', text: update.content.text }] + }, + ...join + } + ] + } return update.content.type === 'text' ? [ { type: 'text.delta', - item: update.messageId - ? { id: `message:${update.messageId}` } - : { stream: update.sessionUpdate }, + item: messageKey + ? { id: messageKey } + : update.messageId + ? { id: `message:${update.messageId}` } + : { stream: update.sessionUpdate }, channel: update.sessionUpdate === 'agent_thought_chunk' ? 'reasoning' : 'assistant', text: update.content.text, ...join diff --git a/src/main/acp/acp-timeline-dialect.test.ts b/src/main/acp/acp-timeline-dialect.test.ts index c5b3b59087de..23fd7024ff13 100644 --- a/src/main/acp/acp-timeline-dialect.test.ts +++ b/src/main/acp/acp-timeline-dialect.test.ts @@ -42,7 +42,18 @@ const question = { describe('ACP dialect request and turn boundaries', () => { it('preserves provider permission labels, validates selected ids, and cancels unanswered rows on turn end', async () => { const { rig, translator, apply } = await dialectRig() - apply(translator.openPrompt('send-1', 1000)) + apply(translator.openPrompt('send-1', 1000).events) + apply( + translator.notification( + 'session/update', + { + sessionId: 'provider-1', + _meta: { promptId: 'prompt:send-1' }, + update: { sessionUpdate: 'tool_call', toolCallId: 'call-1', title: 'Write example' } + }, + 1001 + ) + ) const request = translator.request( 'session/request_permission', { @@ -80,7 +91,22 @@ describe('ACP dialect request and turn boundaries', () => { ).toEqual({ outcome: 'accepted', answers: { 'Choose a test?': 'Integration' } }) expect( ask.reply({ kind: 'answers', answers: [{ questionId: 'q1', optionIds: [], other: 'Smoke' }] }) - ).toEqual({ outcome: 'accepted', answers: { 'Choose a test?': 'Smoke' } }) + ).toEqual({ + outcome: 'accepted', + answers: { 'Choose a test?': 'Other' }, + annotations: { 'Choose a test?': { notes: 'Smoke' } } + }) + const alias = translator.request('x.ai/ask_user_question', question, 3).presentation! + expect( + alias.reply({ + kind: 'answers', + answers: [{ questionId: 'q1', optionIds: ['o1'], other: 'Also run smoke checks' }] + }) + ).toEqual({ + outcome: 'accepted', + answers: { 'Choose a test?': 'Unit' }, + annotations: { 'Choose a test?': { notes: 'Also run smoke checks' } } + }) expect(ask.reply(null)).toEqual({ outcome: 'cancelled' }) expect(() => ask.reply({ kind: 'answers', answers: [] })).toThrow('every offered question') expect(() => @@ -124,10 +150,14 @@ describe('ACP dialect request and turn boundaries', () => { it('keeps client prompt settlement on the response, and never gives an autonomous turn invented success', async () => { const { rig, translator, apply, notification } = await dialectRig() - apply(translator.openPrompt('send-1', 1000)) + apply(translator.openPrompt('send-1', 1000).events) notification('_x.ai/session_notification', { sessionId: 'provider-1', - update: { sessionUpdate: 'turn_completed', prompt_id: 'regular', stop_reason: 'end_turn' } + update: { + sessionUpdate: 'turn_completed', + prompt_id: 'prompt:send-1', + stop_reason: 'end_turn' + } }) expect((await rig.turns())[0]!.state).toBe('running') apply(translator.promptResult('send-1', { stopReason: 'cancelled' }, 1200)) @@ -155,14 +185,14 @@ describe('ACP dialect request and turn boundaries', () => { it('keeps live autonomous output while suppressing marked replay during load', async () => { const { rig, translator, apply, notification } = await dialectRig() - apply(translator.openPrompt('send-1', 1000)) + apply(translator.openPrompt('send-1', 1000).events) apply(translator.promptResult('send-1', { stopReason: 'end_turn' }, 1200)) await rig.rows() translator.beginLoad() notification('session/update', { sessionId: 'provider-1', update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'Old' } }, - _meta: { isReplay: true, promptId: 'old' } + _meta: { isReplay: true, promptId: 'prompt:send-1' } }) notification('session/update', { sessionId: 'provider-1', diff --git a/src/main/acp/acp-timeline-fixture.test-support.ts b/src/main/acp/acp-timeline-fixture.test-support.ts index ffe89fcc17b5..3e2bdd39afcb 100644 --- a/src/main/acp/acp-timeline-fixture.test-support.ts +++ b/src/main/acp/acp-timeline-fixture.test-support.ts @@ -98,7 +98,7 @@ export async function openAcpFixtureRig() { providerSessionId = session.sessionId lane = translator() } - apply(lane.openPrompt(`${frame.process}:${message.id}`, at)) + apply(lane.openPrompt(`${frame.process}:${message.id}`, at).events) } else if (direction === 'in' && message.method && message.id !== undefined) { const request = lane.request(message.method, message.params, message.id) apply(request.events) @@ -146,6 +146,10 @@ export async function openAcpFixtureRig() { } else if (direction === 'in' && message.method) { apply(lane.notification(message.method, message.params, at)) } else if (direction === 'in') { + const models = z.object({ models: z.unknown() }).safeParse(message.result) + if (models.success) { + apply(lane.contextModels(models.data.models, at)) + } const result = PromptResponseSchema.safeParse(message.result) if (result.success) { apply(lane.promptResult(`${frame.process}:${message.id}`, result.data, at)) diff --git a/src/main/acp/acp-timeline-generic.test.ts b/src/main/acp/acp-timeline-generic.test.ts index 8cfdc3c38dec..2b9ebb51dcd3 100644 --- a/src/main/acp/acp-timeline-generic.test.ts +++ b/src/main/acp/acp-timeline-generic.test.ts @@ -30,7 +30,7 @@ async function genericRig() { describe('generic ACP translation', () => { it('keeps named text across non-text barriers, splits anonymous text, and assembles reasoning', async () => { const { rig, translator, apply, update } = await genericRig() - apply(translator.openPrompt('send-1', 1000)) + apply(translator.openPrompt('send-1', 1000).events) update({ sessionUpdate: 'agent_message_chunk', messageId: 'm1', @@ -71,7 +71,7 @@ describe('generic ACP translation', () => { it('merges tool snapshots, bounds input/output and emits a separately keyed valid diff', async () => { const { rig, translator, apply, update } = await genericRig() - apply(translator.openPrompt('send-1', 1000)) + apply(translator.openPrompt('send-1', 1000).events) const oldText = 'same\nbefore\n' const newText = 'same\nafter\n' update({ @@ -117,9 +117,9 @@ describe('generic ACP translation', () => { expect(applyPatch(oldText, diff.patch.head)).toBe(newText) }) - it('records context window facts and does not double-count cached tokens in prompt usage', async () => { + it('keeps context occupancy from usage_update instead of aggregate prompt usage', async () => { const { rig, translator, apply, update } = await genericRig() - apply(translator.openPrompt('send-1', 1000)) + apply(translator.openPrompt('send-1', 1000).events) update({ sessionUpdate: 'usage_update', used: 42, size: 100 }) apply( translator.promptResult( @@ -142,23 +142,23 @@ describe('generic ACP translation', () => { expect(turn.contextUsage?.used).toMatchObject({ kind: 'estimate', usage: { - inputTokens: 20, - cacheReadInputTokens: 20, - cacheCreationInputTokens: 10, - outputTokens: 5 + inputTokens: 42, + cacheReadInputTokens: 0, + cacheCreationInputTokens: 0, + outputTokens: 0 } }) if (turn.contextUsage?.used?.kind !== 'estimate') { throw new Error('Missing usage') } - expect(contextTokensFromUsage(turn.contextUsage.used.usage)).toBe(50) + expect(contextTokensFromUsage(turn.contextUsage.used.usage)).toBe(42) }) it.each(['refusal', 'max_tokens', 'max_turn_requests'] as const)( - 'records %s as provider failure with the original stop reason', + 'records %s as provider failure without an extra status row', async (stopReason) => { const { rig, translator, apply } = await genericRig() - apply(translator.openPrompt('send-1', 1000)) + apply(translator.openPrompt('send-1', 1000).events) apply(translator.promptResult('send-1', { stopReason }, 1200)) expect((await rig.turns())[0]).toMatchObject({ state: 'completed', outcome: 'failure' }) expect( @@ -166,7 +166,7 @@ describe('generic ACP translation', () => { (row) => row.body.kind === 'status' && row.body.providerFrame?.kind === `prompt:${stopReason}` ) - ).toBe(true) + ).toBe(false) } ) @@ -234,7 +234,7 @@ describe('generic ACP translation', () => { it('requires an explicit journal decision for marked replay and uses typed standard events', async () => { const { rig, translator, apply } = await genericRig() - expect(() => + expect( translator.notification( 'session/update', { @@ -247,8 +247,8 @@ describe('generic ACP translation', () => { }, 1100 ) - ).toThrow('beginLoad') - apply(translator.openPrompt('send-1', 1000)) + ).toEqual([]) + apply(translator.openPrompt('send-1', 1000).events) apply( translator.sessionEvent( { @@ -301,7 +301,7 @@ describe('generic ACP translation', () => { it('evicts completed snapshots during long turns while preserving active tool input', async () => { const { rig, translator, apply, update } = await genericRig() - apply(translator.openPrompt('send-1', 1000)) + apply(translator.openPrompt('send-1', 1000).events) update({ sessionUpdate: 'tool_call', toolCallId: 'active', diff --git a/src/main/acp/acp-timeline-joins.test.ts b/src/main/acp/acp-timeline-joins.test.ts index 2b9dafaff228..af14c305f1e3 100644 --- a/src/main/acp/acp-timeline-joins.test.ts +++ b/src/main/acp/acp-timeline-joins.test.ts @@ -80,7 +80,7 @@ describe('ACP joins through the v3 assembler', () => { }, 1100 ) - apply(translator.openPrompt('send-1', 1000)) + apply(translator.openPrompt('send-1', 1000).events) apply(chunk('Hel')) const before = (await rig.rows()).find((row) => row.body.kind === 'message')! rig.assembler = rig.restart({ generation: 'gen-2' }) @@ -110,7 +110,7 @@ describe('ACP joins through the v3 assembler', () => { options: [{ optionId: 'allow', name: 'Allow', kind: 'allow_once' }] } const translator = build() - for (const event of translator.openPrompt('send-1', 1000)) { + for (const event of translator.openPrompt('send-1', 1000).events) { rig.assembler.apply(event) } const event = translator.request('session/request_permission', params, 0).events[0]! diff --git a/src/main/acp/acp-timeline-recovery.test.ts b/src/main/acp/acp-timeline-recovery.test.ts new file mode 100644 index 000000000000..e300d7a3e203 --- /dev/null +++ b/src/main/acp/acp-timeline-recovery.test.ts @@ -0,0 +1,187 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { contextTokensFromUsage } from '../../shared/agent-session-context-usage' +import { + closeProviderTimelineRigs, + messageText +} from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import { openAcpFixtureRig, readAcpFixture } from './acp-timeline-fixture.test-support' +import { AcpTimelineTranslator } from './acp-timeline-translator' +import { GROK_ACP_DIALECT } from './acp-dialects/grok-dialect' + +afterEach(closeProviderTimelineRigs) + +describe('ACP reviewed traffic and recovery', () => { + it('replays every notification in the full recording without extension status rows or persisted signatures', async () => { + const f = await openAcpFixtureRig() + const frames = await readAcpFixture('s1-full-notifications') + expect(frames).toHaveLength(142) + const rows = await f.feed(frames) + expect(rows.filter((row) => row.body.kind === 'status')).toEqual([]) + expect(rows.filter((row) => row.body.kind === 'tool-call')).toHaveLength(2) + expect(rows.filter((row) => row.body.kind === 'message')).toHaveLength(2) + expect(await f.rig.turns()).toHaveLength(1) + expect(JSON.stringify(rows)).not.toContain('signature') + const context = (await f.rig.turns())[0]?.contextUsage + expect(context?.window?.tokens).toBe(256000) + expect(context?.used?.kind === 'estimate' && contextTokensFromUsage(context.used.usage)).toBe( + 34622 + ) + }) + + it('uses the last of nine model calls instead of the aggregate turn usage', async () => { + const f = await openAcpFixtureRig() + await f.feed(await readAcpFixture('s5-plan-approved')) + const usage = (await f.rig.turns())[0]?.contextUsage?.used + expect(usage?.kind === 'estimate' && contextTokensFromUsage(usage.usage)).toBe(39190) + }) + + it('adopts missing history into a partial journal after a restart without duplicating partial text', async () => { + const f = await openAcpFixtureRig() + const frames = await readAcpFixture('s4-resume') + const live = frames.filter((frame) => frame.process === 'A') + const firstText = live.findIndex( + (frame) => + frame.message.params && JSON.stringify(frame.message.params).includes('agent_message_chunk') + ) + await f.feed(live.slice(0, firstText + 1)) + const partial = (await f.rig.rows()).find((row) => row.body.kind === 'message') + expect(partial).toBeDefined() + f.restart() + const replay = frames.filter((frame) => frame.process === 'B') + const next = replay.findIndex((frame) => frame.message.method === 'session/prompt') + await f.feed(replay.slice(0, next)) + f.finishLoad() + const rows = await f.rig.rows() + const messages = rows.filter( + (row) => row.body.kind === 'message' && row.body.role === 'assistant' + ) + expect(messages).toHaveLength(2) + expect(messages[0]?.itemId).toBe(partial?.itemId) + expect(messages.map((row) => messageText(row.body))).toEqual([ + "I'll read `notes.txt` and report the second line.", + 'The second line of `notes.txt` is `bananas`.' + ]) + expect(rows.filter((row) => row.body.kind === 'tool-call')).toHaveLength(2) + expect((await f.rig.turns())[0]?.outcome).toBe('success') + }) + + it('re-derives replay adoption after adoption itself was interrupted', async () => { + const f = await openAcpFixtureRig() + const replay = (await readAcpFixture('s4-resume')).filter((frame) => frame.process === 'B') + const next = replay.findIndex((frame) => frame.message.method === 'session/prompt') + await f.feed(replay.slice(0, 4)) + const original = (await f.rig.rows()).find( + (row) => row.body.kind === 'message' && row.body.role === 'assistant' + ) + f.restart() + await f.feed(replay.slice(0, next)) + f.finishLoad() + const rows = await f.rig.rows() + expect( + rows.filter((row) => row.body.kind === 'message' && row.body.role === 'user') + ).toHaveLength(1) + const assistants = rows.filter( + (row) => row.body.kind === 'message' && row.body.role === 'assistant' + ) + expect(assistants).toHaveLength(2) + expect(assistants[0]?.itemId).toBe(original?.itemId) + expect((await f.rig.turns())[0]?.outcome).toBe('success') + }) + + it.each([undefined, null])( + 'accepts an empty plan request and replies with the valid approval shape (%s)', + async (planContent) => { + const f = await openAcpFixtureRig() + for (const method of ['_x.ai/exit_plan_mode', 'x.ai/exit_plan_mode']) { + const presentation = f + .lane() + .request( + method, + { sessionId: 'session-1', toolCallId: 'plan', planContent }, + 1 + ).presentation + expect(presentation?.body).toMatchObject({ + kind: 'approval', + subject: { kind: 'plan', text: 'The agent exited plan mode without writing a plan.' } + }) + expect(presentation?.reply({ kind: 'option', optionId: 'approved' })).toEqual({ + outcome: 'approved' + }) + } + } + ) + + it('degrades tool snapshots under forty large concurrent tools without throwing or losing later updates', () => { + const lane = new AcpTimelineTranslator({ + sessionId: 'session-1', + journalItems: () => [], + dialect: GROK_ACP_DIALECT + }) + const big = 'x"\\'.repeat(12000) + for (let index = 0; index < 40; index += 1) { + expect(() => + lane.notification( + 'session/update', + { + sessionId: 'session-1', + update: { + sessionUpdate: 'tool_call', + toolCallId: `t${index}`, + title: 'Write', + status: 'in_progress', + rawInput: { content: big }, + rawOutput: big + } + }, + index + ) + ).not.toThrow() + } + for (let index = 0; index < 40; index += 1) { + expect( + lane.notification( + 'session/update', + { + sessionId: 'session-1', + update: { + sessionUpdate: 'tool_call_update', + toolCallId: `t${index}`, + status: 'completed' + } + }, + 50 + index + ) + ).toMatchObject([{ type: 'item.close', body: { state: 'completed' } }]) + } + }) + + it('ignores stray replay and both extension method aliases by default', async () => { + const f = await openAcpFixtureRig() + for (const method of ['_x.ai/session_notification', 'x.ai/session_notification']) { + expect( + f.lane().notification( + method, + { + sessionId: 'session-1', + update: { sessionUpdate: 'response_completed', signature: 'opaque' } + }, + 1000 + ) + ).toEqual([]) + } + expect(f.lane().notification('_unknown/extension', { sessionId: 'session-1' }, 1000)).toEqual( + [] + ) + expect( + f.lane().notification( + 'session/update', + { + sessionId: 'session-1', + _meta: { isReplay: true }, + update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'Old' } } + }, + 1000 + ) + ).toEqual([]) + }) +}) diff --git a/src/main/acp/acp-timeline-requests.ts b/src/main/acp/acp-timeline-requests.ts index 1756521d965a..16fe309d7516 100644 --- a/src/main/acp/acp-timeline-requests.ts +++ b/src/main/acp/acp-timeline-requests.ts @@ -1,5 +1,10 @@ +import { z } from 'zod' +import type { AgentJournalRenderItem } from '../../shared/agent-session-journal-types' +import type { ProviderTimelineEvent } from '../native-chat/agent-session-timeline/provider-timeline-event' +import type { AcpDialect, AcpRequestPresentation } from './acp-dialects/acp-dialect' +import { acpJournalTurnKey } from './acp-journal-turns' +import type { AcpToolTimeline } from './acp-tool-timeline' import { AcpRpcError } from './acp-errors' -import type { AcpRequestPresentation } from './acp-dialects/acp-dialect' import { RequestPermissionRequestSchema } from './generated/acp-protocol.generated' export const pendingAcpResolution = { @@ -37,3 +42,60 @@ export function acpPermissionPresentation(params: unknown): AcpRequestPresentati } } } + +const requestSessionSchema = z.object({ sessionId: z.string() }) +const requestToolSchema = z.object({ + toolCallId: z.string().optional(), + toolCall: z.object({ toolCallId: z.string() }).optional() +}) + +export function translateAcpRequest( + method: string, + params: unknown, + id: string | number, + options: { + sessionId: string + journalItems(): readonly AgentJournalRenderItem[] + dialect: AcpDialect + tools: AcpToolTimeline + } +): { events: ProviderTimelineEvent[]; presentation?: AcpRequestPresentation } { + const session = requestSessionSchema.safeParse(params) + if (!session.success || session.data.sessionId !== options.sessionId) { + throw new AcpRpcError(-32602, 'ACP request belongs to an unknown session') + } + const presentation = + method === 'session/request_permission' + ? acpPermissionPresentation(params) + : options.dialect.request?.(method, params) + const tool = requestToolSchema.safeParse(params) + const callId = tool.success ? (tool.data.toolCall?.toolCallId ?? tool.data.toolCallId) : undefined + const storedTool = callId + ? options + .journalItems() + .find((row) => row.body.kind === 'tool-call' && row.body.callId === callId) + : undefined + const turnItemId = + storedTool?.turnScope?.kind === 'turn' ? storedTool.turnScope.turnItemId : undefined + const storedTurn = options.journalItems().find((row) => row.itemId === turnItemId) + const turn = callId + ? (options.tools.turn(callId) ?? (storedTurn && acpJournalTurnKey(storedTurn))) + : undefined + const join = { thread: options.sessionId, ...(turn === undefined ? {} : { turn }) } + if (!presentation) { + return { + events: [{ type: 'provider.frame', frameKind: `request:${method}`, payload: params, join }] + } + } + return { + presentation, + events: [ + { + type: 'request.open', + request: `${method}:${JSON.stringify(id)}`, + body: presentation.body, + join + } + ] + } +} diff --git a/src/main/acp/acp-timeline-routing.test.ts b/src/main/acp/acp-timeline-routing.test.ts new file mode 100644 index 000000000000..d5c12540c7bc --- /dev/null +++ b/src/main/acp/acp-timeline-routing.test.ts @@ -0,0 +1,122 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { + closeProviderTimelineRigs, + messageText +} from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import { openAcpFixtureRig } from './acp-timeline-fixture.test-support' + +const chunk = (promptId: string, text: string) => ({ + sessionId: 'session-1', + _meta: { promptId }, + update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text } } +}) +const done = (promptId: string, stopReason = 'end_turn') => ({ + sessionId: 'session-1', + update: { sessionUpdate: 'turn_completed', prompt_id: promptId, stop_reason: stopReason } +}) +afterEach(closeProviderTimelineRigs) + +describe('ACP exact prompt identity', () => { + it.each(['task-completed-2', 'subagent-completed-2', 'notifications-9', 'future-wake'])( + 'opens %s independently of a queued user prompt', + async (wake) => { + const f = await openAcpFixtureRig() + const lane = f.lane() + const prompt = lane.openPrompt('c1', 1000) + expect(prompt.events).toEqual([]) + f.apply(lane.notification('session/update', chunk(wake, 'background'), 1001)) + f.apply(lane.notification('_x.ai/session_notification', done(wake), 1002)) + f.apply(lane.notification('session/update', chunk(prompt.promptId, 'user reply'), 1003)) + f.apply(lane.notification('_x.ai/session_notification', done(prompt.promptId), 1004)) + f.apply(lane.promptResult('c1', { stopReason: 'end_turn' }, 1005)) + const rows = await f.rig.rows() + const messages = rows.filter((row) => row.body.kind === 'message') + expect(messages.map((row) => messageText(row.body))).toEqual(['background', 'user reply']) + expect(messages[0]?.turnScope).not.toEqual(messages[1]?.turnScope) + expect((await f.rig.turns()).map((turn) => turn.outcome)).toEqual(['success', 'success']) + } + ) + + it('keeps a streaming background turn open when the user sends and scopes its permission to the tool', async () => { + const f = await openAcpFixtureRig() + const lane = f.lane() + f.apply(lane.notification('session/update', chunk('background', 'first'), 1000)) + f.apply( + lane.notification( + 'session/update', + { + sessionId: 'session-1', + _meta: { promptId: 'background' }, + update: { + sessionUpdate: 'tool_call', + toolCallId: 'bg-tool', + title: 'Write', + status: 'pending' + } + }, + 1001 + ) + ) + const prompt = lane.openPrompt('c1', 1002) + f.apply(prompt.events) + const request = lane.request( + 'session/request_permission', + { + sessionId: 'session-1', + toolCall: { toolCallId: 'bg-tool', title: 'Write' }, + options: [{ optionId: 'allow', name: 'Allow', kind: 'allow_once' }] + }, + 0 + ) + expect(request.events[0]).toMatchObject({ join: { turn: 'background' } }) + f.apply(request.events) + f.apply(lane.notification('session/update', chunk('background', 'second'), 1003)) + f.apply(lane.notification('_x.ai/session_notification', done('background'), 1004)) + f.apply( + lane.notification( + '_x.ai/queue/changed', + { sessionId: 'session-1', runningPromptId: prompt.promptId }, + 1005 + ) + ) + f.apply(lane.notification('session/update', chunk(prompt.promptId, 'user reply'), 1006)) + f.apply(lane.promptResult('c1', { stopReason: 'end_turn' }, 1007)) + const rows = await f.rig.rows() + expect(rows.find((row) => row.body.kind === 'approval')?.turnScope).toEqual( + rows.find((row) => row.body.kind === 'tool-call')?.turnScope + ) + expect((await f.rig.turns()).map((turn) => turn.outcome)).toEqual(['success', 'success']) + expect( + rows.filter((row) => row.body.kind === 'message').map((row) => messageText(row.body)) + ).toEqual(['first', 'second', 'user reply']) + }) + + it.each(['error', 'rate_limit'])( + 'reports %s before an RPC rejection and accepts the next prompt', + async (stopReason) => { + const f = await openAcpFixtureRig() + const lane = f.lane() + const prompt = lane.openPrompt('c1', 1000) + f.apply(lane.notification('session/update', chunk(prompt.promptId, 'partial'), 1001)) + f.apply( + lane.notification('_x.ai/session_notification', done(prompt.promptId, stopReason), 1002) + ) + f.apply(lane.promptFailed('c1', new Error('Provider rejected prompt'), 1003)) + const next = lane.openPrompt('c2', 1004) + f.apply(next.events) + f.apply(lane.notification('session/update', chunk(next.promptId, 'next'), 1005)) + expect(lane.promptResult('c1', { stopReason: 'end_turn' }, 1006)).toEqual([]) + f.apply(lane.promptResult('c2', { stopReason: 'end_turn' }, 1007)) + expect((await f.rig.turns()).map((turn) => turn.outcome)).toEqual(['failure', 'success']) + } + ) + + it('settles an RPC-only failure even if the provider never streamed a frame', async () => { + const f = await openAcpFixtureRig() + const lane = f.lane() + lane.openPrompt('c1', 1000) + f.apply(lane.promptFailed('c1', { code: -32603, message: 'Rejected' }, 1001)) + expect((await f.rig.turns())[0]).toMatchObject({ state: 'completed', outcome: 'failure' }) + expect(() => lane.openPrompt('c2', 1002)).not.toThrow() + }) +}) diff --git a/src/main/acp/acp-timeline-translator.ts b/src/main/acp/acp-timeline-translator.ts index fc764b0797eb..0867dcf40c12 100644 --- a/src/main/acp/acp-timeline-translator.ts +++ b/src/main/acp/acp-timeline-translator.ts @@ -1,82 +1,70 @@ import { z } from 'zod' -import type { AgentJournalRenderItem } from '../../shared/agent-session-journal-types' +import type { + AgentJournalMessageItem, + AgentJournalRenderItem +} from '../../shared/agent-session-journal-types' +import { BoundedMap } from '../../shared/bounded-map' import type { ProviderTimelineEvent } from '../native-chat/agent-session-timeline/provider-timeline-event' -import { acpNotificationEnvelopeSchema, acpResponseUsage } from './acp-context-usage' +import { acpNotificationEnvelopeSchema } from './acp-context-usage' import { GENERIC_ACP_DIALECT, type AcpDialect, - type AcpDialectNotification, type AcpRequestPresentation } from './acp-dialects/acp-dialect' +import { acpJournalTurnIsSettled, acpJournalTurnHasUser } from './acp-journal-turns' +import { acpTurnEnd, AcpPromptTurns } from './acp-prompt-turns' import { AcpReplayUserMessages } from './acp-replay-user-messages' -import { AcpRpcError } from './acp-errors' import type { AcpSessionEvent } from './acp-session-runtime' -import { acpPermissionPresentation } from './acp-timeline-requests' +import { translateAcpRequest } from './acp-timeline-requests' +export { acpTurnEnd } from './acp-prompt-turns' import { acpSessionUpdate } from './acp-session-update' import { AcpToolTimeline } from './acp-tool-timeline' +import { AcpTurnMessages } from './acp-turn-messages' import { SessionNotificationSchema, type PromptResponse } from './generated/acp-protocol.generated' const requestSessionSchema = z.object({ sessionId: z.string() }) - -export function acpTurnEnd( - turn: string, - stopReason: string, - at: number, - durationMs?: number -): ProviderTimelineEvent { - return { - type: 'turn.end', - turn, - at, - state: stopReason === 'cancelled' ? 'interrupted' : 'completed', - ...(stopReason === 'end_turn' - ? { outcome: 'success' as const } - : stopReason === 'cancelled' - ? { outcome: 'cancellation' as const } - : ['refusal', 'max_tokens', 'max_turn_requests'].includes(stopReason) - ? { outcome: 'failure' as const } - : {}), - ...(durationMs === undefined ? {} : { durationMs }) - } +type LoadReplay = { + adopt: boolean + turn?: string + serial: number + users: AcpReplayUserMessages + pendingUser?: AgentJournalMessageItem } - -type PromptTurn = { turn: string; providerTurn?: string; durationMs?: number } -type LoadReplay = { adopt: boolean; turn?: string; serial: number; users: AcpReplayUserMessages } - export type AcpTimelineTranslatorOptions = { sessionId: string - /** The host supplies the committed journal after draining earlier writes, before session/load. */ + /** Committed journal after the host drains earlier writes before session/load. */ journalItems(): readonly AgentJournalRenderItem[] dialect?: AcpDialect } -/** Each method consumes one provider frame once; the lane retries returned grammar events. */ +/** Consumes each frame once; the host retries the returned grammar events. */ export class AcpTimelineTranslator { private readonly dialect: AcpDialect + private readonly prompts: AcpPromptTurns private readonly tools = new AcpToolTimeline() - private readonly aliases = new Map() - private prompt?: PromptTurn + private readonly messages = new AcpTurnMessages() + private readonly started = new BoundedMap({ maxEntries: 128 }) private replay?: LoadReplay + private window?: { tokens: number; capturedAt: number } + private activeTurn?: string constructor(private readonly options: AcpTimelineTranslatorOptions) { this.dialect = options.dialect ?? GENERIC_ACP_DIALECT + this.prompts = new AcpPromptTurns( + options.sessionId, + this.dialect.injectedPromptIdentity === true + ) } - openPrompt(clientMessageId: string, at: number): ProviderTimelineEvent[] { - if (this.prompt || this.replay) { + /** The host injects promptId as session/prompt._meta.promptId (and requestId). */ + openPrompt( + clientMessageId: string, + at: number + ): { promptId: string; events: ProviderTimelineEvent[] } { + if (this.replay) { throw new Error('ACP prompt overlaps a prompt or load') } - const turn = `prompt:${clientMessageId}` - this.prompt = { turn } - return [ - { - type: 'input.accepted', - clientMessageId, - requestedAt: at, - join: { thread: this.options.sessionId, turn } - }, - { type: 'turn.open', turn, at } - ] + return this.prompts.open(clientMessageId, at) } promptResult( @@ -84,38 +72,46 @@ export class AcpTimelineTranslator { result: PromptResponse, at: number ): ProviderTimelineEvent[] { - const turn = `prompt:${clientMessageId}` - const usage = - this.dialect.promptUsage?.(result, at) ?? - (result.usage ? acpResponseUsage(result.usage, at) : undefined) - const events: ProviderTimelineEvent[] = usage - ? [{ type: 'context.usage', usage, join: { thread: this.options.sessionId, turn } }] - : [] - if (!['end_turn', 'cancelled'].includes(result.stopReason)) { - events.push({ - type: 'provider.frame', - frameKind: `prompt:${result.stopReason}`, - payload: result, - join: { thread: this.options.sessionId, turn } - }) - } - events.push( - acpTurnEnd( - turn, - result.stopReason, - at, - this.prompt?.turn === turn ? this.prompt.durationMs : undefined - ) - ) - this.tools.end(turn) - if (this.prompt?.turn === turn) { - this.prompt = undefined + return this.finishPrompt(clientMessageId, result.stopReason, at) + } + + promptFailed(clientMessageId: string, _error: unknown, at: number): ProviderTimelineEvent[] { + return this.finishPrompt(clientMessageId, 'error', at) + } + + private finishPrompt( + clientMessageId: string, + stopReason: string, + at: number + ): ProviderTimelineEvent[] { + const prompt = this.prompts.current + if (prompt?.clientMessageId !== clientMessageId) { + return [] } + const events = this.prompts.start(prompt.turn, at) + events.push(acpTurnEnd(prompt.turn, stopReason, at, prompt.durationMs)) + this.end(prompt.turn) + this.prompts.current = undefined return events } + contextModels(models: unknown, at: number): ProviderTimelineEvent[] { + const tokens = this.dialect.contextWindow?.(models) + if (tokens === undefined) { + return [] + } + this.window = { tokens, capturedAt: at } + return [ + { + type: 'context.usage', + usage: { window: this.window }, + join: { thread: this.options.sessionId } + } + ] + } + beginLoad(): void { - if (this.prompt || this.replay) { + if (this.prompts.current || this.replay) { throw new Error('ACP load overlaps a prompt or load') } this.replay = { @@ -131,8 +127,7 @@ export class AcpTimelineTranslator { if (!turn) { return [] } - this.tools.end(turn) - // Generic ACP history has no stop verdict; load completion must not invent success. + this.end(turn) return [{ type: 'turn.end', turn, at, state: 'completed' }] } @@ -145,24 +140,57 @@ export class AcpTimelineTranslator { } notification(method: string, params: unknown, at: number): ProviderTimelineEvent[] { - const envelope = acpNotificationEnvelopeSchema.safeParse(params) const session = requestSessionSchema.safeParse(params) if (session.success && session.data.sessionId !== this.options.sessionId) { return [] } - const extension = this.dialect.notification?.(method, params, at) + const interpreted = this.dialect.notification?.(method, params, at) + if (interpreted?.disposition === 'ignore') { + return [] + } + const extension = interpreted + if (method !== 'session/update' && !extension) { + return [] + } + const envelope = acpNotificationEnvelopeSchema.safeParse(params) const markedReplay = (envelope.success && envelope.data._meta?.isReplay === true) || extension?.replay === true if (markedReplay && !this.replay) { - throw new Error('ACP replay requires beginLoad journal decision') + return [] } const isReplay = extension?.replay ?? (markedReplay || this.replay !== undefined) - if (isReplay && this.replay && !this.replay.adopt) { + const providerTurn = extension?.turn + if ( + isReplay && + this.replay && + (providerTurn + ? acpJournalTurnIsSettled(this.options.journalItems(), providerTurn) + : !this.replay.adopt && !this.dialect.injectedPromptIdentity) + ) { + this.replay.pendingUser = undefined return [] } const standard = method === 'session/update' ? SessionNotificationSchema.safeParse(params) : undefined const events: ProviderTimelineEvent[] = [] + const replayUser = + isReplay && this.replay && standard?.success + ? this.replay.users.observe(standard.data.update) + : undefined + if ( + isReplay && + this.replay && + this.dialect.injectedPromptIdentity && + replayUser?.body && + !providerTurn + ) { + this.replay.pendingUser = replayUser.body + return [] + } + if (replayUser?.startsMessage && this.replay?.turn && !providerTurn) { + events.push({ type: 'turn.end', turn: this.replay.turn, at, state: 'completed' }) + this.end(this.replay.turn) + } const substantive = standard?.success && [ @@ -173,64 +201,80 @@ export class AcpTimelineTranslator { 'tool_call_update', 'plan' ].includes(standard.data.update.sessionUpdate) - const replayUser = - isReplay && this.replay?.adopt && standard?.success - ? this.replay.users.observe(standard.data.update) - : undefined - if (replayUser?.startsMessage && this.replay?.turn) { - events.push({ type: 'turn.end', turn: this.replay.turn, at, state: 'completed' }) - this.tools.end(this.replay.turn) - this.replay.turn = undefined + let turn = + providerTurn ?? + (isReplay + ? this.replay?.turn + : this.prompts.current?.opened + ? this.prompts.current.turn + : this.activeTurn) + if (!turn && isReplay && this.replay?.adopt && substantive) { + turn = `replay:${this.replay.serial++}` } - const turn = this.routeTurn( - extension, - events, - at, - substantive || extension?.end !== undefined, - isReplay - ) - const join = { - join: { thread: this.options.sessionId, ...(turn === undefined ? {} : { turn }) } + if (turn && (substantive || extension?.end || extension?.started)) { + events.push(...this.start(turn, extension?.at ?? at)) + if (isReplay && this.replay) { + this.replay.turn = turn + if (this.replay.pendingUser) { + const existing = acpJournalTurnHasUser(this.options.journalItems(), turn) + if (!existing) { + events.push({ + type: 'item.update', + item: `replay-user:${turn}:`, + body: this.replay.pendingUser, + join: { thread: this.options.sessionId, turn } + }) + } + this.replay.pendingUser = undefined + } + } } + const join = { thread: this.options.sessionId, ...(turn === undefined ? {} : { turn }) } if (extension?.usage) { - events.push({ type: 'context.usage', usage: extension.usage, ...join }) + this.window = extension.usage.window ?? this.window + events.push({ + type: 'context.usage', + usage: { ...extension.usage, ...(this.window ? { window: this.window } : {}) }, + join + }) } if (extension?.end && turn) { - // Client prompts settle on their own response; extension completions own autonomous turns. - if (this.prompt?.turn === turn) { - this.prompt.durationMs = extension.end.durationMs + if ( + this.prompts.current?.turn === turn && + ['end_turn', 'cancelled'].includes(extension.end.stopReason) + ) { + this.prompts.current.durationMs = extension.end.durationMs } else { - if (!['end_turn', 'cancelled'].includes(extension.end.stopReason)) { - events.push({ - type: 'provider.frame', - frameKind: `turn:${extension.end.stopReason}`, - payload: params, - ...join - }) - } events.push(acpTurnEnd(turn, extension.end.stopReason, at, extension.end.durationMs)) - this.tools.end(turn) - if (this.replay?.turn === turn) { - this.replay.turn = undefined + this.end(turn) + if (this.prompts.current?.turn === turn) { + this.prompts.current = undefined } } return events } if (standard?.success) { + const messageKey = + turn && this.dialect.injectedPromptIdentity + ? this.messages.key(turn, standard.data.update) + : undefined return [ ...events, ...acpSessionUpdate( standard.data, turn, at, - isReplay && (this.replay?.adopt ?? false), + isReplay, this.tools, this.dialect, - replayUser?.body + replayUser?.body, + messageKey ) ] } - events.push({ type: 'provider.frame', frameKind: method, payload: params, ...join }) + if (method === 'session/update') { + events.push({ type: 'provider.frame', frameKind: method, payload: params, join }) + } return events } @@ -238,79 +282,36 @@ export class AcpTimelineTranslator { method: string, params: unknown, id: string | number - ): { - events: ProviderTimelineEvent[] - presentation?: AcpRequestPresentation - } { - const session = requestSessionSchema.safeParse(params) - if (!session.success || session.data.sessionId !== this.options.sessionId) { - throw new AcpRpcError(-32602, 'ACP request belongs to an unknown session') - } - const presentation = - method === 'session/request_permission' - ? acpPermissionPresentation(params) - : this.dialect.request?.(method, params) - const join = { thread: this.options.sessionId } - if (!presentation) { - return { - events: [{ type: 'provider.frame', frameKind: `request:${method}`, payload: params, join }] - } - } - return { - presentation, - events: [ - { - type: 'request.open', - request: `${method}:${JSON.stringify(id)}`, - body: presentation.body, - join - } - ] - } + ): { events: ProviderTimelineEvent[]; presentation?: AcpRequestPresentation } { + return translateAcpRequest(method, params, id, { + ...this.options, + dialect: this.dialect, + tools: this.tools + }) } - private alias(providerTurn: string, turn: string): void { - if (this.aliases.size >= 128) { - this.aliases.delete(this.aliases.keys().next().value ?? '') + private start(turn: string, at: number): ProviderTimelineEvent[] { + if (turn === this.prompts.current?.turn) { + const events = this.prompts.start(turn, at) + this.activeTurn = turn + return events } - this.aliases.set(providerTurn, turn) + if (this.started.has(turn)) { + return [] + } + this.started.set(turn, true) + this.activeTurn = turn + return [{ type: 'turn.open', turn, at }] } - private routeTurn( - extension: AcpDialectNotification | undefined, - events: ProviderTimelineEvent[], - at: number, - substantive: boolean, - isReplay: boolean - ): string | undefined { - const providerTurn = extension?.turn - const known = providerTurn ? this.aliases.get(providerTurn) : undefined - if (known) { - return known - } - if (isReplay && this.replay?.adopt && substantive) { - if (!this.replay.turn) { - this.replay.turn = providerTurn ?? `replay:${this.replay.serial++}` - events.push({ type: 'turn.open', turn: this.replay.turn, at: extension?.at ?? at }) - } - if (providerTurn) { - this.alias(providerTurn, this.replay.turn) - } - return this.replay.turn - } - if (extension?.agentInitiated && providerTurn && substantive) { - this.alias(providerTurn, providerTurn) - events.push({ type: 'turn.open', turn: providerTurn, at: extension.at ?? at }) - return providerTurn + private end(turn: string): void { + this.tools.end(turn) + this.messages.end(turn) + if (this.activeTurn === turn) { + this.activeTurn = undefined } - if (providerTurn && this.prompt && !this.prompt.providerTurn) { - this.prompt.providerTurn = providerTurn - this.alias(providerTurn, this.prompt.turn) + if (this.replay?.turn === turn) { + this.replay.turn = undefined } - return ( - (providerTurn ? (this.aliases.get(providerTurn) ?? providerTurn) : undefined) ?? - this.replay?.turn ?? - this.prompt?.turn - ) } } diff --git a/src/main/acp/acp-tool-timeline.ts b/src/main/acp/acp-tool-timeline.ts index b1c72d6d063e..5cde7b29e0b0 100644 --- a/src/main/acp/acp-tool-timeline.ts +++ b/src/main/acp/acp-tool-timeline.ts @@ -150,6 +150,10 @@ export class AcpToolTimeline { return events } + turn(toolCallId: string): string | undefined { + return this.tools.get(toolCallId)?.turn + } + end(turn: string): void { for (const [key, snapshot] of this.tools) { if (snapshot.turn === turn) { @@ -165,6 +169,13 @@ export class AcpToolTimeline { private remember(key: string, snapshot: ToolSnapshot): void { const previous = this.tools.get(key) + if (this.size(key, snapshot) > 1024 * 1024) { + if (previous) { + this.bytes -= this.size(key, previous) + this.tools.delete(key) + } + return + } let bytes = this.bytes - (previous ? this.size(key, previous) : 0) + this.size(key, snapshot) let count = this.tools.size + (previous ? 0 : 1) const evicted: string[] = [] @@ -178,8 +189,15 @@ export class AcpToolTimeline { bytes -= this.size(candidate, stored) } } - if (count > 128 || bytes > 1024 * 1024) { - throw new Error('ACP tool snapshot budget exceeded') + for (const [candidate, stored] of this.tools) { + if (count <= 128 && bytes <= 1024 * 1024) { + break + } + if (candidate !== key && !evicted.includes(candidate)) { + evicted.push(candidate) + count -= 1 + bytes -= this.size(candidate, stored) + } } for (const candidate of evicted) { this.tools.delete(candidate) diff --git a/src/main/acp/acp-turn-messages.ts b/src/main/acp/acp-turn-messages.ts new file mode 100644 index 000000000000..e7b8a7e49bc9 --- /dev/null +++ b/src/main/acp/acp-turn-messages.ts @@ -0,0 +1,35 @@ +import { BoundedMap } from '../../shared/bounded-map' +import type { SessionUpdate } from './generated/acp-protocol.generated' + +type MessagePosition = { ordinal: number; channel?: string } + +/** Stable turn-relative message positions also identify coalesced load snapshots. */ +export class AcpTurnMessages { + private readonly turns = new BoundedMap({ maxEntries: 128 }) + + key(turn: string, update: SessionUpdate): string | undefined { + const state = this.turns.get(turn) ?? { ordinal: 0 } + this.turns.set(turn, state) + if ( + update.sessionUpdate !== 'agent_message_chunk' && + update.sessionUpdate !== 'agent_thought_chunk' + ) { + if (['tool_call', 'tool_call_update', 'plan'].includes(update.sessionUpdate)) { + state.channel = undefined + } + return undefined + } + if (update.messageId) { + return `message:${update.messageId}` + } + if (state.channel !== update.sessionUpdate) { + state.ordinal += 1 + state.channel = update.sessionUpdate + } + return `turn-message:${JSON.stringify([turn, state.ordinal])}` + } + + end(turn: string): void { + this.turns.delete(turn) + } +} diff --git a/src/main/acp/fixtures/s1-basic.jsonl b/src/main/acp/fixtures/s1-basic.jsonl index e15324dcc61a..b36e8ce2f1dd 100644 --- a/src/main/acp/fixtures/s1-basic.jsonl +++ b/src/main/acp/fixtures/s1-basic.jsonl @@ -1,11 +1,12 @@ -{"direction":"out","process":"p1","message":{"jsonrpc":"2.0","id":3,"method":"session/prompt","params":{"sessionId":"session-1","prompt":[{"type":"text","text":"Fixture prompt"}]}}} -{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"I'll"}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} -{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" list"}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} -{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call","toolCallId":"call-1","title":"list_dir","rawInput":{"target_directory":"/workspace/file-1"},"_meta":{"x.ai/tool":{"name":"list_dir","kind":"list"}}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} -{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-1","kind":"other","title":"List `/workspace/file-1`","locations":[{"path":"/workspace/file-1"}],"rawInput":{"variant":"ListDir","target_directory":"/workspace/file-1"},"_meta":{"x.ai/tool":{"name":"list_dir","kind":"list"}}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} -{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call","toolCallId":"call-2","title":"read_file","rawInput":{"target_file":"/workspace/file-2"},"_meta":{"x.ai/tool":{"name":"read_file","kind":"read"}}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} -{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-2","kind":"read","title":"Read `/workspace/file-2`","locations":[{"path":"/workspace/file-2"}],"rawInput":{"variant":"ReadFile","target_file":"/workspace/file-2"},"_meta":{"x.ai/tool":{"name":"read_file","kind":"read"}}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} -{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-2","status":"completed","content":[{"type":"content","content":{"type":"text","text":"1\u2192# Scratch project 1\n\nA tiny throwaway project used to record Grok ACP traffic.\n"}}],"rawOutput":{"type":"ReadFile","FileContent":{"content":"1\u2192# Scratch project 1\n\nA tiny throwaway project used to record Grok ACP traffic.\n","content_concise":"1\u2192# Scratch project 1\n\nA tiny throwaway project used to record Grok ACP traffic.\n","absolute_path":"/workspace/file-2","offset":null,"raw_output":"# Scratch project 1\n\nA tiny throwaway project used to record Grok ACP traffic.\n","total_lines":4}}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} -{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-1","status":"completed","rawOutput":{"type":"ListDir","Content":{"content":"- /workspace/file-3\n - math.js\n - notes.txt\n - README.md","absolute_root_path":"/workspace/file-1"}}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} -{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"turn_completed","prompt_id":"turn-1","stop_reason":"end_turn","usage":{"inputTokens":68922,"outputTokens":195,"cachedReadTokens":35840,"cacheCreationTokens":0},"elapsed_ms":5289}}}} +{"direction":"out","process":"p1","message":{"jsonrpc":"2.0","id":3,"method":"session/prompt","params":{"sessionId":"session-1","prompt":[{"type":"text","text":"Fixture prompt"}],"_meta":{"promptId":"prompt:p1:3"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"I'll"}},"_meta":{"promptId":"prompt:p1:3","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" list"}},"_meta":{"promptId":"prompt:p1:3","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call","toolCallId":"call-1","title":"list_dir","rawInput":{"target_directory":"/workspace/file-1"},"_meta":{"x.ai/tool":{"name":"list_dir","kind":"list"}}},"_meta":{"promptId":"prompt:p1:3","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-1","kind":"other","title":"List `/workspace/file-1`","locations":[{"path":"/workspace/file-1"}],"rawInput":{"variant":"ListDir","target_directory":"/workspace/file-1"},"_meta":{"x.ai/tool":{"name":"list_dir","kind":"list"}}},"_meta":{"promptId":"prompt:p1:3","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call","toolCallId":"call-2","title":"read_file","rawInput":{"target_file":"/workspace/file-2"},"_meta":{"x.ai/tool":{"name":"read_file","kind":"read"}}},"_meta":{"promptId":"prompt:p1:3","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-2","kind":"read","title":"Read `/workspace/file-2`","locations":[{"path":"/workspace/file-2"}],"rawInput":{"variant":"ReadFile","target_file":"/workspace/file-2"},"_meta":{"x.ai/tool":{"name":"read_file","kind":"read"}}},"_meta":{"promptId":"prompt:p1:3","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-2","status":"completed","content":[{"type":"content","content":{"type":"text","text":"1\u2192# Scratch project 1\n\nA tiny throwaway project used to record Grok ACP traffic.\n"}}],"rawOutput":{"type":"ReadFile","FileContent":{"content":"1\u2192# Scratch project 1\n\nA tiny throwaway project used to record Grok ACP traffic.\n","content_concise":"1\u2192# Scratch project 1\n\nA tiny throwaway project used to record Grok ACP traffic.\n","absolute_path":"/workspace/file-2","offset":null,"raw_output":"# Scratch project 1\n\nA tiny throwaway project used to record Grok ACP traffic.\n","total_lines":4}}},"_meta":{"promptId":"prompt:p1:3","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-1","status":"completed","rawOutput":{"type":"ListDir","Content":{"content":"- /workspace/file-3\n - math.js\n - notes.txt\n - README.md","absolute_root_path":"/workspace/file-1"}}},"_meta":{"promptId":"prompt:p1:3","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"response_completed","usage":{"input_tokens":446,"output_tokens":74,"cache_read_input_tokens":34176,"cache_creation_input_tokens":0,"reasoning_tokens":24}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"turn_completed","prompt_id":"prompt:p1:3","stop_reason":"end_turn","usage":{"inputTokens":68922,"outputTokens":195,"cachedReadTokens":35840,"cacheCreationTokens":0},"elapsed_ms":5289}}}} {"direction":"in","process":"p1","message":{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn","_meta":{"usage":{"inputTokens":68922,"outputTokens":195,"cachedReadTokens":35840,"cacheCreationTokens":0}}}}} diff --git a/src/main/acp/fixtures/s1-full-notifications.jsonl b/src/main/acp/fixtures/s1-full-notifications.jsonl new file mode 100644 index 000000000000..de1e19fd50a0 --- /dev/null +++ b/src/main/acp/fixtures/s1-full-notifications.jsonl @@ -0,0 +1,142 @@ +{"direction":"out","process":"p1","message":{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":1,"clientCapabilities":{"fs":{"readTextFile":false,"writeTextFile":false},"terminal":false},"clientInfo":{"name":"orca-acp-capture","title":"Orca ACP capture harness","version":"0.0.1"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":true},"mcpCapabilities":{"http":true,"sse":true},"sessionCapabilities":{"list":{},"resume":{},"close":{}},"auth":{},"_meta":{"x.ai/fs_notify":true,"x.ai/hooks":{"blockingEvents":["pre_tool_use","stop","subagent_stop"],"decisions":["deny","block"],"stopSignals":["continue","stopReason","additionalContext"]},"x.ai/capabilities":{"toolOverrides":{"x_keyword_search":true,"x_semantic_search":true,"x_user_search":false,"x_thread_fetch":false}}}},"authMethods":[{"id":"cached_token","name":"cached_token","description":"Cached token from ~/.grok/auth.json"},{"id":"grok.com","name":"Grok","description":"Sign in with Grok"}],"_meta":{"grokShell":true,"defaultAuthMethodId":"cached_token","x.ai/mcp/sdk":true,"x.ai/pluginDirs":true,"currentWorkingDirectory":"Fixture value","agentVersion":"1.0.46","agentId":"","agentInstanceId":"","modelState":{"currentModelId":"grok-4.7","availableModels":[{"modelId":"grok-4.7","name":"Grok 4.7","description":"Fixture value","_meta":{"totalContextTokens":256000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}],"contextWindows":[256000,500000]}},{"modelId":"grok-4.7-build-fast","name":"Grok 4.7 Fast","description":"Fast variant. 2x the price.","_meta":{"totalContextTokens":256000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}],"contextWindows":[256000,500000]}},{"modelId":"grok-4.6","name":"Grok 4.6","_meta":{"totalContextTokens":256000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Highest effort and reasoning level","default":false},{"id":"high","value":"high","label":"High","description":"Higher implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}],"contextWindows":[256000,500000]}},{"modelId":"grok-4.5","name":"Grok 4.5","_meta":{"totalContextTokens":256000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"high","value":"high","label":"High","description":"Highest implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}],"contextWindows":[256000,500000]}}]},"mcpServers":[],"mcpApps":false,"metadata":null,"availableCommands":[],"cancelRewind":true,"sessionRecap":true,"feedbackTraceOffer":false,"voiceMode":true}}}} +{"direction":"out","process":"p1","message":{"jsonrpc":"2.0","id":2,"method":"session/new","params":{"cwd":"Fixture value","mcpServers":[]}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session/setup","params":{"method":"session/new","phase":"auth","sessionId":null}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session/setup","params":{"method":"session/new","phase":"resolve_workspace","sessionId":null}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session/setup","params":{"method":"session/new","phase":"folder_trust","sessionId":null}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session/setup","params":{"method":"session/new","phase":"plugin_registry","sessionId":null}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session/setup","params":{"method":"session/new","phase":"mcp_merge","sessionId":null}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session/setup","params":{"method":"session/new","phase":"persistence_init","sessionId":"session-1"}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/mcp/servers_updated","params":{"mcpServers":[]}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session/setup","params":{"method":"session/new","phase":"spawn_session_actor","sessionId":"session-1"}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session/setup","params":{"method":"session/new","phase":"agent_build","sessionId":"session-1"}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/models/update","params":{"currentModelId":"grok-4.7","availableModels":[{"modelId":"grok-4.7","name":"Grok 4.7","description":"Fixture value","_meta":{"totalContextTokens":256000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}],"contextWindows":[256000,500000]}},{"modelId":"grok-4.7-build-fast","name":"Grok 4.7 Fast","description":"Fast variant. 2x the price.","_meta":{"totalContextTokens":256000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}],"contextWindows":[256000,500000]}},{"modelId":"grok-4.6","name":"Grok 4.6","_meta":{"totalContextTokens":256000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Highest effort and reasoning level","default":false},{"id":"high","value":"high","label":"High","description":"Higher implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}],"contextWindows":[256000,500000]}},{"modelId":"grok-4.5","name":"Grok 4.5","_meta":{"totalContextTokens":256000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"high","value":"high","label":"High","description":"Highest implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}],"contextWindows":[256000,500000]}}]}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/models/update","params":{"currentModelId":"grok-4.7","availableModels":[{"modelId":"grok-4.7","name":"Grok 4.7","description":"Fixture value","_meta":{"totalContextTokens":256000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}],"contextWindows":[256000,500000]}},{"modelId":"grok-4.7-build-fast","name":"Grok 4.7 Fast","description":"Fast variant. 2x the price.","_meta":{"totalContextTokens":256000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}],"contextWindows":[256000,500000]}},{"modelId":"grok-4.6","name":"Grok 4.6","_meta":{"totalContextTokens":256000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Highest effort and reasoning level","default":false},{"id":"high","value":"high","label":"High","description":"Higher implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}],"contextWindows":[256000,500000]}},{"modelId":"grok-4.5","name":"Grok 4.5","_meta":{"totalContextTokens":256000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"high","value":"high","label":"High","description":"Highest implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}],"contextWindows":[256000,500000]}}]}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/settings/update","params":{"show_resolved_model":false,"sharing_enabled":false,"privacy_notice_rollout":true,"privacy_banner_reshow_days":0,"session_picker_grouped":null,"tips":["Use @ to attach files like @src/main.rs.","Use @! for hidden or ignored files: @!.github/workflows.","Press Ctrl+O to toggle auto-approve mode.","Use Shift+Tab to cycle between modes like Plan mode.","Run /compact [context] when chat gets long.","Press Ctrl+B to background a running terminal command.","Start Grok in a fresh worktree with `-w`; add `-r ` to resume an existing session there.","Use Ctrl+Enter to interject messages. Or just Enter to queue messages.","Run /dashboard (or Ctrl+\\) to see and manage all your agents in one place.","Try out workflows using /workflows."],"slash_command_tags":{"learn":"new","memory":"new"},"announcements":[{"id":"grok-4.7","message":"Select 'Grok 4.7' under /model.","severity":"info","title":"Grok 4.7 is here!","cta":null,"updated_at":null,"expires_at":null,"dismissible":null,"persistent":null},{"id":"Fixture value","message":"Ask Grok to /learn from your past traces and tune Grok Build to how you work.","severity":"info","title":"Fixture value","cta":null,"updated_at":null,"expires_at":null,"dismissible":null,"persistent":null}],"campaigns":[{"id":"grok-4.7-launch","models":{"default":"grok-4.7"}}],"gate_message":null,"gate_url":null,"gate_label":null,"allow_access":true,"consent_gate":null,"auto_permission_mode_enabled":null,"prompt_suggestions_enabled":null,"permission_mode":null,"group_tool_verbs":null,"collapsed_edit_blocks":null,"subscription_watch_interval_secs":60,"dock_enabled":true,"terminal_theme_enabled":null,"subagent_model_inheritance_enabled":true}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/announcements/update","params":{"gen":1791057313,"announcements":[{"id":"grok-4.7","message":"Select 'Grok 4.7' under /model.","severity":"info","title":"Grok 4.7 is here!","cta":null,"updated_at":null,"expires_at":null,"dismissible":null,"persistent":null},{"id":"Fixture value","message":"Ask Grok to /learn from your past traces and tune Grok Build to how you work.","severity":"info","title":"Fixture value","cta":null,"updated_at":null,"expires_at":null,"dismissible":null,"persistent":null}]}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/mcp_initialized","params":{"sessionId":"session-1","mcpToolCount":0,"elapsedMs":0}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/settings/update","params":{"show_resolved_model":false,"sharing_enabled":false,"privacy_notice_rollout":true,"privacy_banner_reshow_days":0,"session_picker_grouped":null,"tips":["Use @ to attach files like @src/main.rs.","Use @! for hidden or ignored files: @!.github/workflows.","Press Ctrl+O to toggle auto-approve mode.","Use Shift+Tab to cycle between modes like Plan mode.","Run /compact [context] when chat gets long.","Press Ctrl+B to background a running terminal command.","Start Grok in a fresh worktree with `-w`; add `-r ` to resume an existing session there.","Use Ctrl+Enter to interject messages. Or just Enter to queue messages.","Run /dashboard (or Ctrl+\\) to see and manage all your agents in one place.","Try out workflows using /workflows."],"slash_command_tags":{"learn":"new","memory":"new"},"announcements":[{"id":"grok-4.7","message":"Select 'Grok 4.7' under /model.","severity":"info","title":"Grok 4.7 is here!","cta":null,"updated_at":null,"expires_at":null,"dismissible":null,"persistent":null},{"id":"Fixture value","message":"Ask Grok to /learn from your past traces and tune Grok Build to how you work.","severity":"info","title":"Fixture value","cta":null,"updated_at":null,"expires_at":null,"dismissible":null,"persistent":null}],"campaigns":[{"id":"grok-4.7-launch","models":{"default":"grok-4.7"}}],"gate_message":null,"gate_url":null,"gate_label":null,"allow_access":true,"consent_gate":null,"auto_permission_mode_enabled":null,"prompt_suggestions_enabled":null,"permission_mode":null,"group_tool_verbs":null,"collapsed_edit_blocks":null,"subscription_watch_interval_secs":60,"dock_enabled":true,"terminal_theme_enabled":null,"subagent_model_inheritance_enabled":true}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/announcements/update","params":{"gen":1791057314,"announcements":[{"id":"grok-4.7","message":"Select 'Grok 4.7' under /model.","severity":"info","title":"Grok 4.7 is here!","cta":null,"updated_at":null,"expires_at":null,"dismissible":null,"persistent":null},{"id":"Fixture value","message":"Ask Grok to /learn from your past traces and tune Grok Build to how you work.","severity":"info","title":"Fixture value","cta":null,"updated_at":null,"expires_at":null,"dismissible":null,"persistent":null}]}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session/setup","params":{"method":"session/new","phase":"model_switch","sessionId":"session-1"}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"available_commands_update","availableCommands":[],"_meta":{"tools":["run_terminal_command","read_file","search_replace","list_dir","grep","kill_command_or_subagent","todo_write","get_command_or_subagent_output","spawn_subagent","scheduler_create","scheduler_delete","scheduler_list","monitor","search_tool","use_tool","workflow","enter_plan_mode","exit_plan_mode","ask_user_question","send_feedback","web_search","web_fetch","image_gen","image_edit","image_to_video","reference_to_video","write"]}},"_meta":{"totalTokens":1833,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-0","agentTimestampMs":1791057313672,"updateType":"AvailableCommandsUpdate","updateParams":{"commandsCount":59}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"available_commands_update","availableCommands":[],"_meta":{"tools":["run_terminal_command","read_file","search_replace","list_dir","grep","kill_command_or_subagent","todo_write","get_command_or_subagent_output","spawn_subagent","scheduler_create","scheduler_delete","scheduler_list","monitor","search_tool","use_tool","workflow","enter_plan_mode","exit_plan_mode","ask_user_question","send_feedback","web_search","web_fetch","image_gen","image_edit","image_to_video","reference_to_video","write"]}},"_meta":{"totalTokens":1833,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-1","agentTimestampMs":1791057313680,"updateType":"AvailableCommandsUpdate","updateParams":{"commandsCount":59}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"model_changed","model_id":"grok-4.7","reasoning_effort":"high"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session/setup","params":{"method":"session/new","phase":"git_discovery","sessionId":"session-1"}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session/setup","params":{"method":"session/new","phase":"finalize_response","sessionId":"session-1"}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session/setup","params":{"method":"session/new","phase":"tool_overrides","sessionId":"session-1"}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","id":2,"result":{"sessionId":"session-1","models":{"currentModelId":"grok-4.7","availableModels":[{"modelId":"grok-4.7","name":"Grok 4.7","description":"Fixture value","_meta":{"totalContextTokens":256000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}],"contextWindows":[256000,500000]}},{"modelId":"grok-4.7-build-fast","name":"Grok 4.7 Fast","description":"Fast variant. 2x the price.","_meta":{"totalContextTokens":256000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}],"contextWindows":[256000,500000]}},{"modelId":"grok-4.6","name":"Grok 4.6","_meta":{"totalContextTokens":256000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Highest effort and reasoning level","default":false},{"id":"high","value":"high","label":"High","description":"Higher implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}],"contextWindows":[256000,500000]}},{"modelId":"grok-4.5","name":"Grok 4.5","_meta":{"totalContextTokens":256000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"high","value":"high","label":"High","description":"Highest implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}],"contextWindows":[256000,500000]}}]},"configOptions":[{"id":"model","name":"Model","category":"model","type":"select","currentValue":"grok-4.7","options":[{"value":"grok-4.7","name":"Grok 4.7"},{"value":"grok-4.7-build-fast","name":"Grok 4.7 Fast"},{"value":"grok-4.6","name":"Grok 4.6"},{"value":"grok-4.5","name":"Grok 4.5"}]},{"id":"reasoning_effort","name":"Reasoning Effort","category":"thought_level","type":"select","currentValue":"high","options":[{"value":"xhigh","name":"Extra High","description":"Maximum reasoning for the hardest tasks."},{"value":"high","name":"High","description":"Thorough reasoning and quality. Recommended."},{"value":"medium","name":"Medium","description":"Strong quality with a faster turnaround."},{"value":"low","name":"Low","description":"Fastest responses. Best for simple tasks."}]}],"_meta":{"currentWorkingDirectory":"Fixture value","codebaseIndexed":[],"isGitRepo":false,"gitRoot":null,"showNonGitWarning":false,"feedbackEnabled":true,"x.ai/sessionConfig":{"options":[{"id":"grok-4.7","category":"model","label":"Grok 4.7","selected":true},{"id":"grok-4.7-build-fast","category":"model","label":"Grok 4.7 Fast","selected":false},{"id":"grok-4.6","category":"model","label":"Grok 4.6","selected":false},{"id":"grok-4.5","category":"model","label":"Grok 4.5","selected":false},{"id":"xhigh","category":"mode","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","selected":false},{"id":"high","category":"mode","label":"High","description":"Thorough reasoning and quality. Recommended.","selected":true},{"id":"medium","category":"mode","label":"Medium","description":"Strong quality with a faster turnaround.","selected":false},{"id":"low","category":"mode","label":"Low","description":"Fastest responses. Best for simple tasks.","selected":false}]},"x.ai/sessionDetail":{"sessionId":"session-1","kind":"build","cwd":"Fixture value","currentModelId":"grok-4.7"},"x.ai/memoryMode":"v2"}}}} +{"direction":"out","process":"p1","message":{"jsonrpc":"2.0","id":3,"method":"session/prompt","params":{"sessionId":"session-1","prompt":[{"type":"text","text":"list the files in this folder and read README.md"}],"_meta":{"promptId":"prompt:p1:3"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session/setup","params":{"method":"session/new","phase":"response_ready","sessionId":"session-1"}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"hook_execution","event_name":"session_start","runs":[{"name":"global/orca-status:session_start[0].hooks[0]","status":{"status":"success","elapsed_ms":9}},{"name":"global/settings:session_start[0].hooks[0]","status":{"status":"success","elapsed_ms":14}}]},"_meta":{"eventId":"01a10355-beef-7083-805c-806758f9a7d2-2","agentTimestampMs":1791057313704}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/sessions/changed","params":{"upserted":[{"sessionId":"session-1","title":null,"cwd":"Fixture value","isWorktree":false,"modelId":"grok-4.7","reasoningEffort":"high","yolo":true,"activity":"working","resident":true,"lastChangeUnixMs":1791057313707,"origin":{"kind":"local"}}],"removed":[]}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/queue/changed","params":{"sessionId":"session-1","entries":[{"id":"prompt:p1:3","version":0,"kind":"prompt","text":"list the files in this folder and read README.md","position":0}]}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/queue/changed","params":{"sessionId":"session-1","entries":[],"runningPromptId":"prompt:p1:3","runningText":"list the files in this folder and read README.md","runningKind":"prompt"}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"hook_run_started","event_name":"user_prompt_submit","prompt_id":"prompt:p1:3","count":2},"_meta":{"agentTimestampMs":1791057313720}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"hook_execution","event_name":"user_prompt_submit","prompt_id":"prompt:p1:3","runs":[{"name":"global/orca-status:user_prompt_submit[0].hooks[0]","status":{"status":"success","elapsed_ms":8}},{"name":"global/settings:user_prompt_submit[0].hooks[0]","status":{"status":"success","elapsed_ms":11}}]},"_meta":{"eventId":"01a10355-beef-7083-805c-806758f9a7d2-3","agentTimestampMs":1791057313739}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/models/update","params":{"currentModelId":"grok-4.7","availableModels":[{"modelId":"grok-4.7","name":"Grok 4.7","description":"Fixture value","_meta":{"totalContextTokens":256000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}],"contextWindows":[256000,500000]}},{"modelId":"grok-4.7-build-fast","name":"Grok 4.7 Fast","description":"Fast variant. 2x the price.","_meta":{"totalContextTokens":256000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}],"contextWindows":[256000,500000]}},{"modelId":"grok-4.6","name":"Grok 4.6","_meta":{"totalContextTokens":256000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Highest effort and reasoning level","default":false},{"id":"high","value":"high","label":"High","description":"Higher implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}],"contextWindows":[256000,500000]}},{"modelId":"grok-4.5","name":"Grok 4.5","_meta":{"totalContextTokens":256000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"high","value":"high","label":"High","description":"Highest implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}],"contextWindows":[256000,500000]}}]}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/announcements/update","params":{"gen":1791057315,"announcements":[{"id":"grok-4.7","message":"Select 'Grok 4.7' under /model.","severity":"info","title":"Grok 4.7 is here!","cta":null,"updated_at":null,"expires_at":null,"dismissible":null,"persistent":null},{"id":"Fixture value","message":"Ask Grok to /learn from your past traces and tune Grok Build to how you work.","severity":"info","title":"Fixture value","cta":null,"updated_at":null,"expires_at":null,"dismissible":null,"persistent":null}]}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"I'll"}},"_meta":{"totalTokens":1851,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-6","agentTimestampMs":1791057316114,"promptId":"prompt:p1:3","streamStartMs":1791057314618,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":1}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" list"}},"_meta":{"totalTokens":1851,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-7","agentTimestampMs":1791057316114,"promptId":"prompt:p1:3","streamStartMs":1791057314618,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":2}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" the"}},"_meta":{"totalTokens":1851,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-8","agentTimestampMs":1791057316114,"promptId":"prompt:p1:3","streamStartMs":1791057314618,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":3}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" folder"}},"_meta":{"totalTokens":1851,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-9","agentTimestampMs":1791057316114,"promptId":"prompt:p1:3","streamStartMs":1791057314618,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":4}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" contents"}},"_meta":{"totalTokens":1851,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-10","agentTimestampMs":1791057316114,"promptId":"prompt:p1:3","streamStartMs":1791057314618,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":5}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" and"}},"_meta":{"totalTokens":1851,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-11","agentTimestampMs":1791057316114,"promptId":"prompt:p1:3","streamStartMs":1791057314618,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":6}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" read"}},"_meta":{"totalTokens":1851,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-12","agentTimestampMs":1791057316114,"promptId":"prompt:p1:3","streamStartMs":1791057314618,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":7}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" `"}},"_meta":{"totalTokens":1851,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-13","agentTimestampMs":1791057316114,"promptId":"prompt:p1:3","streamStartMs":1791057314618,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":8}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"README"}},"_meta":{"totalTokens":1851,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-14","agentTimestampMs":1791057316114,"promptId":"prompt:p1:3","streamStartMs":1791057314618,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":9}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":".md"}},"_meta":{"totalTokens":1851,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-15","agentTimestampMs":1791057316114,"promptId":"prompt:p1:3","streamStartMs":1791057314618,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":10}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"`."}},"_meta":{"totalTokens":1851,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-16","agentTimestampMs":1791057316114,"promptId":"prompt:p1:3","streamStartMs":1791057314618,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":11}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_delta_chunk","tool_call_id":"call-d473a4c2-cca8-48da-a279-35e37479b59a-0","tool_index":0,"name":"list_dir"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_delta_chunk","tool_index":0,"arguments_delta":"Fixture value"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","id":"Fixture value","result":{"result":{"reloaded":1}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"available_commands_update","availableCommands":[],"_meta":{"tools":["run_terminal_command","read_file","search_replace","list_dir","grep","kill_command_or_subagent","todo_write","get_command_or_subagent_output","spawn_subagent","scheduler_create","scheduler_delete","scheduler_list","monitor","search_tool","use_tool","workflow","enter_plan_mode","exit_plan_mode","ask_user_question","send_feedback","web_search","web_fetch","image_gen","image_edit","image_to_video","reference_to_video","write"]}},"_meta":{"totalTokens":1851,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-17","agentTimestampMs":1791057316633,"promptId":"prompt:p1:3","streamStartMs":1791057314618,"turnStartMs":1791057313774,"updateType":"AvailableCommandsUpdate","updateParams":{"commandsCount":59}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","id":"Fixture value","result":{"result":{"reloaded":1}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","id":"Fixture value","result":{"result":{"reloaded":1}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"available_commands_update","availableCommands":[],"_meta":{"tools":["run_terminal_command","read_file","search_replace","list_dir","grep","kill_command_or_subagent","todo_write","get_command_or_subagent_output","spawn_subagent","scheduler_create","scheduler_delete","scheduler_list","monitor","search_tool","use_tool","workflow","enter_plan_mode","exit_plan_mode","ask_user_question","send_feedback","web_search","web_fetch","image_gen","image_edit","image_to_video","reference_to_video","write"]}},"_meta":{"totalTokens":1851,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-18","agentTimestampMs":1791057316655,"promptId":"prompt:p1:3","streamStartMs":1791057314618,"turnStartMs":1791057313774,"updateType":"AvailableCommandsUpdate","updateParams":{"commandsCount":59}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"available_commands_update","availableCommands":[],"_meta":{"tools":["run_terminal_command","read_file","search_replace","list_dir","grep","kill_command_or_subagent","todo_write","get_command_or_subagent_output","spawn_subagent","scheduler_create","scheduler_delete","scheduler_list","monitor","search_tool","use_tool","workflow","enter_plan_mode","exit_plan_mode","ask_user_question","send_feedback","web_search","web_fetch","image_gen","image_edit","image_to_video","reference_to_video","write"]}},"_meta":{"totalTokens":1851,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-19","agentTimestampMs":1791057316666,"promptId":"prompt:p1:3","streamStartMs":1791057314618,"turnStartMs":1791057313774,"updateType":"AvailableCommandsUpdate","updateParams":{"commandsCount":59}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_delta_chunk","tool_call_id":"call-d473a4c2-cca8-48da-a279-35e37479b59a-1","tool_index":1,"name":"read_file"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_delta_chunk","tool_index":1,"arguments_delta":"Fixture value"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"available_commands_update","availableCommands":[],"_meta":{"tools":["run_terminal_command","read_file","search_replace","list_dir","grep","kill_command_or_subagent","todo_write","get_command_or_subagent_output","spawn_subagent","scheduler_create","scheduler_delete","scheduler_list","monitor","search_tool","use_tool","workflow","enter_plan_mode","exit_plan_mode","ask_user_question","send_feedback","web_search","web_fetch","image_gen","image_edit","image_to_video","reference_to_video","write"]}},"_meta":{"totalTokens":34429,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-20","agentTimestampMs":1791057316961,"promptId":"prompt:p1:3","streamStartMs":1791057314618,"turnStartMs":1791057313774,"updateType":"AvailableCommandsUpdate","updateParams":{"commandsCount":59}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"response_completed","usage":{"input_tokens":32636,"output_tokens":121,"cache_read_input_tokens":1664,"cache_creation_input_tokens":0,"reasoning_tokens":28}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"hook_run_started","event_name":"pre_tool_use","tool_name":"list_dir","count":2},"_meta":{"agentTimestampMs":1791057316962}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call","toolCallId":"call-d473a4c2-cca8-48da-a279-35e37479b59a-0","title":"list_dir","rawInput":{"target_directory":"Fixture value"},"_meta":{"x.ai/tool":{"version":1,"name":"list_dir","kind":"list","namespace":"grok_build","label":"List Files","read_only":true}}},"_meta":{"totalTokens":34429,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-21","agentTimestampMs":1791057316962,"promptId":"prompt:p1:3","streamStartMs":1791057314618,"turnStartMs":1791057313774,"updateType":"ToolCall","updateParams":{"toolCallId":"call-d473a4c2-cca8-48da-a279-35e37479b59a-0","title":"list_dir","kind":"Other","status":"Pending"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"hook_execution","event_name":"pre_tool_use","tool_name":"list_dir","runs":[{"name":"global/orca-status:pre_tool_use[0].hooks[0]","status":{"status":"success","elapsed_ms":11}},{"name":"global/settings:pre_tool_use[1].hooks[0]","status":{"status":"success","elapsed_ms":12}}]},"_meta":{"eventId":"01a10355-beef-7083-805c-806758f9a7d2-22","agentTimestampMs":1791057316986}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"pending_interaction","tool_call_id":"call-d473a4c2-cca8-48da-a279-35e37479b59a-0","kind":"permission"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-d473a4c2-cca8-48da-a279-35e37479b59a-0","kind":"other","title":"Fixture value","locations":[{"path":"Fixture value"}],"rawInput":{"variant":"ListDir","target_directory":"Fixture value"},"_meta":{"x.ai/tool":{"version":1,"name":"list_dir","kind":"list","namespace":"grok_build","label":"List Files","read_only":true,"input":{"directory":"Fixture value"}}}},"_meta":{"totalTokens":34429,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-23","agentTimestampMs":1791057316986,"promptId":"prompt:p1:3","streamStartMs":1791057314618,"turnStartMs":1791057313774,"updateType":"ToolCallUpdate","updateParams":{"toolCallId":"call-d473a4c2-cca8-48da-a279-35e37479b59a-0","status":null}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"interaction_resolved","tool_call_id":"call-d473a4c2-cca8-48da-a279-35e37479b59a-0"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"hook_run_started","event_name":"pre_tool_use","tool_name":"read_file","count":2},"_meta":{"agentTimestampMs":1791057316988}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call","toolCallId":"call-d473a4c2-cca8-48da-a279-35e37479b59a-1","title":"read_file","rawInput":{"target_file":"Fixture value"},"_meta":{"x.ai/tool":{"version":1,"name":"read_file","kind":"read","namespace":"grok_build","label":"Read","read_only":true}}},"_meta":{"totalTokens":34429,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-24","agentTimestampMs":1791057316988,"promptId":"prompt:p1:3","streamStartMs":1791057314618,"turnStartMs":1791057313774,"updateType":"ToolCall","updateParams":{"toolCallId":"call-d473a4c2-cca8-48da-a279-35e37479b59a-1","title":"read_file","kind":"Other","status":"Pending"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"hook_execution","event_name":"pre_tool_use","tool_name":"read_file","runs":[{"name":"global/orca-status:pre_tool_use[0].hooks[0]","status":{"status":"success","elapsed_ms":8}},{"name":"global/settings:pre_tool_use[1].hooks[0]","status":{"status":"success","elapsed_ms":12}}]},"_meta":{"eventId":"01a10355-beef-7083-805c-806758f9a7d2-25","agentTimestampMs":1791057317008}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"pending_interaction","tool_call_id":"call-d473a4c2-cca8-48da-a279-35e37479b59a-1","kind":"permission"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-d473a4c2-cca8-48da-a279-35e37479b59a-1","kind":"read","title":"Fixture value","locations":[{"path":"Fixture value"}],"rawInput":{"variant":"ReadFile","target_file":"Fixture value"},"_meta":{"x.ai/tool":{"version":1,"name":"read_file","kind":"read","namespace":"grok_build","label":"Read","read_only":true,"input":{"path":"Fixture value"}}}},"_meta":{"totalTokens":34429,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-26","agentTimestampMs":1791057317008,"promptId":"prompt:p1:3","streamStartMs":1791057314618,"turnStartMs":1791057313774,"updateType":"ToolCallUpdate","updateParams":{"toolCallId":"call-d473a4c2-cca8-48da-a279-35e37479b59a-1","status":null}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"interaction_resolved","tool_call_id":"call-d473a4c2-cca8-48da-a279-35e37479b59a-1"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"hook_run_started","event_name":"post_tool_use","tool_name":"read_file","count":2},"_meta":{"agentTimestampMs":1791057317012}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"hook_execution","event_name":"post_tool_use","tool_name":"read_file","runs":[{"name":"global/orca-status:post_tool_use[0].hooks[0]","status":{"status":"success","elapsed_ms":8}},{"name":"global/settings:post_tool_use[0].hooks[0]","status":{"status":"success","elapsed_ms":11}}]},"_meta":{"eventId":"01a10355-beef-7083-805c-806758f9a7d2-28","agentTimestampMs":1791057317032}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"hook_run_started","event_name":"post_tool_use","tool_name":"list_dir","count":2},"_meta":{"agentTimestampMs":1791057317035}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-d473a4c2-cca8-48da-a279-35e37479b59a-1","status":"completed","content":[{"type":"content","content":{"type":"text","text":"1\u2192# Scratch project 1\n\nA tiny throwaway project used to record Grok ACP traffic.\n"}}],"rawOutput":{"type":"ReadFile","FileContent":{"content":"1\u2192# Scratch project 1\n\nA tiny throwaway project used to record Grok ACP traffic.\n","content_concise":"1\u2192# Scratch project 1\n\nA tiny throwaway project used to record Grok ACP traffic.\n","absolute_path":"Fixture value","offset":null,"raw_output":"# Scratch project 1\n\nA tiny throwaway project used to record Grok ACP traffic.\n","total_lines":4}}},"_meta":{"totalTokens":34429,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-27","agentTimestampMs":1791057317032,"promptId":"prompt:p1:3","streamStartMs":1791057314618,"turnStartMs":1791057313774,"updateType":"ToolCallUpdate","updateParams":{"toolCallId":"call-d473a4c2-cca8-48da-a279-35e37479b59a-1","status":"Completed"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"hook_execution","event_name":"post_tool_use","tool_name":"list_dir","runs":[{"name":"global/orca-status:post_tool_use[0].hooks[0]","status":{"status":"success","elapsed_ms":8}},{"name":"global/settings:post_tool_use[0].hooks[0]","status":{"status":"success","elapsed_ms":19}}]},"_meta":{"eventId":"01a10355-beef-7083-805c-806758f9a7d2-30","agentTimestampMs":1791057317063}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-d473a4c2-cca8-48da-a279-35e37479b59a-0","status":"completed","rawOutput":{"type":"ListDir","Content":{"content":"Fixture value","absolute_root_path":"Fixture value"}}},"_meta":{"totalTokens":34449,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-29","agentTimestampMs":1791057317063,"promptId":"prompt:p1:3","streamStartMs":1791057314618,"turnStartMs":1791057313774,"updateType":"ToolCallUpdate","updateParams":{"toolCallId":"call-d473a4c2-cca8-48da-a279-35e37479b59a-0","status":"Completed"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","id":"Fixture value","result":{"result":{"reloaded":1}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"available_commands_update","availableCommands":[],"_meta":{"tools":["run_terminal_command","read_file","search_replace","list_dir","grep","kill_command_or_subagent","todo_write","get_command_or_subagent_output","spawn_subagent","scheduler_create","scheduler_delete","scheduler_list","monitor","search_tool","use_tool","workflow","enter_plan_mode","exit_plan_mode","ask_user_question","send_feedback","web_search","web_fetch","image_gen","image_edit","image_to_video","reference_to_video","write"]}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-33","agentTimestampMs":1791057317622,"promptId":"prompt:p1:3","streamStartMs":1791057314618,"turnStartMs":1791057313774,"updateType":"AvailableCommandsUpdate","updateParams":{"commandsCount":59}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"session_summary_generated","session_summary":"List files and read README.md"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"session_info_update","title":"List files and read README.md"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"This"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-34","agentTimestampMs":1791057318881,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":1}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" folder"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-35","agentTimestampMs":1791057318881,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":2}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" has"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-36","agentTimestampMs":1791057318882,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":3}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" three"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-37","agentTimestampMs":1791057318882,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":4}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" files"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-38","agentTimestampMs":1791057318882,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":5}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":":\n\n"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-39","agentTimestampMs":1791057318882,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":6}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"-"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-40","agentTimestampMs":1791057318882,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":7}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" `"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-41","agentTimestampMs":1791057318882,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":8}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"math"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-42","agentTimestampMs":1791057318888,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":9}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":".js"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-43","agentTimestampMs":1791057318888,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":10}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"`\n"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-44","agentTimestampMs":1791057318888,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":11}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"-"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-45","agentTimestampMs":1791057318888,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":12}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" `"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-46","agentTimestampMs":1791057318888,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":13}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"notes"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-47","agentTimestampMs":1791057318888,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":14}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":".txt"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-48","agentTimestampMs":1791057318888,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":15}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"`\n"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-49","agentTimestampMs":1791057318888,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":16}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"-"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-50","agentTimestampMs":1791057318888,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":17}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" `"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-51","agentTimestampMs":1791057318888,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":18}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"README"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-52","agentTimestampMs":1791057318888,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":19}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":".md"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-53","agentTimestampMs":1791057318888,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":20}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"`\n\n"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-54","agentTimestampMs":1791057318888,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":21}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"`"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-55","agentTimestampMs":1791057318888,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":22}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"README"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-56","agentTimestampMs":1791057318888,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":23}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":".md"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-57","agentTimestampMs":1791057318888,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":24}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"`"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-58","agentTimestampMs":1791057318888,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":25}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" says"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-59","agentTimestampMs":1791057318888,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":26}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":":\n\n"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-60","agentTimestampMs":1791057318888,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":27}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"```"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-61","agentTimestampMs":1791057318888,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":28}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"markdown"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-62","agentTimestampMs":1791057318888,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":29}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"\n"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-63","agentTimestampMs":1791057318889,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":30}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"#"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-64","agentTimestampMs":1791057318889,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":31}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" Scratch"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-65","agentTimestampMs":1791057318889,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":32}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" project"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-66","agentTimestampMs":1791057318889,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":33}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" "}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-67","agentTimestampMs":1791057318889,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":34}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"1"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-68","agentTimestampMs":1791057318889,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":35}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"\n\n"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-69","agentTimestampMs":1791057318889,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":36}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"A"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-70","agentTimestampMs":1791057318889,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":37}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" tiny"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-71","agentTimestampMs":1791057318889,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":38}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" throw"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-72","agentTimestampMs":1791057318889,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":39}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"away"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-73","agentTimestampMs":1791057318889,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":40}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" project"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-74","agentTimestampMs":1791057318889,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":41}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" used"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-75","agentTimestampMs":1791057318889,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":42}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" to"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-76","agentTimestampMs":1791057318889,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":43}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" record"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-77","agentTimestampMs":1791057318889,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":44}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" G"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-78","agentTimestampMs":1791057318889,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":45}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"rok"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-79","agentTimestampMs":1791057318890,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":46}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" ACP"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-80","agentTimestampMs":1791057318890,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":47}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" traffic"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-81","agentTimestampMs":1791057318890,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":48}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":".\n"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-82","agentTimestampMs":1791057318890,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":49}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"```"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-83","agentTimestampMs":1791057318890,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":50}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"available_commands_update","availableCommands":[],"_meta":{"tools":["run_terminal_command","read_file","search_replace","list_dir","grep","kill_command_or_subagent","todo_write","get_command_or_subagent_output","spawn_subagent","scheduler_create","scheduler_delete","scheduler_list","monitor","search_tool","use_tool","workflow","enter_plan_mode","exit_plan_mode","ask_user_question","send_feedback","web_search","web_fetch","image_gen","image_edit","image_to_video","reference_to_video","write"]}},"_meta":{"totalTokens":34705,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-84","agentTimestampMs":1791057318943,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AvailableCommandsUpdate","updateParams":{"commandsCount":59}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"response_completed","usage":{"input_tokens":446,"output_tokens":74,"cache_read_input_tokens":34176,"cache_creation_input_tokens":0,"reasoning_tokens":24}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"hook_run_started","event_name":"stop","prompt_id":"prompt:p1:3","count":2},"_meta":{"agentTimestampMs":1791057318944}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"hook_execution","event_name":"stop","prompt_id":"prompt:p1:3","runs":[{"name":"global/orca-status:stop[0].hooks[0]","status":{"status":"success","elapsed_ms":8}},{"name":"global/settings:stop[0].hooks[0]","status":{"status":"success","elapsed_ms":11}}]},"_meta":{"eventId":"01a10355-beef-7083-805c-806758f9a7d2-87","agentTimestampMs":1791057318964}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/queue/changed","params":{"sessionId":"session-1","entries":[]}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"turn_completed","prompt_id":"prompt:p1:3","stop_reason":"end_turn","usage":{"inputTokens":68922,"outputTokens":195,"totalTokens":69117,"cachedReadTokens":35840,"cacheCreationTokens":0,"reasoningTokens":52,"modelCalls":2,"apiDurationMs":5045,"costUsdTicks":289863600,"modelUsage":{"grok-4.7-build":{"inputTokens":68922,"outputTokens":195,"totalTokens":69117,"cachedReadTokens":35840,"cacheCreationTokens":0,"reasoningTokens":52,"modelCalls":2,"apiDurationMs":5045,"costUsdTicks":289863600}},"numTurns":2},"elapsed_ms":5289},"_meta":{"eventId":"01a10355-beef-7083-805c-806758f9a7d2-88","agentTimestampMs":1791057319003}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session/prompt_complete","params":{"sessionId":"session-1","promptId":"prompt:p1:3","stopReason":"end_turn","agentResult":null}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/sessions/changed","params":{"upserted":[{"sessionId":"session-1","title":null,"cwd":"Fixture value","isWorktree":false,"modelId":"grok-4.7","reasoningEffort":"high","yolo":true,"activity":"idle","resident":true,"lastChangeUnixMs":1791057319035,"origin":{"kind":"local"}}],"removed":[]}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn","_meta":{"sessionId":"session-1","requestId":"prompt:p1:3","promptId":"prompt:p1:3","totalTokens":34705,"modelId":"grok-4.7","inputTokens":34622,"outputTokens":74,"cachedReadTokens":34176,"reasoningTokens":24,"usage":{"inputTokens":68922,"outputTokens":195,"totalTokens":69117,"cachedReadTokens":35840,"cacheCreationTokens":0,"reasoningTokens":52,"modelCalls":2,"apiDurationMs":5045,"costUsdTicks":289863600,"modelUsage":{"grok-4.7-build":{"inputTokens":68922,"outputTokens":195,"totalTokens":69117,"cachedReadTokens":35840,"cacheCreationTokens":0,"reasoningTokens":52,"modelCalls":2,"apiDurationMs":5045,"costUsdTicks":289863600}},"numTurns":2}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"last_turn_summary","summary":"Three files; README is a tiny Grok ACP scratch project","prompt_id":"prompt:p1:3"},"_meta":{"agentTimestampMs":1791057320779}}}} diff --git a/src/main/acp/fixtures/s2-permission.jsonl b/src/main/acp/fixtures/s2-permission.jsonl index fe5198b52458..66bb39db3367 100644 --- a/src/main/acp/fixtures/s2-permission.jsonl +++ b/src/main/acp/fixtures/s2-permission.jsonl @@ -1,20 +1,20 @@ -{"direction":"out","process":"p1","message":{"jsonrpc":"2.0","id":3,"method":"session/prompt","params":{"sessionId":"session-1","prompt":[{"type":"text","text":"Fixture prompt"}]}}} -{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"I'll"}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} -{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" create"}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} -{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call","toolCallId":"call-1","title":"write","rawInput":{"file_path":"/workspace/file-1","content":"hello from grok\n"},"_meta":{"x.ai/tool":{"name":"write","kind":"write"}}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} -{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-1","kind":"edit","title":"Write `/workspace/file-1`","content":[{"type":"diff","path":"/workspace/file-1","oldText":"","newText":"hello from grok\n"}],"locations":[{"path":"/workspace/file-1"}],"rawInput":{"variant":"Write","file_path":"/workspace/file-1","content":"hello from grok\n"},"_meta":{"x.ai/tool":{"name":"write","kind":"write"}}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} +{"direction":"out","process":"p1","message":{"jsonrpc":"2.0","id":3,"method":"session/prompt","params":{"sessionId":"session-1","prompt":[{"type":"text","text":"Fixture prompt"}],"_meta":{"promptId":"prompt:p1:3"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"I'll"}},"_meta":{"promptId":"prompt:p1:3","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" create"}},"_meta":{"promptId":"prompt:p1:3","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call","toolCallId":"call-1","title":"write","rawInput":{"file_path":"/workspace/file-1","content":"hello from grok\n"},"_meta":{"x.ai/tool":{"name":"write","kind":"write"}}},"_meta":{"promptId":"prompt:p1:3","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-1","kind":"edit","title":"Write `/workspace/file-1`","content":[{"type":"diff","path":"/workspace/file-1","oldText":"","newText":"hello from grok\n"}],"locations":[{"path":"/workspace/file-1"}],"rawInput":{"variant":"Write","file_path":"/workspace/file-1","content":"hello from grok\n"},"_meta":{"x.ai/tool":{"name":"write","kind":"write"}}},"_meta":{"promptId":"prompt:p1:3","turnStartMs":1000}}}} {"direction":"in","process":"p1","message":{"jsonrpc":"2.0","id":0,"method":"session/request_permission","params":{"sessionId":"session-1","toolCall":{"toolCallId":"call-1","kind":"edit","title":"Write `/workspace/file-1` \u2014 hook 'plugin/capture-force-ask/hooks:pre_tool_use[0].hooks[0]' asks: capture harness forces a permission prompt","rawInput":{"variant":"Write","file_path":"/workspace/file-1","content":"hello from grok\n"},"_meta":{"x.ai/tool":{"name":"write","kind":"write"}}},"options":[{"optionId":"allow-edits-session","name":"Yes, allow all edits during this session","kind":"allow_always"},{"optionId":"allow-once","name":"Yes","kind":"allow_once"},{"optionId":"reject-once","name":"No, and tell Grok what to do differently","kind":"reject_once"}]}}} {"direction":"out","process":"p1","message":{"jsonrpc":"2.0","id":0,"result":{"outcome":{"outcome":"selected","optionId":"allow-once"}}}} -{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-1","status":"completed","content":[{"type":"diff","path":"/workspace/file-1","oldText":"","newText":"hello from grok\n"}],"rawOutput":{"type":"SearchReplace","EditsApplied":{"old_string":"","new_string":"hello from grok\n","tool_output_for_prompt":"The file /workspace/file-1 has been created.","tool_output_for_prompt_concise":"The file /workspace/file-1 has been created.","absolute_path":"/workspace/file-1","edits":{"details":[{"old_string":"","old_line":1,"new_string":"hello from grok\n","new_line":1,"context_before":"","context_after":"","line_prefix":""}]}}}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} -{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"turn_completed","prompt_id":"turn-1","stop_reason":"end_turn","usage":{"inputTokens":69120,"outputTokens":260,"cachedReadTokens":36096,"cacheCreationTokens":0},"elapsed_ms":8673}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-1","status":"completed","content":[{"type":"diff","path":"/workspace/file-1","oldText":"","newText":"hello from grok\n"}],"rawOutput":{"type":"SearchReplace","EditsApplied":{"old_string":"","new_string":"hello from grok\n","tool_output_for_prompt":"The file /workspace/file-1 has been created.","tool_output_for_prompt_concise":"The file /workspace/file-1 has been created.","absolute_path":"/workspace/file-1","edits":{"details":[{"old_string":"","old_line":1,"new_string":"hello from grok\n","new_line":1,"context_before":"","context_after":"","line_prefix":""}]}}}},"_meta":{"promptId":"prompt:p1:3","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"turn_completed","prompt_id":"prompt:p1:3","stop_reason":"end_turn","usage":{"inputTokens":69120,"outputTokens":260,"cachedReadTokens":36096,"cacheCreationTokens":0},"elapsed_ms":8673}}}} {"direction":"in","process":"p1","message":{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn","_meta":{"usage":{"inputTokens":69120,"outputTokens":260,"cachedReadTokens":36096,"cacheCreationTokens":0}}}}} -{"direction":"out","process":"p1","message":{"jsonrpc":"2.0","id":5,"method":"session/prompt","params":{"sessionId":"session-2","prompt":[{"type":"text","text":"Fixture prompt"}]}}} -{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-2","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"I'll"}},"_meta":{"promptId":"turn-2","turnStartMs":1000}}}} -{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-2","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" create"}},"_meta":{"promptId":"turn-2","turnStartMs":1000}}}} -{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-2","update":{"sessionUpdate":"tool_call","toolCallId":"call-2","title":"write","rawInput":{"file_path":"/workspace/file-2","content":"hello from grok\n"},"_meta":{"x.ai/tool":{"name":"write","kind":"write"}}},"_meta":{"promptId":"turn-2","turnStartMs":1000}}}} -{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-2","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-2","kind":"edit","title":"Write `/workspace/file-2`","content":[{"type":"diff","path":"/workspace/file-2","oldText":"","newText":"hello from grok\n"}],"locations":[{"path":"/workspace/file-2"}],"rawInput":{"variant":"Write","file_path":"/workspace/file-2","content":"hello from grok\n"},"_meta":{"x.ai/tool":{"name":"write","kind":"write"}}},"_meta":{"promptId":"turn-2","turnStartMs":1000}}}} +{"direction":"out","process":"p1","message":{"jsonrpc":"2.0","id":5,"method":"session/prompt","params":{"sessionId":"session-2","prompt":[{"type":"text","text":"Fixture prompt"}],"_meta":{"promptId":"prompt:p1:5"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-2","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"I'll"}},"_meta":{"promptId":"prompt:p1:5","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-2","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" create"}},"_meta":{"promptId":"prompt:p1:5","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-2","update":{"sessionUpdate":"tool_call","toolCallId":"call-2","title":"write","rawInput":{"file_path":"/workspace/file-2","content":"hello from grok\n"},"_meta":{"x.ai/tool":{"name":"write","kind":"write"}}},"_meta":{"promptId":"prompt:p1:5","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-2","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-2","kind":"edit","title":"Write `/workspace/file-2`","content":[{"type":"diff","path":"/workspace/file-2","oldText":"","newText":"hello from grok\n"}],"locations":[{"path":"/workspace/file-2"}],"rawInput":{"variant":"Write","file_path":"/workspace/file-2","content":"hello from grok\n"},"_meta":{"x.ai/tool":{"name":"write","kind":"write"}}},"_meta":{"promptId":"prompt:p1:5","turnStartMs":1000}}}} {"direction":"in","process":"p1","message":{"jsonrpc":"2.0","id":1,"method":"session/request_permission","params":{"sessionId":"session-2","toolCall":{"toolCallId":"call-2","kind":"edit","title":"Write `/workspace/file-2` \u2014 hook 'plugin/capture-force-ask/hooks:pre_tool_use[0].hooks[0]' asks: capture harness forces a permission prompt","rawInput":{"variant":"Write","file_path":"/workspace/file-2","content":"hello from grok\n"},"_meta":{"x.ai/tool":{"name":"write","kind":"write"}}},"options":[{"optionId":"allow-edits-session","name":"Yes, allow all edits during this session","kind":"allow_always"},{"optionId":"allow-once","name":"Yes","kind":"allow_once"},{"optionId":"reject-once","name":"No, and tell Grok what to do differently","kind":"reject_once"}]}}} {"direction":"out","process":"p1","message":{"jsonrpc":"2.0","id":1,"result":{"outcome":{"outcome":"selected","optionId":"reject-once"}}}} -{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-2","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-2","status":"failed","content":[{"type":"content","content":{"type":"text","text":"User rejected the execution for tool `write`"}}]},"_meta":{"promptId":"turn-2","turnStartMs":1000}}}} -{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-2","update":{"sessionUpdate":"turn_completed","prompt_id":"turn-2","stop_reason":"cancelled","usage":{"inputTokens":34448,"outputTokens":163,"cachedReadTokens":1664,"cacheCreationTokens":0},"elapsed_ms":4475}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-2","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-2","status":"failed","content":[{"type":"content","content":{"type":"text","text":"User rejected the execution for tool `write`"}}]},"_meta":{"promptId":"prompt:p1:5","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-2","update":{"sessionUpdate":"turn_completed","prompt_id":"prompt:p1:5","stop_reason":"cancelled","usage":{"inputTokens":34448,"outputTokens":163,"cachedReadTokens":1664,"cacheCreationTokens":0},"elapsed_ms":4475}}}} {"direction":"in","process":"p1","message":{"jsonrpc":"2.0","id":5,"result":{"stopReason":"cancelled","_meta":{"usage":{"inputTokens":34448,"outputTokens":163,"cachedReadTokens":1664,"cacheCreationTokens":0}}}}} diff --git a/src/main/acp/fixtures/s3-cancel.jsonl b/src/main/acp/fixtures/s3-cancel.jsonl index a8828e5ef760..72c56ee2edf3 100644 --- a/src/main/acp/fixtures/s3-cancel.jsonl +++ b/src/main/acp/fixtures/s3-cancel.jsonl @@ -1,13 +1,13 @@ -{"direction":"out","process":"p1","message":{"jsonrpc":"2.0","id":3,"method":"session/prompt","params":{"sessionId":"session-1","prompt":[{"type":"text","text":"Fixture prompt"}]}}} -{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"I'll"}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} -{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" run"}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} -{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call","toolCallId":"call-1","title":"run_terminal_command","rawInput":{"command":"sleep 20; echo finished > cancel-marker.txt","description":"Sleep 20s then write cancel marker","block_until_ms":35000},"_meta":{"x.ai/tool":{"name":"run_terminal_command","kind":"execute"}}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} -{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-1","kind":"execute","title":"Execute `sleep 20; echo finished > cancel-marker.txt`","content":[{"type":"content","content":{"type":"text","text":"Sleep 20s then write cancel marker"}}],"locations":[],"rawInput":{"variant":"Bash","command":"sleep 20; echo finished > cancel-marker.txt","description":"Sleep 20s then write cancel marker","is_background":false,"block_until_ms":35000},"_meta":{"x.ai/tool":{"name":"run_terminal_command","kind":"execute"}}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} +{"direction":"out","process":"p1","message":{"jsonrpc":"2.0","id":3,"method":"session/prompt","params":{"sessionId":"session-1","prompt":[{"type":"text","text":"Fixture prompt"}],"_meta":{"promptId":"prompt:p1:3"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"I'll"}},"_meta":{"promptId":"prompt:p1:3","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" run"}},"_meta":{"promptId":"prompt:p1:3","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call","toolCallId":"call-1","title":"run_terminal_command","rawInput":{"command":"sleep 20; echo finished > cancel-marker.txt","description":"Sleep 20s then write cancel marker","block_until_ms":35000},"_meta":{"x.ai/tool":{"name":"run_terminal_command","kind":"execute"}}},"_meta":{"promptId":"prompt:p1:3","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-1","kind":"execute","title":"Execute `sleep 20; echo finished > cancel-marker.txt`","content":[{"type":"content","content":{"type":"text","text":"Sleep 20s then write cancel marker"}}],"locations":[],"rawInput":{"variant":"Bash","command":"sleep 20; echo finished > cancel-marker.txt","description":"Sleep 20s then write cancel marker","is_background":false,"block_until_ms":35000},"_meta":{"x.ai/tool":{"name":"run_terminal_command","kind":"execute"}}},"_meta":{"promptId":"prompt:p1:3","turnStartMs":1000}}}} {"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-1","status":"in_progress","content":[{"type":"content","content":{"type":"text","text":""}}],"rawOutput":{"type":"Bash","output":[],"output_for_prompt":"","exit_code":0,"command":"sleep 20; echo finished > cancel-marker.txt","truncated":false,"signal":null,"timed_out":false,"description":null,"current_dir":"/workspace/file-1","output_file":"","total_bytes":0}}}}} {"direction":"out","process":"p1","message":{"jsonrpc":"2.0","method":"session/cancel","params":{"sessionId":"session-1"}}} -{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"turn_completed","prompt_id":"turn-1","stop_reason":"cancelled","usage":{"inputTokens":34344,"outputTokens":318,"cachedReadTokens":1664,"cacheCreationTokens":0},"elapsed_ms":7773}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"turn_completed","prompt_id":"prompt:p1:3","stop_reason":"cancelled","usage":{"inputTokens":34344,"outputTokens":318,"cachedReadTokens":1664,"cacheCreationTokens":0},"elapsed_ms":7773}}}} {"direction":"in","process":"p1","message":{"jsonrpc":"2.0","id":3,"result":{"stopReason":"cancelled","_meta":{"usage":{"inputTokens":34344,"outputTokens":318,"cachedReadTokens":1664,"cacheCreationTokens":0}}}}} -{"direction":"out","process":"p1","message":{"jsonrpc":"2.0","id":4,"method":"session/prompt","params":{"sessionId":"session-1","prompt":[{"type":"text","text":"Fixture prompt"}]}}} -{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"ok"}},"_meta":{"promptId":"turn-2","turnStartMs":1000}}}} -{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"turn_completed","prompt_id":"turn-2","stop_reason":"end_turn","usage":{"inputTokens":34876,"outputTokens":189,"cachedReadTokens":34304,"cacheCreationTokens":0},"elapsed_ms":3373}}}} +{"direction":"out","process":"p1","message":{"jsonrpc":"2.0","id":4,"method":"session/prompt","params":{"sessionId":"session-1","prompt":[{"type":"text","text":"Fixture prompt"}],"_meta":{"promptId":"prompt:p1:4"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"ok"}},"_meta":{"promptId":"prompt:p1:4","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"turn_completed","prompt_id":"prompt:p1:4","stop_reason":"end_turn","usage":{"inputTokens":34876,"outputTokens":189,"cachedReadTokens":34304,"cacheCreationTokens":0},"elapsed_ms":3373}}}} {"direction":"in","process":"p1","message":{"jsonrpc":"2.0","id":4,"result":{"stopReason":"end_turn","_meta":{"usage":{"inputTokens":34876,"outputTokens":189,"cachedReadTokens":34304,"cacheCreationTokens":0}}}}} diff --git a/src/main/acp/fixtures/s4-resume.jsonl b/src/main/acp/fixtures/s4-resume.jsonl index 479e13236172..93adbc92a2f9 100644 --- a/src/main/acp/fixtures/s4-resume.jsonl +++ b/src/main/acp/fixtures/s4-resume.jsonl @@ -1,23 +1,23 @@ -{"direction":"out","process":"A","message":{"jsonrpc":"2.0","id":3,"method":"session/prompt","params":{"sessionId":"session-1","prompt":[{"type":"text","text":"Fixture prompt"}]}}} -{"direction":"in","process":"A","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"I'll"}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} -{"direction":"in","process":"A","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" read"}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} -{"direction":"in","process":"A","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call","toolCallId":"call-1","title":"grep","rawInput":{"pattern":".","glob":"notes.txt"},"_meta":{"x.ai/tool":{"name":"grep","kind":"search"}}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} -{"direction":"in","process":"A","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-1","kind":"search","title":".","locations":[],"rawInput":{"variant":"Grep","pattern":".","path":null,"glob":"notes.txt","-i":false,"type":null,"multiline":false},"_meta":{"x.ai/tool":{"name":"grep","kind":"search"}}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} -{"direction":"in","process":"A","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call","toolCallId":"call-2","title":"list_dir","rawInput":{"target_directory":"/workspace/file-1"},"_meta":{"x.ai/tool":{"name":"list_dir","kind":"list"}}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} -{"direction":"in","process":"A","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-2","kind":"other","title":"List `/workspace/file-1`","locations":[{"path":"/workspace/file-1"}],"rawInput":{"variant":"ListDir","target_directory":"/workspace/file-1"},"_meta":{"x.ai/tool":{"name":"list_dir","kind":"list"}}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} -{"direction":"in","process":"A","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-2","status":"completed","rawOutput":{"type":"ListDir","Content":{"content":"- /workspace/file-2\n - math.js\n - notes.txt\n - README.md","absolute_root_path":"/workspace/file-1"}}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} -{"direction":"in","process":"A","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-1","status":"completed","content":[{"type":"content","content":{"type":"text","text":"found 3 matches"}}],"rawOutput":{"type":"GrepSearch","stdout":[60,119,111,114,107,115,112,97,99,101,95,114,101,115,117,108,116,32,119,111,114,107,115,112,97,99,101,95,112,97,116,104,61,34,47,119,111,114,107,115,112,97,99,101,47,112,114,111,106,101,99,116,34,62,10,47,119,111,114,107,115,112,97,99,101,47,112,114,111,106,101,99,116,47,110,111,116,101,115,46,116,120,116,58,101,120,97,109,112,108,101,10,60,47,119,111,114,107,115,112,97,99,101,95,114,101,115,117,108,116,62],"stderr":[],"exit_code":0,"match_count":3,"file_matches":[{"path":"/workspace/file-3","matches":[{"line_number":1,"content":"apples"},{"line_number":2,"content":"bananas"},{"line_number":3,"content":"cherries"}]}]}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} -{"direction":"in","process":"A","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"turn_completed","prompt_id":"turn-1","stop_reason":"end_turn","usage":{"inputTokens":68864,"outputTokens":121,"cachedReadTokens":35968,"cacheCreationTokens":0},"elapsed_ms":5043}}}} +{"direction":"out","process":"A","message":{"jsonrpc":"2.0","id":3,"method":"session/prompt","params":{"sessionId":"session-1","prompt":[{"type":"text","text":"Fixture prompt"}],"_meta":{"promptId":"prompt:A:3"}}}} +{"direction":"in","process":"A","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"I'll"}},"_meta":{"promptId":"prompt:A:3","turnStartMs":1000}}}} +{"direction":"in","process":"A","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" read"}},"_meta":{"promptId":"prompt:A:3","turnStartMs":1000}}}} +{"direction":"in","process":"A","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call","toolCallId":"call-1","title":"grep","rawInput":{"pattern":".","glob":"notes.txt"},"_meta":{"x.ai/tool":{"name":"grep","kind":"search"}}},"_meta":{"promptId":"prompt:A:3","turnStartMs":1000}}}} +{"direction":"in","process":"A","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-1","kind":"search","title":".","locations":[],"rawInput":{"variant":"Grep","pattern":".","path":null,"glob":"notes.txt","-i":false,"type":null,"multiline":false},"_meta":{"x.ai/tool":{"name":"grep","kind":"search"}}},"_meta":{"promptId":"prompt:A:3","turnStartMs":1000}}}} +{"direction":"in","process":"A","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call","toolCallId":"call-2","title":"list_dir","rawInput":{"target_directory":"/workspace/file-1"},"_meta":{"x.ai/tool":{"name":"list_dir","kind":"list"}}},"_meta":{"promptId":"prompt:A:3","turnStartMs":1000}}}} +{"direction":"in","process":"A","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-2","kind":"other","title":"List `/workspace/file-1`","locations":[{"path":"/workspace/file-1"}],"rawInput":{"variant":"ListDir","target_directory":"/workspace/file-1"},"_meta":{"x.ai/tool":{"name":"list_dir","kind":"list"}}},"_meta":{"promptId":"prompt:A:3","turnStartMs":1000}}}} +{"direction":"in","process":"A","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-2","status":"completed","rawOutput":{"type":"ListDir","Content":{"content":"- /workspace/file-2\n - math.js\n - notes.txt\n - README.md","absolute_root_path":"/workspace/file-1"}}},"_meta":{"promptId":"prompt:A:3","turnStartMs":1000}}}} +{"direction":"in","process":"A","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-1","status":"completed","content":[{"type":"content","content":{"type":"text","text":"found 3 matches"}}],"rawOutput":{"type":"GrepSearch","stdout":[60,119,111,114,107,115,112,97,99,101,95,114,101,115,117,108,116,32,119,111,114,107,115,112,97,99,101,95,112,97,116,104,61,34,47,119,111,114,107,115,112,97,99,101,47,112,114,111,106,101,99,116,34,62,10,47,119,111,114,107,115,112,97,99,101,47,112,114,111,106,101,99,116,47,110,111,116,101,115,46,116,120,116,58,101,120,97,109,112,108,101,10,60,47,119,111,114,107,115,112,97,99,101,95,114,101,115,117,108,116,62],"stderr":[],"exit_code":0,"match_count":3,"file_matches":[{"path":"/workspace/file-3","matches":[{"line_number":1,"content":"apples"},{"line_number":2,"content":"bananas"},{"line_number":3,"content":"cherries"}]}]}},"_meta":{"promptId":"prompt:A:3","turnStartMs":1000}}}} +{"direction":"in","process":"A","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"turn_completed","prompt_id":"prompt:A:3","stop_reason":"end_turn","usage":{"inputTokens":68864,"outputTokens":121,"cachedReadTokens":35968,"cacheCreationTokens":0},"elapsed_ms":5043}}}} {"direction":"in","process":"A","message":{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn","_meta":{"usage":{"inputTokens":68864,"outputTokens":121,"cachedReadTokens":35968,"cacheCreationTokens":0}}}}} {"direction":"out","process":"B","message":{"jsonrpc":"2.0","id":2,"method":"session/load","params":{"sessionId":"session-1"}}} {"direction":"in","process":"B","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"user_message_chunk","content":{"type":"text","text":"Read notes.txt and tell me its second line."},"_meta":{"promptIndex":0}},"_meta":{"isReplay":true}}}} -{"direction":"in","process":"B","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"I'll read `notes.txt` and report the second line."}},"_meta":{"promptId":"turn-1","turnStartMs":1000,"isReplay":true}}}} -{"direction":"in","process":"B","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call","toolCallId":"call-2","title":"List `/workspace/file-1`","status":"completed","locations":[{"path":"/workspace/file-1"}],"rawInput":{"variant":"ListDir","target_directory":"/workspace/file-1"},"rawOutput":{"type":"ListDir","Content":{"content":"- /workspace/file-2\n - math.js\n - notes.txt\n - README.md","absolute_root_path":"/workspace/file-1"}},"_meta":{"x.ai/tool":{"name":"list_dir","kind":"list"}}},"_meta":{"promptId":"turn-1","turnStartMs":1000,"isReplay":true}}}} -{"direction":"in","process":"B","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call","toolCallId":"call-1","title":".","kind":"search","status":"completed","content":[{"type":"content","content":{"type":"text","text":"found 3 matches"}}],"rawInput":{"variant":"Grep","pattern":".","path":null,"glob":"notes.txt","-i":false,"type":null,"multiline":false},"rawOutput":{"type":"GrepSearch","stdout":[60,119,111,114,107,115,112,97,99,101,95,114,101,115,117,108,116,32,119,111,114,107,115,112,97,99,101,95,112,97,116,104,61,34,47,119,111,114,107,115,112,97,99,101,47,112,114,111,106,101,99,116,34,62,10,47,119,111,114,107,115,112,97,99,101,47,112,114,111,106,101,99,116,47,110,111,116,101,115,46,116,120,116,58,101,120,97,109,112,108,101,10,60,47,119,111,114,107,115,112,97,99,101,95,114,101,115,117,108,116,62],"stderr":[],"exit_code":0,"match_count":3,"file_matches":[{"path":"/workspace/file-3","matches":[{"line_number":1,"content":"apples"},{"line_number":2,"content":"bananas"},{"line_number":3,"content":"cherries"}]}]},"_meta":{"x.ai/tool":{"name":"grep","kind":"search"}}},"_meta":{"promptId":"turn-1","turnStartMs":1000,"isReplay":true}}}} -{"direction":"in","process":"B","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"The second line of `notes.txt` is `bananas`."}},"_meta":{"promptId":"turn-1","turnStartMs":1000,"isReplay":true}}}} -{"direction":"in","process":"B","message":{"jsonrpc":"2.0","method":"_x.ai/session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"turn_completed","prompt_id":"turn-1","stop_reason":"end_turn","usage":{"inputTokens":68864,"outputTokens":121,"cachedReadTokens":35968,"cacheCreationTokens":0},"elapsed_ms":5043},"_meta":{"isReplay":true}}}} -{"direction":"out","process":"B","message":{"jsonrpc":"2.0","id":3,"method":"session/prompt","params":{"sessionId":"session-1","prompt":[{"type":"text","text":"Fixture prompt"}]}}} -{"direction":"in","process":"B","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"The"}},"_meta":{"promptId":"turn-2","turnStartMs":1000}}}} -{"direction":"in","process":"B","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" second"}},"_meta":{"promptId":"turn-2","turnStartMs":1000}}}} -{"direction":"in","process":"B","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"turn_completed","prompt_id":"turn-2","stop_reason":"end_turn","usage":{"inputTokens":34621,"outputTokens":36,"cachedReadTokens":34432,"cacheCreationTokens":0},"elapsed_ms":1829}}}} +{"direction":"in","process":"B","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"I'll read `notes.txt` and report the second line."}},"_meta":{"promptId":"prompt:A:3","turnStartMs":1000,"isReplay":true}}}} +{"direction":"in","process":"B","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call","toolCallId":"call-2","title":"List `/workspace/file-1`","status":"completed","locations":[{"path":"/workspace/file-1"}],"rawInput":{"variant":"ListDir","target_directory":"/workspace/file-1"},"rawOutput":{"type":"ListDir","Content":{"content":"- /workspace/file-2\n - math.js\n - notes.txt\n - README.md","absolute_root_path":"/workspace/file-1"}},"_meta":{"x.ai/tool":{"name":"list_dir","kind":"list"}}},"_meta":{"promptId":"prompt:A:3","turnStartMs":1000,"isReplay":true}}}} +{"direction":"in","process":"B","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call","toolCallId":"call-1","title":".","kind":"search","status":"completed","content":[{"type":"content","content":{"type":"text","text":"found 3 matches"}}],"rawInput":{"variant":"Grep","pattern":".","path":null,"glob":"notes.txt","-i":false,"type":null,"multiline":false},"rawOutput":{"type":"GrepSearch","stdout":[60,119,111,114,107,115,112,97,99,101,95,114,101,115,117,108,116,32,119,111,114,107,115,112,97,99,101,95,112,97,116,104,61,34,47,119,111,114,107,115,112,97,99,101,47,112,114,111,106,101,99,116,34,62,10,47,119,111,114,107,115,112,97,99,101,47,112,114,111,106,101,99,116,47,110,111,116,101,115,46,116,120,116,58,101,120,97,109,112,108,101,10,60,47,119,111,114,107,115,112,97,99,101,95,114,101,115,117,108,116,62],"stderr":[],"exit_code":0,"match_count":3,"file_matches":[{"path":"/workspace/file-3","matches":[{"line_number":1,"content":"apples"},{"line_number":2,"content":"bananas"},{"line_number":3,"content":"cherries"}]}]},"_meta":{"x.ai/tool":{"name":"grep","kind":"search"}}},"_meta":{"promptId":"prompt:A:3","turnStartMs":1000,"isReplay":true}}}} +{"direction":"in","process":"B","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"The second line of `notes.txt` is `bananas`."}},"_meta":{"promptId":"prompt:A:3","turnStartMs":1000,"isReplay":true}}}} +{"direction":"in","process":"B","message":{"jsonrpc":"2.0","method":"_x.ai/session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"turn_completed","prompt_id":"prompt:A:3","stop_reason":"end_turn","usage":{"inputTokens":68864,"outputTokens":121,"cachedReadTokens":35968,"cacheCreationTokens":0},"elapsed_ms":5043},"_meta":{"isReplay":true}}}} +{"direction":"out","process":"B","message":{"jsonrpc":"2.0","id":3,"method":"session/prompt","params":{"sessionId":"session-1","prompt":[{"type":"text","text":"Fixture prompt"}],"_meta":{"promptId":"prompt:B:3"}}}} +{"direction":"in","process":"B","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"The"}},"_meta":{"promptId":"prompt:B:3","turnStartMs":1000}}}} +{"direction":"in","process":"B","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" second"}},"_meta":{"promptId":"prompt:B:3","turnStartMs":1000}}}} +{"direction":"in","process":"B","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"turn_completed","prompt_id":"prompt:B:3","stop_reason":"end_turn","usage":{"inputTokens":34621,"outputTokens":36,"cachedReadTokens":34432,"cacheCreationTokens":0},"elapsed_ms":1829}}}} {"direction":"in","process":"B","message":{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn","_meta":{"usage":{"inputTokens":34621,"outputTokens":36,"cachedReadTokens":34432,"cacheCreationTokens":0}}}}} diff --git a/src/main/acp/fixtures/s5-plan-approved.jsonl b/src/main/acp/fixtures/s5-plan-approved.jsonl index 43b2e30a194e..29e87decdb06 100644 --- a/src/main/acp/fixtures/s5-plan-approved.jsonl +++ b/src/main/acp/fixtures/s5-plan-approved.jsonl @@ -1,15 +1,16 @@ -{"direction":"out","process":"p1","message":{"jsonrpc":"2.0","id":4,"method":"session/prompt","params":{"sessionId":"session-1","prompt":[{"type":"text","text":"Fixture prompt"}]}}} -{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"I'll"}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} -{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" look"}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} -{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call","toolCallId":"call-1","title":"ask_user_question","rawInput":{"questions":[{"question":"Which test approach do you want for the subtract function?","options":[{"label":"node:test (Recommended)","description":"A math.test.js file using Node's built-in test runner. Run it with node --test. No new dependencies or package.json."},{"label":"Plain assert script","description":"A small math.test.js that uses node:assert and exits non-zero on failure. Run it with node math.test.js. No test framework."},{"label":"Add a package.json and a test script","description":"Add package.json with a test script that runs the same Node built-in tests, so npm test works."}]}]},"_meta":{"x.ai/tool":{"name":"ask_user_question","kind":"ask_user"}}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} -{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-1","kind":"other","title":"Ask: Which test approach do you want for the subtract function?","locations":[],"rawInput":{"variant":"AskUserQuestion","questions":[{"question":"Which test approach do you want for the subtract function?","options":[{"label":"node:test (Recommended)","description":"A math.test.js file using Node's built-in test runner. Run it with node --test. No new dependencies or package.json."},{"label":"Plain assert script","description":"A small math.test.js that uses node:assert and exits non-zero on failure. Run it with node math.test.js. No test framework."},{"label":"Add a package.json and a test script","description":"Add package.json with a test script that runs the same Node built-in tests, so npm test works."}],"multiSelect":null}]},"_meta":{"x.ai/tool":{"name":"ask_user_question","kind":"ask_user"}}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} +{"direction":"out","process":"p1","message":{"jsonrpc":"2.0","id":4,"method":"session/prompt","params":{"sessionId":"session-1","prompt":[{"type":"text","text":"Fixture prompt"}],"_meta":{"promptId":"prompt:p1:4"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"I'll"}},"_meta":{"promptId":"prompt:p1:4","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" look"}},"_meta":{"promptId":"prompt:p1:4","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call","toolCallId":"call-1","title":"ask_user_question","rawInput":{"questions":[{"question":"Which test approach do you want for the subtract function?","options":[{"label":"node:test (Recommended)","description":"A math.test.js file using Node's built-in test runner. Run it with node --test. No new dependencies or package.json."},{"label":"Plain assert script","description":"A small math.test.js that uses node:assert and exits non-zero on failure. Run it with node math.test.js. No test framework."},{"label":"Add a package.json and a test script","description":"Add package.json with a test script that runs the same Node built-in tests, so npm test works."}]}]},"_meta":{"x.ai/tool":{"name":"ask_user_question","kind":"ask_user"}}},"_meta":{"promptId":"prompt:p1:4","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-1","kind":"other","title":"Ask: Which test approach do you want for the subtract function?","locations":[],"rawInput":{"variant":"AskUserQuestion","questions":[{"question":"Which test approach do you want for the subtract function?","options":[{"label":"node:test (Recommended)","description":"A math.test.js file using Node's built-in test runner. Run it with node --test. No new dependencies or package.json."},{"label":"Plain assert script","description":"A small math.test.js that uses node:assert and exits non-zero on failure. Run it with node math.test.js. No test framework."},{"label":"Add a package.json and a test script","description":"Add package.json with a test script that runs the same Node built-in tests, so npm test works."}],"multiSelect":null}]},"_meta":{"x.ai/tool":{"name":"ask_user_question","kind":"ask_user"}}},"_meta":{"promptId":"prompt:p1:4","turnStartMs":1000}}}} {"direction":"in","process":"p1","message":{"jsonrpc":"2.0","id":0,"method":"_x.ai/ask_user_question","params":{"sessionId":"session-1","toolCallId":"call-1","questions":[{"question":"Which test approach do you want for the subtract function?","options":[{"label":"node:test (Recommended)","description":"A math.test.js file using Node's built-in test runner. Run it with node --test. No new dependencies or package.json."},{"label":"Plain assert script","description":"A small math.test.js that uses node:assert and exits non-zero on failure. Run it with node math.test.js. No test framework."},{"label":"Add a package.json and a test script","description":"Add package.json with a test script that runs the same Node built-in tests, so npm test works."}],"multiSelect":null}],"mode":"default"}}} {"direction":"out","process":"p1","message":{"jsonrpc":"2.0","id":0,"result":{"outcome":"accepted","answers":{"Which test approach do you want for the subtract function?":"node:test (Recommended)"}}}} -{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-1","status":"completed","content":[{"type":"content","content":{"type":"text","text":"User has answered your questions: \"Which test approach do you want for the subtract function?\"=\"node:test (Recommended)\". You can now continue with the user's answers in mind."}}],"rawOutput":{"type":"AskUserQuestion","UserAnswered":{"message":"User has answered your questions: \"Which test approach do you want for the subtract function?\"=\"node:test (Recommended)\". You can now continue with the user's answers in mind."}}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} -{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call","toolCallId":"call-2","title":"exit_plan_mode","rawInput":{},"_meta":{"x.ai/tool":{"name":"exit_plan_mode","kind":"exit_plan"}}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} -{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-2","kind":"other","title":"Plan: Exit","locations":[],"rawInput":{"variant":"ExitPlanMode"},"_meta":{"x.ai/tool":{"name":"exit_plan_mode","kind":"exit_plan"}}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-1","status":"completed","content":[{"type":"content","content":{"type":"text","text":"User has answered your questions: \"Which test approach do you want for the subtract function?\"=\"node:test (Recommended)\". You can now continue with the user's answers in mind."}}],"rawOutput":{"type":"AskUserQuestion","UserAnswered":{"message":"User has answered your questions: \"Which test approach do you want for the subtract function?\"=\"node:test (Recommended)\". You can now continue with the user's answers in mind."}}},"_meta":{"promptId":"prompt:p1:4","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call","toolCallId":"call-2","title":"exit_plan_mode","rawInput":{},"_meta":{"x.ai/tool":{"name":"exit_plan_mode","kind":"exit_plan"}}},"_meta":{"promptId":"prompt:p1:4","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-2","kind":"other","title":"Plan: Exit","locations":[],"rawInput":{"variant":"ExitPlanMode"},"_meta":{"x.ai/tool":{"name":"exit_plan_mode","kind":"exit_plan"}}},"_meta":{"promptId":"prompt:p1:4","turnStartMs":1000}}}} {"direction":"in","process":"p1","message":{"jsonrpc":"2.0","id":1,"method":"_x.ai/exit_plan_mode","params":{"sessionId":"session-1","toolCallId":"call-2","planContent":"# Plan\n\nUpdate the example and run its tests."}}} {"direction":"out","process":"p1","message":{"jsonrpc":"2.0","id":1,"result":{"outcome":"approved"}}} -{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-2","status":"completed","title":"Plan mode exited","content":[{"type":"content","content":{"type":"text","text":"Plan file: /workspace/file-1"}}],"rawOutput":{"type":"ExitPlanMode","PlanReady":{"message":"Your plan has been approved. You can now start coding.","plan_content":"# Add subtract and a node:test\n\n## Problem\n\n`math.js` only exports `add(a, b)`. There is no `subtract`, and nothing in this folder can check that the math functions return the right values.\n\n## User-facing change\n\nCallers can import `subtract` from `math.js` and get `a - b`. A new test file checks that behavior and can be run with Node's built-in test runner.\n\n## Approach\n\nStay inside this folder. No new dependencies and no `package.json`. You chose `node:test`.\n\n1. In `math.js`, add `subtract(a, b)` next to `add`, using the same style: a named export that returns `a - b`.\n2. Add `math.test.js` that imports `subtract` and uses `node:test` plus `node:assert/strict`. Cover a positive difference, a negative difference, and zero.\n3. Run `node --test math.test.js` from this folder and confirm the tests pass.\n\n## Out of scope\n\n- Changing `add`\n- Editing `README.md` or `notes.txt`\n- Adding a package manager, test script, or extra files\n","plan_file_path":"/workspace/file-1"}}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} -{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"turn_completed","prompt_id":"turn-1","stop_reason":"end_turn","usage":{"inputTokens":331949,"outputTokens":3858,"cachedReadTokens":294144,"cacheCreationTokens":0},"elapsed_ms":64864}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-2","status":"completed","title":"Plan mode exited","content":[{"type":"content","content":{"type":"text","text":"Plan file: /workspace/file-1"}}],"rawOutput":{"type":"ExitPlanMode","PlanReady":{"message":"Your plan has been approved. You can now start coding.","plan_content":"# Add subtract and a node:test\n\n## Problem\n\n`math.js` only exports `add(a, b)`. There is no `subtract`, and nothing in this folder can check that the math functions return the right values.\n\n## User-facing change\n\nCallers can import `subtract` from `math.js` and get `a - b`. A new test file checks that behavior and can be run with Node's built-in test runner.\n\n## Approach\n\nStay inside this folder. No new dependencies and no `package.json`. You chose `node:test`.\n\n1. In `math.js`, add `subtract(a, b)` next to `add`, using the same style: a named export that returns `a - b`.\n2. Add `math.test.js` that imports `subtract` and uses `node:test` plus `node:assert/strict`. Cover a positive difference, a negative difference, and zero.\n3. Run `node --test math.test.js` from this folder and confirm the tests pass.\n\n## Out of scope\n\n- Changing `add`\n- Editing `README.md` or `notes.txt`\n- Adding a package manager, test script, or extra files\n","plan_file_path":"/workspace/file-1"}}},"_meta":{"promptId":"prompt:p1:4","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"response_completed","usage":{"input_tokens":278,"output_tokens":255,"cache_read_input_tokens":38912,"cache_creation_input_tokens":0,"reasoning_tokens":138}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"turn_completed","prompt_id":"prompt:p1:4","stop_reason":"end_turn","usage":{"inputTokens":331949,"outputTokens":3858,"cachedReadTokens":294144,"cacheCreationTokens":0},"elapsed_ms":64864}}}} {"direction":"in","process":"p1","message":{"jsonrpc":"2.0","id":4,"result":{"stopReason":"end_turn","_meta":{"usage":{"inputTokens":331949,"outputTokens":3858,"cachedReadTokens":294144,"cacheCreationTokens":0}}}}} diff --git a/src/main/acp/fixtures/s6-background.jsonl b/src/main/acp/fixtures/s6-background.jsonl index e63527fd9d23..927b68df55e2 100644 --- a/src/main/acp/fixtures/s6-background.jsonl +++ b/src/main/acp/fixtures/s6-background.jsonl @@ -1,10 +1,10 @@ -{"direction":"out","process":"p1","message":{"jsonrpc":"2.0","id":3,"method":"session/prompt","params":{"sessionId":"session-1","prompt":[{"type":"text","text":"Fixture prompt"}]}}} -{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"I'll"}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} -{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" start"}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} -{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call","toolCallId":"call-1","title":"run_terminal_command","rawInput":{"command":"sleep 15; echo bg-done > bg-marker.txt","description":"Sleep then write bg-marker.txt","block_until_ms":0},"_meta":{"x.ai/tool":{"name":"run_terminal_command","kind":"execute"}}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} -{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-1","kind":"execute","title":"Execute `sleep 15; echo bg-done > bg-marker.txt`","content":[{"type":"content","content":{"type":"text","text":"Sleep then write bg-marker.txt"}}],"locations":[],"rawInput":{"variant":"Bash","command":"sleep 15; echo bg-done > bg-marker.txt","description":"Sleep then write bg-marker.txt","is_background":false,"block_until_ms":0},"_meta":{"x.ai/tool":{"name":"run_terminal_command","kind":"execute"}}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} -{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-1","status":"completed","title":"[bg] sleep 15; echo bg-done > bg-marker.txt (01a10366)","content":[{"type":"content","content":{"type":"text","text":"Background task 01a10366-1e7f-7191-bcd1-fac24585851f started"}}],"rawOutput":{"type":"BackgroundTaskStarted","task_id":"01a10366-1e7f-7191-bcd1-fac24585851f","task_type":"bash","output_file":"/workspace/file-1","status":"running","command":"sleep 15; echo bg-done > bg-marker.txt","summary":"Background task 01a10366-1e7f-7191-bcd1-fac24585851f started","retrieval_hint":"Use get_command_or_subagent_output with task_ids=[\"01a10366-1e7f-7191-bcd1-fac24585851f\"] when you need the output."}},"_meta":{"promptId":"turn-1","turnStartMs":1000}}}} -{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"turn_completed","prompt_id":"turn-1","stop_reason":"end_turn","usage":{"inputTokens":69127,"outputTokens":207,"cachedReadTokens":35968,"cacheCreationTokens":0},"elapsed_ms":5774}}}} +{"direction":"out","process":"p1","message":{"jsonrpc":"2.0","id":3,"method":"session/prompt","params":{"sessionId":"session-1","prompt":[{"type":"text","text":"Fixture prompt"}],"_meta":{"promptId":"prompt:p1:3"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"I'll"}},"_meta":{"promptId":"prompt:p1:3","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" start"}},"_meta":{"promptId":"prompt:p1:3","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call","toolCallId":"call-1","title":"run_terminal_command","rawInput":{"command":"sleep 15; echo bg-done > bg-marker.txt","description":"Sleep then write bg-marker.txt","block_until_ms":0},"_meta":{"x.ai/tool":{"name":"run_terminal_command","kind":"execute"}}},"_meta":{"promptId":"prompt:p1:3","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-1","kind":"execute","title":"Execute `sleep 15; echo bg-done > bg-marker.txt`","content":[{"type":"content","content":{"type":"text","text":"Sleep then write bg-marker.txt"}}],"locations":[],"rawInput":{"variant":"Bash","command":"sleep 15; echo bg-done > bg-marker.txt","description":"Sleep then write bg-marker.txt","is_background":false,"block_until_ms":0},"_meta":{"x.ai/tool":{"name":"run_terminal_command","kind":"execute"}}},"_meta":{"promptId":"prompt:p1:3","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-1","status":"completed","title":"[bg] sleep 15; echo bg-done > bg-marker.txt (01a10366)","content":[{"type":"content","content":{"type":"text","text":"Background task 01a10366-1e7f-7191-bcd1-fac24585851f started"}}],"rawOutput":{"type":"BackgroundTaskStarted","task_id":"01a10366-1e7f-7191-bcd1-fac24585851f","task_type":"bash","output_file":"/workspace/file-1","status":"running","command":"sleep 15; echo bg-done > bg-marker.txt","summary":"Background task 01a10366-1e7f-7191-bcd1-fac24585851f started","retrieval_hint":"Use get_command_or_subagent_output with task_ids=[\"01a10366-1e7f-7191-bcd1-fac24585851f\"] when you need the output."}},"_meta":{"promptId":"prompt:p1:3","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"turn_completed","prompt_id":"prompt:p1:3","stop_reason":"end_turn","usage":{"inputTokens":69127,"outputTokens":207,"cachedReadTokens":35968,"cacheCreationTokens":0},"elapsed_ms":5774}}}} {"direction":"in","process":"p1","message":{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn","_meta":{"usage":{"inputTokens":69127,"outputTokens":207,"cachedReadTokens":35968,"cacheCreationTokens":0}}}}} {"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"The"}},"_meta":{"promptId":"task-completed-2","turnStartMs":1000}}}} {"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" background"}},"_meta":{"promptId":"task-completed-2","turnStartMs":1000}}}} From 19b4c7e24e53de4affaba3eaaf0fb76b135d8922 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 23:16:31 -0700 Subject: [PATCH 22/62] Detect short and Unicode home paths in encoded ACP fixtures --- src/main/acp/acp-fixture-privacy.test.ts | 42 +++++++++++++++--------- 1 file changed, 27 insertions(+), 15 deletions(-) diff --git a/src/main/acp/acp-fixture-privacy.test.ts b/src/main/acp/acp-fixture-privacy.test.ts index b683ec1eeb1e..e95728768881 100644 --- a/src/main/acp/acp-fixture-privacy.test.ts +++ b/src/main/acp/acp-fixture-privacy.test.ts @@ -46,9 +46,17 @@ function decodedStrings(value: unknown, depth = 0): string[] { } catch { /* Ordinary text is not JSON. */ } - for (const token of value.match(/[A-Za-z0-9+/_-]{16,}={0,2}/g) ?? []) { + for (const token of value.match(/[A-Za-z0-9+/_-]{8,}={0,2}/g) ?? []) { const decoded = Buffer.from(token, 'base64url').toString('utf8') - if (decoded !== token && /^[\x20-\x7e\r\n\t]+$/.test(decoded)) { + if ( + decoded !== token && + !decoded.includes('\uFFFD') && + [...decoded].every( + (char) => + ['\n', '\r', '\t'].includes(char) || + (char.charCodeAt(0) >= 32 && char.charCodeAt(0) !== 127) + ) + ) { texts.push(...decodedStrings(decoded, depth + 1)) } } @@ -56,20 +64,24 @@ function decodedStrings(value: unknown, depth = 0): string[] { } describe('ACP recording privacy', () => { - it.each(['/Users/private/project', '/home/private/project', 'C:\\Users\\private\\project'])( - 'detects home paths after nested JSON, URL, base64 and byte encoding: %s', - (path) => { - for (const value of [ - path, - encodeURIComponent(path), - Buffer.from(path).toString('base64'), - [...Buffer.from(path)], - JSON.stringify({ nested: JSON.stringify(path) }) - ]) { - expect(decodedStrings(value).some((text) => privateText.test(text))).toBe(true) - } + it.each([ + '/Users/u', + '/home/u', + '/home/é', + '/Users/private/project', + '/home/private/project', + 'C:\\Users\\private\\project' + ])('detects home paths after nested JSON, URL, base64 and byte encoding: %s', (path) => { + for (const value of [ + path, + encodeURIComponent(path), + Buffer.from(path).toString('base64'), + [...Buffer.from(path)], + JSON.stringify({ nested: JSON.stringify(path) }) + ]) { + expect(decodedStrings(value).some((text) => privateText.test(text))).toBe(true) } - ) + }) it('contains no personal metadata or home-directory paths in any decoded fixture', async () => { const directory = new URL('./fixtures/', import.meta.url) From 33fda9bcc36a143e4f44234a7e39e45e3598313e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 23:44:58 -0700 Subject: [PATCH 23/62] Use generic privacy patterns for ACP fixtures --- src/main/acp/acp-fixture-privacy.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/acp/acp-fixture-privacy.test.ts b/src/main/acp/acp-fixture-privacy.test.ts index e95728768881..dced40612d11 100644 --- a/src/main/acp/acp-fixture-privacy.test.ts +++ b/src/main/acp/acp-fixture-privacy.test.ts @@ -2,7 +2,7 @@ import { readFile, readdir } from 'node:fs/promises' import { describe, expect, it } from 'vitest' const privateText = - /\/Users\/|\/home\/|[a-z]:[\\/]+Users[\\/]|brennan|orca-qa|native-chat-other-agents|[\w.+-]+@[\w.-]+\.[a-z]{2,}|\bhostname\b|\b[\w-]+\.(?:local|lan)\b|signature|subscription_tier|skills?/i + /\/Users\/|\/home\/|[a-z]:[\\/]+Users[\\/]|[\w.+-]+@[\w.-]+\.[a-z]{2,}|\bhostname\b|\b[\w-]+\.(?:local|lan)\b|signature|subscription_tier|skills?/i function decodedStrings(value: unknown, depth = 0): string[] { if (depth > 8) { From d52d38b40ae518752b2582d6bfb19d9beacbb270 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 00:17:30 -0700 Subject: [PATCH 24/62] Translate Grok background work into durable task rows --- src/main/acp/acp-background-task-timeline.ts | 71 ++++++++ src/main/acp/acp-context-usage.ts | 30 ++++ src/main/acp/acp-dialects/acp-dialect.ts | 6 + .../acp/acp-dialects/grok-background-tasks.ts | 104 +++++++++++ src/main/acp/acp-dialects/grok-dialect.ts | 6 + src/main/acp/acp-session-update.ts | 7 +- .../acp/acp-timeline-background-tasks.test.ts | 170 ++++++++++++++++++ src/main/acp/acp-timeline-recordings.test.ts | 4 +- src/main/acp/acp-timeline-translator.ts | 31 ++-- .../claude-background-task-row-journal.ts | 18 +- src/shared/native-chat-background-task-row.ts | 11 ++ 11 files changed, 421 insertions(+), 37 deletions(-) create mode 100644 src/main/acp/acp-background-task-timeline.ts create mode 100644 src/main/acp/acp-dialects/grok-background-tasks.ts create mode 100644 src/main/acp/acp-timeline-background-tasks.test.ts diff --git a/src/main/acp/acp-background-task-timeline.ts b/src/main/acp/acp-background-task-timeline.ts new file mode 100644 index 000000000000..141e2f480237 --- /dev/null +++ b/src/main/acp/acp-background-task-timeline.ts @@ -0,0 +1,71 @@ +import type { AgentJournalRenderItem } from '../../shared/agent-session-journal-types' +import { BoundedMap } from '../../shared/bounded-map' +import { backgroundTaskJournalBody } from '../../shared/native-chat-background-task-row' +import { + isBackgroundTaskBlock, + type NativeChatBackgroundTaskBlock +} from '../../shared/native-chat-types' +import { + boundInlineText, + DEFAULT_JOURNAL_PAYLOAD_LIMITS +} from '../native-chat/agent-session-journal/journal-payload-bounds' +import type { + ProviderTimelineEvent, + ProviderTimelineJoin +} from '../native-chat/agent-session-timeline/provider-timeline-event' +import type { AcpBackgroundTaskUpdate } from './acp-dialects/acp-dialect' + +/** Partial provider snapshots only; the assembler owns settlement and durable placement. */ +export class AcpBackgroundTaskTimeline { + private readonly snapshots = new BoundedMap({ + maxEntries: 128, + maxBytes: 1024 * 1024, + sizeOf: (block, key) => Buffer.byteLength(key) + Buffer.byteLength(JSON.stringify(block)) + }) + + constructor(private readonly journalItems: () => readonly AgentJournalRenderItem[]) {} + + translate( + updates: AcpBackgroundTaskUpdate[], + join: ProviderTimelineJoin + ): ProviderTimelineEvent[] { + return updates.map((update) => { + const previous = this.snapshots.get(update.taskId) ?? this.persisted(update.taskId) + const block: NativeChatBackgroundTaskBlock = { + type: 'background-task', + kind: 'unknown', + label: update.taskId, + ...previous, + ...update + } + for (const key of ['label', 'summary', 'error', 'outputFile'] as const) { + const text = block[key] + if (text !== undefined) { + block[key] = boundInlineText(text, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text + } + } + this.snapshots.set(block.taskId, block) + return { + type: 'item.update', + item: `background-task:${block.taskId}`, + body: backgroundTaskJournalBody(block), + join + } + }) + } + + private persisted(taskId: string): NativeChatBackgroundTaskBlock | undefined { + for (const row of this.journalItems()) { + if (row.body.kind !== 'message') { + continue + } + const block = row.body.blocks.find( + (candidate) => isBackgroundTaskBlock(candidate) && candidate.taskId === taskId + ) + if (block && isBackgroundTaskBlock(block)) { + return block + } + } + return undefined + } +} diff --git a/src/main/acp/acp-context-usage.ts b/src/main/acp/acp-context-usage.ts index 000b915295bc..f3f260df741c 100644 --- a/src/main/acp/acp-context-usage.ts +++ b/src/main/acp/acp-context-usage.ts @@ -1,7 +1,37 @@ import { z } from 'zod' import type { AgentSessionContextUsage } from '../../shared/agent-session-context-usage' +import type { + ProviderTimelineEvent, + ProviderTimelineJoin +} from '../native-chat/agent-session-timeline/provider-timeline-event' +import type { AcpDialect } from './acp-dialects/acp-dialect' import type { UsageUpdate } from './generated/acp-protocol.generated' +export class AcpContextTimeline { + private window?: { tokens: number; capturedAt: number } + + models( + models: unknown, + at: number, + dialect: AcpDialect, + join: ProviderTimelineJoin + ): ProviderTimelineEvent[] { + const tokens = dialect.contextWindow?.(models) + return tokens === undefined ? [] : this.update({ window: { tokens, capturedAt: at } }, join) + } + + update(usage: AgentSessionContextUsage, join: ProviderTimelineJoin): ProviderTimelineEvent[] { + this.window = usage.window ?? this.window + return [ + { + type: 'context.usage', + usage: { ...usage, ...(this.window ? { window: this.window } : {}) }, + join + } + ] + } +} + export function acpWindowUsage(update: UsageUpdate, at: number): AgentSessionContextUsage { return { window: { tokens: update.size, capturedAt: at }, diff --git a/src/main/acp/acp-dialects/acp-dialect.ts b/src/main/acp/acp-dialects/acp-dialect.ts index 04f9c4c3997a..4b7d15a32c93 100644 --- a/src/main/acp/acp-dialects/acp-dialect.ts +++ b/src/main/acp/acp-dialects/acp-dialect.ts @@ -1,5 +1,6 @@ import type { AgentSessionContextUsage } from '../../../shared/agent-session-context-usage' import type { AgentSessionPromptResponse } from '../../../shared/agent-session-question-answer' +import type { NativeChatBackgroundTaskBlock } from '../../../shared/native-chat-types' import type { ProviderTimelineRequestBody } from '../../native-chat/agent-session-timeline/provider-timeline-event' import type { ToolCallUpdate } from '../generated/acp-protocol.generated' @@ -8,6 +9,9 @@ export type AcpRequestPresentation = { reply(response: AgentSessionPromptResponse | null): unknown } +export type AcpBackgroundTaskUpdate = Pick & + Partial> + export type AcpDialectNotification = | { disposition: 'ignore' } | { @@ -18,12 +22,14 @@ export type AcpDialectNotification = started?: boolean end?: { stopReason: string; durationMs?: number } usage?: AgentSessionContextUsage + backgroundTasks?: AcpBackgroundTaskUpdate[] } /** Hooks interpret extensions; lifecycle and row identity stay shared. */ export type AcpDialect = { injectedPromptIdentity?: true toolName?(update: ToolCallUpdate): string | undefined + toolBackgroundTasks?(update: ToolCallUpdate): AcpBackgroundTaskUpdate[] notification?(method: string, params: unknown, at: number): AcpDialectNotification | undefined contextWindow?(models: unknown): number | undefined request?(method: string, params: unknown): AcpRequestPresentation | undefined diff --git a/src/main/acp/acp-dialects/grok-background-tasks.ts b/src/main/acp/acp-dialects/grok-background-tasks.ts new file mode 100644 index 000000000000..6813f195ec57 --- /dev/null +++ b/src/main/acp/acp-dialects/grok-background-tasks.ts @@ -0,0 +1,104 @@ +import { z } from 'zod' +import { normalizeBackgroundTaskKind } from '../../../shared/native-chat-background-task-row' +import type { NativeChatBackgroundTaskBlock } from '../../../shared/native-chat-types' +import type { ToolCallUpdate } from '../generated/acp-protocol.generated' +import type { AcpBackgroundTaskUpdate, AcpDialectNotification } from './acp-dialect' + +const taskSchema = z.object({ + task_id: z.string().min(1), + command: z.string().optional(), + description: z.string().nullish(), + monitor_description: z.string().nullish(), + display_command: z.string().nullish(), + output_file: z.string().optional(), + summary: z.string().optional(), + error: z.string().optional(), + output: z.string().optional(), + kind: z.string().optional(), + task_type: z.string().optional(), + exit_code: z.number().int().nullish(), + signal: z.union([z.string(), z.number()]).nullish(), + explicitly_killed: z.boolean().optional() +}) +const backgroundedSchema = taskSchema.extend({ + sessionUpdate: z.literal('task_backgrounded'), + tool_call_id: z.string().optional() +}) +const completedSchema = z.object({ + sessionUpdate: z.literal('task_completed'), + task_snapshot: taskSchema +}) +const startedOutputSchema = taskSchema.extend({ type: z.literal('BackgroundTaskStarted') }) +type GrokTask = z.infer + +function snapshot( + task: GrokTask, + state: NativeChatBackgroundTaskBlock['state'] +): AcpBackgroundTaskUpdate { + const kind = task.kind ?? task.task_type + const monitor = kind === 'monitor' || task.monitor_description != null + const label = task.monitor_description ?? task.description ?? task.display_command ?? task.command + return { + taskId: task.task_id, + state, + ...(monitor + ? { kind: 'monitor' as const } + : kind !== undefined || task.command !== undefined + ? { + kind: + kind === 'bash' || kind === 'shell' || task.command !== undefined + ? ('command' as const) + : normalizeBackgroundTaskKind(kind ?? 'unknown') + } + : {}), + ...(label === undefined ? {} : { label }), + ...(task.output_file === undefined ? {} : { outputFile: task.output_file }), + ...(task.summary === undefined && task.output === undefined + ? {} + : { summary: task.summary ?? task.output }), + ...(task.error === undefined ? {} : { error: task.error }) + } +} + +export function grokToolBackgroundTasks(update: ToolCallUpdate): AcpBackgroundTaskUpdate[] { + const parsed = startedOutputSchema.safeParse(update.rawOutput) + return parsed.success + ? [{ ...snapshot(parsed.data, 'working'), parentToolUseId: update.toolCallId }] + : [] +} + +export function grokBackgroundTaskNotification( + canonicalMethod: string, + params: unknown +): AcpDialectNotification | undefined { + if (!['x.ai/task_backgrounded', 'x.ai/task_completed'].includes(canonicalMethod)) { + return undefined + } + const parsed = z + .object({ update: z.union([backgroundedSchema, completedSchema]) }) + .safeParse(params) + if (!parsed.success) { + return { disposition: 'ignore' } + } + const update = parsed.data.update + if (update.sessionUpdate === 'task_backgrounded') { + return { + disposition: 'map', + backgroundTasks: [ + { + ...snapshot(update, update.monitor_description != null ? 'monitoring' : 'working'), + ...(update.tool_call_id === undefined ? {} : { parentToolUseId: update.tool_call_id }) + } + ] + } + } + const task = update.task_snapshot + const state = task.explicitly_killed + ? 'idle' + : task.signal != null || (task.exit_code != null && task.exit_code !== 0) + ? 'blocked' + : task.exit_code === 0 + ? 'done' + : 'unverifiable' + return { disposition: 'map', backgroundTasks: [snapshot(task, state)] } +} diff --git a/src/main/acp/acp-dialects/grok-dialect.ts b/src/main/acp/acp-dialects/grok-dialect.ts index 700676d6b581..51a7bf7f59cc 100644 --- a/src/main/acp/acp-dialects/grok-dialect.ts +++ b/src/main/acp/acp-dialects/grok-dialect.ts @@ -2,6 +2,7 @@ import { z } from 'zod' import { acpNotificationEnvelopeSchema } from '../acp-context-usage' import type { AcpDialect, AcpDialectNotification } from './acp-dialect' import { grokRequest } from './grok-requests' +import { grokBackgroundTaskNotification, grokToolBackgroundTasks } from './grok-background-tasks' const tokenCount = z.number().int().nonnegative() const toolMetaSchema = z.object({ 'x.ai/tool': z.object({ name: z.string().min(1) }) }) @@ -55,6 +56,10 @@ function notification( at: number ): AcpDialectNotification | undefined { const canonical = method.startsWith('_') ? method.slice(1) : method + const backgroundTask = grokBackgroundTaskNotification(canonical, params) + if (backgroundTask) { + return backgroundTask + } if (canonical === 'x.ai/models/update') { const tokens = contextWindow(params) return tokens === undefined @@ -125,6 +130,7 @@ export const GROK_ACP_DIALECT: AcpDialect = { return parsed.success ? parsed.data['x.ai/tool'].name : undefined }, request: grokRequest, + toolBackgroundTasks: grokToolBackgroundTasks, notification, contextWindow } diff --git a/src/main/acp/acp-session-update.ts b/src/main/acp/acp-session-update.ts index b9fd57e93736..aa891d2e100f 100644 --- a/src/main/acp/acp-session-update.ts +++ b/src/main/acp/acp-session-update.ts @@ -6,6 +6,7 @@ import { } from '../native-chat/agent-session-journal/journal-payload-bounds' import { acpWindowUsage } from './acp-context-usage' import type { AcpDialect } from './acp-dialects/acp-dialect' +import type { AcpBackgroundTaskTimeline } from './acp-background-task-timeline' import type { AcpToolTimeline } from './acp-tool-timeline' import type { SessionNotification } from './generated/acp-protocol.generated' @@ -16,6 +17,7 @@ export function acpSessionUpdate( replay: boolean, tools: AcpToolTimeline, dialect: AcpDialect, + backgroundTasks: AcpBackgroundTaskTimeline, replayUserBody?: AgentJournalMessageItem, messageKey?: string ): ProviderTimelineEvent[] { @@ -70,7 +72,10 @@ export function acpSessionUpdate( : [] case 'tool_call': case 'tool_call_update': - return tools.translate(update, dialect, join.join) + return [ + ...tools.translate(update, dialect, join.join), + ...backgroundTasks.translate(dialect.toolBackgroundTasks?.(update) ?? [], join.join) + ] case 'plan': return [ { diff --git a/src/main/acp/acp-timeline-background-tasks.test.ts b/src/main/acp/acp-timeline-background-tasks.test.ts new file mode 100644 index 000000000000..60c6f9565c8a --- /dev/null +++ b/src/main/acp/acp-timeline-background-tasks.test.ts @@ -0,0 +1,170 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { backgroundTaskFallbackText } from '../../shared/native-chat-background-task-row' +import { deriveNativeChatRowContent } from '../../shared/native-chat-row-content' +import { isBackgroundTaskBlock } from '../../shared/native-chat-types' +import { closeProviderTimelineRigs } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import { openAcpFixtureRig, readAcpFixture } from './acp-timeline-fixture.test-support' + +afterEach(closeProviderTimelineRigs) + +const taskId = '01a10366-1e7f-7191-bcd1-fac24585851f' + +async function taskRows(fixture: Awaited>) { + return (await fixture.rig.rows()).flatMap((row) => { + if (row.body.kind !== 'message') { + return [] + } + const block = row.body.blocks.find(isBackgroundTaskBlock) + return block ? [{ row, block }] : [] + }) +} + +function started(id = taskId) { + return { + sessionId: 'session-1', + update: { + sessionUpdate: 'task_backgrounded', + task_id: id, + tool_call_id: 'call-1', + command: 'sleep 15', + description: 'Wait for the background command', + output_file: '/workspace/output.log' + } + } +} + +function completed(snapshot: object = { exit_code: 0 }) { + return { + sessionId: 'session-1', + update: { sessionUpdate: 'task_completed', task_snapshot: { task_id: taskId, ...snapshot } } + } +} + +describe('Grok background tasks through the shared timeline', () => { + it.each(['end', 'reset'] as const)( + 'renders the recorded task outside turn settlement and marks it unverifiable on session %s after restart', + async (boundary) => { + const fixture = await openAcpFixtureRig() + await fixture.feed(await readAcpFixture('s6-background')) + const [before] = await taskRows(fixture) + expect(before?.block).toMatchObject({ + taskId, + kind: 'command', + state: 'working', + parentToolUseId: 'call-1', + outputFile: '/workspace/file-1' + }) + expect(before?.row.turnScope?.kind).toBe('turn') + expect((await fixture.rig.turns()).map((turn) => turn.state)).toEqual([ + 'completed', + 'completed' + ]) + expect( + (await fixture.rig.rows()).find((row) => row.body.kind === 'tool-call')?.body + ).toMatchObject({ state: 'completed' }) + if (!before || before.row.body.kind !== 'message') { + throw new Error('Missing background-task row') + } + const render = deriveNativeChatRowContent(before.row.body.blocks) + expect(render.backgroundTasks).toEqual([before.block]) + expect(render.prose).toEqual([]) + + fixture.restart() + fixture.apply([ + boundary === 'end' + ? { type: 'session.ended', verdict: { state: 'unverifiable' } } + : { type: 'session.reset', namespace: 'replacement-session', generation: 'gen-3' } + ]) + const [after] = await taskRows(fixture) + expect(after?.row.itemId).toBe(before.row.itemId) + expect(after?.block.state).toBe('unverifiable') + expect(after?.row.body).toMatchObject({ + blocks: [{ type: 'text', text: backgroundTaskFallbackText(after!.block) }, after!.block] + }) + } + ) + + it.each(['x.ai/', '_x.ai/'])( + 'joins %s task notifications and the tool result into one row and keeps its origin after restart', + async (prefix) => { + const fixture = await openAcpFixtureRig() + const frames = await readAcpFixture('s6-background') + await fixture.feed(frames.slice(0, 5)) + fixture.apply(fixture.lane().notification(`${prefix}task_backgrounded`, started(), 1010)) + await fixture.feed(frames.slice(5)) + const [before] = await taskRows(fixture) + expect(await taskRows(fixture)).toHaveLength(1) + expect(before?.block.label).toBe('sleep 15; echo bg-done > bg-marker.txt') + fixture.restart() + fixture.apply( + fixture + .lane() + .notification( + `${prefix}task_completed`, + completed({ exit_code: 0, output: 'Finished' }), + 2000 + ) + ) + const [after] = await taskRows(fixture) + expect(after?.row.itemId).toBe(before?.row.itemId) + expect(after?.row.turnScope).toEqual(before?.row.turnScope) + expect(after?.block).toMatchObject({ + state: 'done', + summary: 'Finished', + label: before?.block.label, + parentToolUseId: 'call-1' + }) + expect(await fixture.rig.turns()).toHaveLength(2) + fixture.apply(fixture.lane().notification(`${prefix}task_backgrounded`, started(), 2001)) + expect((await taskRows(fixture))[0]?.block.state).toBe('done') + } + ) + + it.each([ + [{ exit_code: 2, error: 'Command failed' }, 'blocked'], + [{ exit_code: null, signal: 'SIGTERM' }, 'blocked'], + [{ exit_code: null, explicitly_killed: true }, 'idle'], + [{ exit_code: null }, 'unverifiable'] + ] as const)( + 'uses the provider outcome %j without claiming unseen success', + async (snapshot, state) => { + const fixture = await openAcpFixtureRig() + await fixture.feed(await readAcpFixture('s6-background')) + fixture.apply(fixture.lane().notification('x.ai/task_completed', completed(snapshot), 2000)) + expect((await taskRows(fixture))[0]?.block.state).toBe(state) + } + ) + + it('recovers partial task metadata from the journal after bounded snapshots are evicted', async () => { + const fixture = await openAcpFixtureRig() + await fixture.feed(await readAcpFixture('s6-background')) + for (let index = 0; index < 130; index += 1) { + fixture.apply( + fixture + .lane() + .notification('x.ai/task_backgrounded', started(`task-${index}`), 1500 + index) + ) + } + await fixture.rig.rows() + fixture.apply(fixture.lane().notification('x.ai/task_completed', completed(), 2000)) + const original = (await taskRows(fixture)).find((task) => task.block.taskId === taskId) + expect(original?.block).toMatchObject({ + state: 'done', + parentToolUseId: 'call-1', + outputFile: '/workspace/file-1', + kind: 'command' + }) + }) + + it('ignores malformed, other-session and replay-only task notifications without opening turns', async () => { + const fixture = await openAcpFixtureRig() + for (const params of [ + { sessionId: 'session-1', update: { sessionUpdate: 'task_completed' } }, + { ...started(), sessionId: 'other' }, + { ...started(), _meta: { isReplay: true } } + ]) { + fixture.apply(fixture.lane().notification('_x.ai/task_backgrounded', params, 1000)) + } + expect(await fixture.rig.rows()).toEqual([]) + }) +}) diff --git a/src/main/acp/acp-timeline-recordings.test.ts b/src/main/acp/acp-timeline-recordings.test.ts index 7155e690f69d..e3dfe80ef58a 100644 --- a/src/main/acp/acp-timeline-recordings.test.ts +++ b/src/main/acp/acp-timeline-recordings.test.ts @@ -128,7 +128,9 @@ describe('recorded ACP traffic through the journal', () => { ['completed', 'success'], ['completed', 'success'] ]) - const messages = rows.filter((row) => row.body.kind === 'message') + const messages = rows.filter( + (row) => row.body.kind === 'message' && row.body.role === 'assistant' + ) expect(messages).toHaveLength(2) expect(messageText(messages[1]!.body)).toBe('The background') expect(messages[0]!.turnScope).not.toEqual(messages[1]!.turnScope) diff --git a/src/main/acp/acp-timeline-translator.ts b/src/main/acp/acp-timeline-translator.ts index 0867dcf40c12..7fb83a6de94b 100644 --- a/src/main/acp/acp-timeline-translator.ts +++ b/src/main/acp/acp-timeline-translator.ts @@ -5,7 +5,8 @@ import type { } from '../../shared/agent-session-journal-types' import { BoundedMap } from '../../shared/bounded-map' import type { ProviderTimelineEvent } from '../native-chat/agent-session-timeline/provider-timeline-event' -import { acpNotificationEnvelopeSchema } from './acp-context-usage' +import { acpNotificationEnvelopeSchema, AcpContextTimeline } from './acp-context-usage' +import { AcpBackgroundTaskTimeline } from './acp-background-task-timeline' import { GENERIC_ACP_DIALECT, type AcpDialect, @@ -42,14 +43,16 @@ export class AcpTimelineTranslator { private readonly dialect: AcpDialect private readonly prompts: AcpPromptTurns private readonly tools = new AcpToolTimeline() + private readonly backgroundTasks: AcpBackgroundTaskTimeline private readonly messages = new AcpTurnMessages() private readonly started = new BoundedMap({ maxEntries: 128 }) private replay?: LoadReplay - private window?: { tokens: number; capturedAt: number } + private readonly context = new AcpContextTimeline() private activeTurn?: string constructor(private readonly options: AcpTimelineTranslatorOptions) { this.dialect = options.dialect ?? GENERIC_ACP_DIALECT + this.backgroundTasks = new AcpBackgroundTaskTimeline(options.journalItems) this.prompts = new AcpPromptTurns( options.sessionId, this.dialect.injectedPromptIdentity === true @@ -96,18 +99,7 @@ export class AcpTimelineTranslator { } contextModels(models: unknown, at: number): ProviderTimelineEvent[] { - const tokens = this.dialect.contextWindow?.(models) - if (tokens === undefined) { - return [] - } - this.window = { tokens, capturedAt: at } - return [ - { - type: 'context.usage', - usage: { window: this.window }, - join: { thread: this.options.sessionId } - } - ] + return this.context.models(models, at, this.dialect, { thread: this.options.sessionId }) } beginLoad(): void { @@ -230,13 +222,11 @@ export class AcpTimelineTranslator { } } const join = { thread: this.options.sessionId, ...(turn === undefined ? {} : { turn }) } + if (extension?.backgroundTasks) { + events.push(...this.backgroundTasks.translate(extension.backgroundTasks, join)) + } if (extension?.usage) { - this.window = extension.usage.window ?? this.window - events.push({ - type: 'context.usage', - usage: { ...extension.usage, ...(this.window ? { window: this.window } : {}) }, - join - }) + events.push(...this.context.update(extension.usage, join)) } if (extension?.end && turn) { if ( @@ -267,6 +257,7 @@ export class AcpTimelineTranslator { isReplay, this.tools, this.dialect, + this.backgroundTasks, replayUser?.body, messageKey ) diff --git a/src/main/claude/claude-background-task-row-journal.ts b/src/main/claude/claude-background-task-row-journal.ts index 45f8dfc2ae17..1419729c5cf1 100644 --- a/src/main/claude/claude-background-task-row-journal.ts +++ b/src/main/claude/claude-background-task-row-journal.ts @@ -1,13 +1,9 @@ import type { - AgentJournalItemBody, AgentJournalItemIdentity, AgentJournalTurnScope } from '../../shared/agent-session-journal-types' -import { backgroundTaskFallbackText } from '../../shared/native-chat-background-task-row' -import { - isBackgroundTaskBlock, - type NativeChatBackgroundTaskBlock -} from '../../shared/native-chat-types' +import { backgroundTaskJournalBody } from '../../shared/native-chat-background-task-row' +import { isBackgroundTaskBlock } from '../../shared/native-chat-types' import type { StructuredAgentSessionEventSink, StructuredAgentSessionLifecycleJournal, @@ -37,15 +33,7 @@ export function claudeBackgroundTaskIdentity( return { provider: 'orca', clientMessageId: key } } -export function claudeBackgroundTaskBody( - block: NativeChatBackgroundTaskBlock -): AgentJournalItemBody { - return { - kind: 'message', - role: 'system', - blocks: [{ type: 'text', text: backgroundTaskFallbackText(block) }, { ...block }] - } -} +export const claudeBackgroundTaskBody = backgroundTaskJournalBody /** Reconcile one queued row against the durable run identity after a rebind. */ export function resolveClaudeBackgroundTaskIdentity( diff --git a/src/shared/native-chat-background-task-row.ts b/src/shared/native-chat-background-task-row.ts index 9be089b74865..717c8691386a 100644 --- a/src/shared/native-chat-background-task-row.ts +++ b/src/shared/native-chat-background-task-row.ts @@ -5,6 +5,7 @@ // the block. The frozen sentence beside it is built here too, so the twin and // the block can never describe the task differently. +import type { AgentJournalMessageItem } from './agent-session-journal-types' import { isBackgroundTaskBlock, type NativeChatBackgroundTaskBlock, @@ -113,6 +114,16 @@ export function backgroundTaskFallbackText(block: NativeChatBackgroundTaskBlock) return `${subject.charAt(0).toUpperCase()}${subject.slice(1)} ${verb}` } +export function backgroundTaskJournalBody( + block: NativeChatBackgroundTaskBlock +): AgentJournalMessageItem { + return { + kind: 'message', + role: 'system', + blocks: [{ type: 'text', text: backgroundTaskFallbackText(block) }, { ...block }] + } +} + /** The background-task rows in `blocks`. */ export function backgroundTaskBlocks( blocks: readonly NativeChatBlock[] From b4e29e06b5194af94e07880324e455823a2dc424 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 00:21:41 -0700 Subject: [PATCH 25/62] Recover background task origin from its persisted launch tool --- src/main/acp/acp-background-task-timeline.ts | 12 +++++- src/main/acp/acp-journal-turns.ts | 10 +++++ .../acp/acp-timeline-background-tasks.test.ts | 39 +++++++++++++++++++ src/main/acp/acp-timeline-requests.ts | 12 +----- src/main/acp/acp-timeline-translator.ts | 4 +- 5 files changed, 64 insertions(+), 13 deletions(-) diff --git a/src/main/acp/acp-background-task-timeline.ts b/src/main/acp/acp-background-task-timeline.ts index 141e2f480237..46cfd3117639 100644 --- a/src/main/acp/acp-background-task-timeline.ts +++ b/src/main/acp/acp-background-task-timeline.ts @@ -14,6 +14,7 @@ import type { ProviderTimelineJoin } from '../native-chat/agent-session-timeline/provider-timeline-event' import type { AcpBackgroundTaskUpdate } from './acp-dialects/acp-dialect' +import { acpJournalToolTurn } from './acp-journal-turns' /** Partial provider snapshots only; the assembler owns settlement and durable placement. */ export class AcpBackgroundTaskTimeline { @@ -23,7 +24,10 @@ export class AcpBackgroundTaskTimeline { sizeOf: (block, key) => Buffer.byteLength(key) + Buffer.byteLength(JSON.stringify(block)) }) - constructor(private readonly journalItems: () => readonly AgentJournalRenderItem[]) {} + constructor( + private readonly journalItems: () => readonly AgentJournalRenderItem[], + private readonly toolTurn: (callId: string) => string | undefined + ) {} translate( updates: AcpBackgroundTaskUpdate[], @@ -45,11 +49,15 @@ export class AcpBackgroundTaskTimeline { } } this.snapshots.set(block.taskId, block) + const turn = block.parentToolUseId + ? (this.toolTurn(block.parentToolUseId) ?? + acpJournalToolTurn(this.journalItems(), block.parentToolUseId)) + : undefined return { type: 'item.update', item: `background-task:${block.taskId}`, body: backgroundTaskJournalBody(block), - join + join: { ...join, ...(turn === undefined ? {} : { turn }) } } }) } diff --git a/src/main/acp/acp-journal-turns.ts b/src/main/acp/acp-journal-turns.ts index 15d48e78e8c4..6d1b95c71274 100644 --- a/src/main/acp/acp-journal-turns.ts +++ b/src/main/acp/acp-journal-turns.ts @@ -19,6 +19,16 @@ export function acpJournalTurnKey(row: AgentJournalRenderItem): string | undefin } } +export function acpJournalToolTurn( + rows: readonly AgentJournalRenderItem[], + callId: string +): string | undefined { + const tool = rows.find((row) => row.body.kind === 'tool-call' && row.body.callId === callId) + const turnItemId = tool?.turnScope?.kind === 'turn' ? tool.turnScope.turnItemId : undefined + const turn = rows.find((row) => row.itemId === turnItemId) + return turn && acpJournalTurnKey(turn) +} + export function acpJournalTurnIsSettled( rows: readonly AgentJournalRenderItem[], key: string diff --git a/src/main/acp/acp-timeline-background-tasks.test.ts b/src/main/acp/acp-timeline-background-tasks.test.ts index 60c6f9565c8a..9dae05d93853 100644 --- a/src/main/acp/acp-timeline-background-tasks.test.ts +++ b/src/main/acp/acp-timeline-background-tasks.test.ts @@ -156,6 +156,45 @@ describe('Grok background tasks through the shared timeline', () => { }) }) + it('places a first task notice after restart beside its original tool while a different turn is active', async () => { + const fixture = await openAcpFixtureRig() + await fixture.feed((await readAcpFixture('s6-background')).slice(0, 5)) + fixture.apply( + fixture.lane().notification( + 'session/update', + { + sessionId: 'session-1', + update: { sessionUpdate: 'tool_call_update', toolCallId: 'call-1', status: 'completed' } + }, + 1200 + ) + ) + fixture.apply(fixture.lane().promptResult('p1:3', { stopReason: 'end_turn' }, 1300)) + const original = (await fixture.rig.rows()).find((row) => row.body.kind === 'tool-call') + fixture.restart() + fixture.apply(fixture.lane().openPrompt('next', 1400).events) + fixture.apply( + fixture.lane().notification( + 'session/update', + { + sessionId: 'session-1', + _meta: { promptId: 'prompt:next' }, + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'Next reply' } + } + }, + 1500 + ) + ) + fixture.apply(fixture.lane().notification('x.ai/task_backgrounded', started(), 1600)) + expect((await taskRows(fixture))[0]?.row.turnScope).toEqual(original?.turnScope) + expect((await fixture.rig.turns()).map((turn) => turn.state)).toEqual(['completed', 'running']) + fixture.apply(fixture.lane().notification('x.ai/task_completed', completed(), 1700)) + expect((await taskRows(fixture))[0]?.block.state).toBe('done') + expect((await fixture.rig.turns())[1]?.state).toBe('running') + }) + it('ignores malformed, other-session and replay-only task notifications without opening turns', async () => { const fixture = await openAcpFixtureRig() for (const params of [ diff --git a/src/main/acp/acp-timeline-requests.ts b/src/main/acp/acp-timeline-requests.ts index 16fe309d7516..23bc215e80b7 100644 --- a/src/main/acp/acp-timeline-requests.ts +++ b/src/main/acp/acp-timeline-requests.ts @@ -2,7 +2,7 @@ import { z } from 'zod' import type { AgentJournalRenderItem } from '../../shared/agent-session-journal-types' import type { ProviderTimelineEvent } from '../native-chat/agent-session-timeline/provider-timeline-event' import type { AcpDialect, AcpRequestPresentation } from './acp-dialects/acp-dialect' -import { acpJournalTurnKey } from './acp-journal-turns' +import { acpJournalToolTurn } from './acp-journal-turns' import type { AcpToolTimeline } from './acp-tool-timeline' import { AcpRpcError } from './acp-errors' import { RequestPermissionRequestSchema } from './generated/acp-protocol.generated' @@ -70,16 +70,8 @@ export function translateAcpRequest( : options.dialect.request?.(method, params) const tool = requestToolSchema.safeParse(params) const callId = tool.success ? (tool.data.toolCall?.toolCallId ?? tool.data.toolCallId) : undefined - const storedTool = callId - ? options - .journalItems() - .find((row) => row.body.kind === 'tool-call' && row.body.callId === callId) - : undefined - const turnItemId = - storedTool?.turnScope?.kind === 'turn' ? storedTool.turnScope.turnItemId : undefined - const storedTurn = options.journalItems().find((row) => row.itemId === turnItemId) const turn = callId - ? (options.tools.turn(callId) ?? (storedTurn && acpJournalTurnKey(storedTurn))) + ? (options.tools.turn(callId) ?? acpJournalToolTurn(options.journalItems(), callId)) : undefined const join = { thread: options.sessionId, ...(turn === undefined ? {} : { turn }) } if (!presentation) { diff --git a/src/main/acp/acp-timeline-translator.ts b/src/main/acp/acp-timeline-translator.ts index 7fb83a6de94b..a99036e1a8c1 100644 --- a/src/main/acp/acp-timeline-translator.ts +++ b/src/main/acp/acp-timeline-translator.ts @@ -52,7 +52,9 @@ export class AcpTimelineTranslator { constructor(private readonly options: AcpTimelineTranslatorOptions) { this.dialect = options.dialect ?? GENERIC_ACP_DIALECT - this.backgroundTasks = new AcpBackgroundTaskTimeline(options.journalItems) + this.backgroundTasks = new AcpBackgroundTaskTimeline(options.journalItems, (callId) => + this.tools.turn(callId) + ) this.prompts = new AcpPromptTurns( options.sessionId, this.dialect.injectedPromptIdentity === true From efe2df4e9a0fa07b825093d2523a7ff97db81635 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 00:24:03 -0700 Subject: [PATCH 26/62] Restate background task fallback from its terminal evidence --- .../acp/acp-dialects/grok-background-tasks.ts | 15 +++++++++++---- .../acp/acp-timeline-background-tasks.test.ts | 1 + 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/main/acp/acp-dialects/grok-background-tasks.ts b/src/main/acp/acp-dialects/grok-background-tasks.ts index 6813f195ec57..d2c221e79227 100644 --- a/src/main/acp/acp-dialects/grok-background-tasks.ts +++ b/src/main/acp/acp-dialects/grok-background-tasks.ts @@ -1,5 +1,8 @@ import { z } from 'zod' -import { normalizeBackgroundTaskKind } from '../../../shared/native-chat-background-task-row' +import { + isSettledBackgroundTaskState, + normalizeBackgroundTaskKind +} from '../../../shared/native-chat-background-task-row' import type { NativeChatBackgroundTaskBlock } from '../../../shared/native-chat-types' import type { ToolCallUpdate } from '../generated/acp-protocol.generated' import type { AcpBackgroundTaskUpdate, AcpDialectNotification } from './acp-dialect' @@ -53,10 +56,14 @@ function snapshot( : {}), ...(label === undefined ? {} : { label }), ...(task.output_file === undefined ? {} : { outputFile: task.output_file }), - ...(task.summary === undefined && task.output === undefined + ...(task.summary === undefined && + task.output === undefined && + !isSettledBackgroundTaskState(state) ? {} - : { summary: task.summary ?? task.output }), - ...(task.error === undefined ? {} : { error: task.error }) + : { summary: task.summary ?? task.output ?? '' }), + ...(task.error === undefined && !isSettledBackgroundTaskState(state) + ? {} + : { error: task.error ?? '' }) } } diff --git a/src/main/acp/acp-timeline-background-tasks.test.ts b/src/main/acp/acp-timeline-background-tasks.test.ts index 9dae05d93853..d62973d7da3d 100644 --- a/src/main/acp/acp-timeline-background-tasks.test.ts +++ b/src/main/acp/acp-timeline-background-tasks.test.ts @@ -154,6 +154,7 @@ describe('Grok background tasks through the shared timeline', () => { outputFile: '/workspace/file-1', kind: 'command' }) + expect(original && backgroundTaskFallbackText(original.block)).toContain('finished') }) it('places a first task notice after restart beside its original tool while a different turn is active', async () => { From d400e03da0edbc9d3998147ae5d6eac8563be51f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 00:33:17 -0700 Subject: [PATCH 27/62] Require session identity on Grok task notifications --- src/main/acp/acp-dialects/grok-background-tasks.ts | 2 +- src/main/acp/acp-timeline-background-tasks.test.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/acp/acp-dialects/grok-background-tasks.ts b/src/main/acp/acp-dialects/grok-background-tasks.ts index d2c221e79227..1f354b26f98a 100644 --- a/src/main/acp/acp-dialects/grok-background-tasks.ts +++ b/src/main/acp/acp-dialects/grok-background-tasks.ts @@ -82,7 +82,7 @@ export function grokBackgroundTaskNotification( return undefined } const parsed = z - .object({ update: z.union([backgroundedSchema, completedSchema]) }) + .object({ sessionId: z.string(), update: z.union([backgroundedSchema, completedSchema]) }) .safeParse(params) if (!parsed.success) { return { disposition: 'ignore' } diff --git a/src/main/acp/acp-timeline-background-tasks.test.ts b/src/main/acp/acp-timeline-background-tasks.test.ts index d62973d7da3d..5b0f5cd18531 100644 --- a/src/main/acp/acp-timeline-background-tasks.test.ts +++ b/src/main/acp/acp-timeline-background-tasks.test.ts @@ -200,6 +200,7 @@ describe('Grok background tasks through the shared timeline', () => { const fixture = await openAcpFixtureRig() for (const params of [ { sessionId: 'session-1', update: { sessionUpdate: 'task_completed' } }, + { update: started().update }, { ...started(), sessionId: 'other' }, { ...started(), _meta: { isReplay: true } } ]) { From 23866edfad59e8c08e1fc62c49d8a37aeae7122c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 01:02:31 -0700 Subject: [PATCH 28/62] Honor explicit Grok task completion without an exit code --- src/main/acp/acp-dialects/grok-background-tasks.ts | 6 ++---- src/main/acp/acp-timeline-background-tasks.test.ts | 5 +++-- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/src/main/acp/acp-dialects/grok-background-tasks.ts b/src/main/acp/acp-dialects/grok-background-tasks.ts index 1f354b26f98a..1477529a45a5 100644 --- a/src/main/acp/acp-dialects/grok-background-tasks.ts +++ b/src/main/acp/acp-dialects/grok-background-tasks.ts @@ -102,10 +102,8 @@ export function grokBackgroundTaskNotification( const task = update.task_snapshot const state = task.explicitly_killed ? 'idle' - : task.signal != null || (task.exit_code != null && task.exit_code !== 0) + : task.signal != null || task.error?.trim() || (task.exit_code != null && task.exit_code !== 0) ? 'blocked' - : task.exit_code === 0 - ? 'done' - : 'unverifiable' + : 'done' return { disposition: 'map', backgroundTasks: [snapshot(task, state)] } } diff --git a/src/main/acp/acp-timeline-background-tasks.test.ts b/src/main/acp/acp-timeline-background-tasks.test.ts index 5b0f5cd18531..a0876365a548 100644 --- a/src/main/acp/acp-timeline-background-tasks.test.ts +++ b/src/main/acp/acp-timeline-background-tasks.test.ts @@ -122,11 +122,12 @@ describe('Grok background tasks through the shared timeline', () => { it.each([ [{ exit_code: 2, error: 'Command failed' }, 'blocked'], + [{ exit_code: null, error: 'Task failed' }, 'blocked'], [{ exit_code: null, signal: 'SIGTERM' }, 'blocked'], [{ exit_code: null, explicitly_killed: true }, 'idle'], - [{ exit_code: null }, 'unverifiable'] + [{ exit_code: null }, 'done'] ] as const)( - 'uses the provider outcome %j without claiming unseen success', + 'preserves explicit task completion and the provider outcome %j', async (snapshot, state) => { const fixture = await openAcpFixtureRig() await fixture.feed(await readAcpFixture('s6-background')) From d0e8a074b0dcd0d1761e5fbf3782136bbff46320 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 01:34:46 -0700 Subject: [PATCH 29/62] Match the background reset test to the final timeline grammar --- src/main/acp/acp-timeline-background-tasks.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/acp/acp-timeline-background-tasks.test.ts b/src/main/acp/acp-timeline-background-tasks.test.ts index a0876365a548..b254c24b101a 100644 --- a/src/main/acp/acp-timeline-background-tasks.test.ts +++ b/src/main/acp/acp-timeline-background-tasks.test.ts @@ -73,7 +73,7 @@ describe('Grok background tasks through the shared timeline', () => { fixture.apply([ boundary === 'end' ? { type: 'session.ended', verdict: { state: 'unverifiable' } } - : { type: 'session.reset', namespace: 'replacement-session', generation: 'gen-3' } + : { type: 'session.reset', namespace: 'replacement-session' } ]) const [after] = await taskRows(fixture) expect(after?.row.itemId).toBe(before.row.itemId) From 71430ae068cca43eebe76c7395b109706a73d967 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 01:41:28 -0700 Subject: [PATCH 30/62] Give task completion fixtures a specific evidence type --- src/main/acp/acp-timeline-background-tasks.test.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/main/acp/acp-timeline-background-tasks.test.ts b/src/main/acp/acp-timeline-background-tasks.test.ts index b254c24b101a..a25a37020093 100644 --- a/src/main/acp/acp-timeline-background-tasks.test.ts +++ b/src/main/acp/acp-timeline-background-tasks.test.ts @@ -33,7 +33,15 @@ function started(id = taskId) { } } -function completed(snapshot: object = { exit_code: 0 }) { +type TaskCompletionEvidence = { + exit_code?: number | null + error?: string + output?: string + signal?: string | number | null + explicitly_killed?: boolean +} + +function completed(snapshot: TaskCompletionEvidence = { exit_code: 0 }) { return { sessionId: 'session-1', update: { sessionUpdate: 'task_completed', task_snapshot: { task_id: taskId, ...snapshot } } From 01bd0385a1bb4dc3c667d27c8f771ad3cf320508 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 03:37:45 -0700 Subject: [PATCH 31/62] Settle ACP background tasks from live and replayed evidence --- src/main/acp/acp-background-task-timeline.ts | 13 +- src/main/acp/acp-dialects/acp-dialect.ts | 11 +- .../acp/acp-dialects/grok-background-tasks.ts | 41 ++- src/main/acp/acp-session-update.ts | 14 +- ...-timeline-background-task-evidence.test.ts | 305 ++++++++++++++++++ .../acp/acp-timeline-background-tasks.test.ts | 20 +- src/main/acp/acp-timeline-translator.ts | 5 +- src/main/acp/acp-tool-timeline.ts | 30 +- src/main/acp/fixtures/s6-background.jsonl | 7 + 9 files changed, 417 insertions(+), 29 deletions(-) create mode 100644 src/main/acp/acp-timeline-background-task-evidence.test.ts diff --git a/src/main/acp/acp-background-task-timeline.ts b/src/main/acp/acp-background-task-timeline.ts index 46cfd3117639..0156a10c223f 100644 --- a/src/main/acp/acp-background-task-timeline.ts +++ b/src/main/acp/acp-background-task-timeline.ts @@ -31,16 +31,23 @@ export class AcpBackgroundTaskTimeline { translate( updates: AcpBackgroundTaskUpdate[], - join: ProviderTimelineJoin + join: ProviderTimelineJoin, + replay = false ): ProviderTimelineEvent[] { return updates.map((update) => { + const { fallbackLabel, ...fields } = update const previous = this.snapshots.get(update.taskId) ?? this.persisted(update.taskId) const block: NativeChatBackgroundTaskBlock = { type: 'background-task', kind: 'unknown', - label: update.taskId, ...previous, - ...update + ...fields, + label: update.label ?? previous?.label ?? fallbackLabel ?? update.taskId, + // A historical launch proves no current liveness; explicit outcomes still settle it. + state: + replay && ['working', 'monitoring', 'waiting'].includes(update.state) + ? 'unverifiable' + : update.state } for (const key of ['label', 'summary', 'error', 'outputFile'] as const) { const text = block[key] diff --git a/src/main/acp/acp-dialects/acp-dialect.ts b/src/main/acp/acp-dialects/acp-dialect.ts index 4b7d15a32c93..9fed58f93789 100644 --- a/src/main/acp/acp-dialects/acp-dialect.ts +++ b/src/main/acp/acp-dialects/acp-dialect.ts @@ -1,4 +1,5 @@ import type { AgentSessionContextUsage } from '../../../shared/agent-session-context-usage' +import type { AgentJournalToolCallItem } from '../../../shared/agent-session-journal-types' import type { AgentSessionPromptResponse } from '../../../shared/agent-session-question-answer' import type { NativeChatBackgroundTaskBlock } from '../../../shared/native-chat-types' import type { ProviderTimelineRequestBody } from '../../native-chat/agent-session-timeline/provider-timeline-event' @@ -10,7 +11,10 @@ export type AcpRequestPresentation = { } export type AcpBackgroundTaskUpdate = Pick & - Partial> + Partial> & { + /** Used only until a provider description is known. */ + fallbackLabel?: string + } export type AcpDialectNotification = | { disposition: 'ignore' } @@ -29,7 +33,10 @@ export type AcpDialectNotification = export type AcpDialect = { injectedPromptIdentity?: true toolName?(update: ToolCallUpdate): string | undefined - toolBackgroundTasks?(update: ToolCallUpdate): AcpBackgroundTaskUpdate[] + toolBackgroundTasks?( + update: ToolCallUpdate, + tool: AgentJournalToolCallItem + ): AcpBackgroundTaskUpdate[] notification?(method: string, params: unknown, at: number): AcpDialectNotification | undefined contextWindow?(models: unknown): number | undefined request?(method: string, params: unknown): AcpRequestPresentation | undefined diff --git a/src/main/acp/acp-dialects/grok-background-tasks.ts b/src/main/acp/acp-dialects/grok-background-tasks.ts index 1477529a45a5..3c19c5e49669 100644 --- a/src/main/acp/acp-dialects/grok-background-tasks.ts +++ b/src/main/acp/acp-dialects/grok-background-tasks.ts @@ -1,4 +1,5 @@ import { z } from 'zod' +import type { AgentJournalToolCallItem } from '../../../shared/agent-session-journal-types' import { isSettledBackgroundTaskState, normalizeBackgroundTaskKind @@ -40,7 +41,8 @@ function snapshot( ): AcpBackgroundTaskUpdate { const kind = task.kind ?? task.task_type const monitor = kind === 'monitor' || task.monitor_description != null - const label = task.monitor_description ?? task.description ?? task.display_command ?? task.command + const label = task.monitor_description?.trim() || task.description?.trim() + const fallbackLabel = task.display_command?.trim() || task.command?.trim() return { taskId: task.task_id, state, @@ -54,23 +56,46 @@ function snapshot( : normalizeBackgroundTaskKind(kind ?? 'unknown') } : {}), - ...(label === undefined ? {} : { label }), + ...(label ? { label } : fallbackLabel ? { fallbackLabel } : {}), ...(task.output_file === undefined ? {} : { outputFile: task.output_file }), - ...(task.summary === undefined && - task.output === undefined && - !isSettledBackgroundTaskState(state) + ...(task.summary === undefined && !isSettledBackgroundTaskState(state) ? {} - : { summary: task.summary ?? task.output ?? '' }), + : { summary: task.summary ?? '' }), ...(task.error === undefined && !isSettledBackgroundTaskState(state) ? {} : { error: task.error ?? '' }) } } -export function grokToolBackgroundTasks(update: ToolCallUpdate): AcpBackgroundTaskUpdate[] { +const taskInputSchema = z.object({ + description: z.string().optional(), + command: z.string().optional(), + task_id: z.string().trim().min(1).optional(), + task_ids: z.array(z.string().trim().min(1)).optional() +}) + +export function grokToolBackgroundTasks( + update: ToolCallUpdate, + tool: AgentJournalToolCallItem +): AcpBackgroundTaskUpdate[] { + const input = taskInputSchema.safeParse(tool.input) + if (tool.name === 'kill_command_or_subagent' && update.status === 'completed' && input.success) { + // Inference: a successful kill call settles its named tasks without a completion notice. + return [ + ...new Set([ + ...(input.data.task_ids ?? []), + ...(input.data.task_id ? [input.data.task_id] : []) + ]) + ].map((taskId) => ({ taskId, state: 'idle', summary: '', error: '' })) + } const parsed = startedOutputSchema.safeParse(update.rawOutput) return parsed.success - ? [{ ...snapshot(parsed.data, 'working'), parentToolUseId: update.toolCallId }] + ? [ + { + ...snapshot({ ...(input.success ? input.data : {}), ...parsed.data }, 'working'), + parentToolUseId: update.toolCallId + } + ] : [] } diff --git a/src/main/acp/acp-session-update.ts b/src/main/acp/acp-session-update.ts index aa891d2e100f..c398c129dcd6 100644 --- a/src/main/acp/acp-session-update.ts +++ b/src/main/acp/acp-session-update.ts @@ -71,11 +71,15 @@ export function acpSessionUpdate( ] : [] case 'tool_call': - case 'tool_call_update': - return [ - ...tools.translate(update, dialect, join.join), - ...backgroundTasks.translate(dialect.toolBackgroundTasks?.(update) ?? [], join.join) - ] + case 'tool_call_update': { + const events = tools.translate(update, dialect, join.join) + const tool = events[0] + const tasks = + tool && 'body' in tool && tool.body?.kind === 'tool-call' + ? (dialect.toolBackgroundTasks?.(update, tool.body) ?? []) + : [] + return [...events, ...backgroundTasks.translate(tasks, join.join, replay)] + } case 'plan': return [ { diff --git a/src/main/acp/acp-timeline-background-task-evidence.test.ts b/src/main/acp/acp-timeline-background-task-evidence.test.ts new file mode 100644 index 000000000000..023f6d6d5860 --- /dev/null +++ b/src/main/acp/acp-timeline-background-task-evidence.test.ts @@ -0,0 +1,305 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { backgroundTaskFallbackText } from '../../shared/native-chat-background-task-row' +import { isBackgroundTaskBlock } from '../../shared/native-chat-types' +import { closeProviderTimelineRigs } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import type { ToolCallUpdate } from './generated/acp-protocol.generated' +import { openAcpFixtureRig, readAcpFixture } from './acp-timeline-fixture.test-support' + +afterEach(closeProviderTimelineRigs) + +const taskId = '01a10366-1e7f-7191-bcd1-fac24585851f' +type Fixture = Awaited> + +async function taskRows(fixture: Fixture) { + return (await fixture.rig.rows()).flatMap((row) => { + const block = + row.body.kind === 'message' ? row.body.blocks.find(isBackgroundTaskBlock) : undefined + return block ? [{ row, block }] : [] + }) +} + +async function launch(fixture: Fixture) { + const frames = await readAcpFixture('s6-background') + const completion = frames.findIndex((frame) => frame.message.method === '_x.ai/task_completed') + expect(completion).toBeGreaterThan(0) + await fixture.feed(frames.slice(0, completion)) + return frames.slice(completion) +} + +function tool(fixture: Fixture, update: ToolCallUpdate, replay = false) { + const events = fixture.lane().notification( + 'session/update', + { + sessionId: 'session-1', + _meta: { promptId: replay ? 'historic-turn' : 'kill-turn', isReplay: replay }, + update: { sessionUpdate: 'tool_call_update', ...update } + }, + 2000 + ) + for (const event of events) { + expect(fixture.rig.assembler.apply(event).admission, update.toolCallId).toMatchObject({ + accepted: true + }) + } +} + +function taskNotice(fixture: Fixture, id: string, replay = false, completed = false) { + fixture.apply( + fixture.lane().notification( + completed ? '_x.ai/task_completed' : '_x.ai/task_backgrounded', + { + sessionId: 'session-1', + _meta: { isReplay: replay }, + update: completed + ? { sessionUpdate: 'task_completed', task_snapshot: { task_id: id, exit_code: 0 } } + : { sessionUpdate: 'task_backgrounded', task_id: id, command: 'sleep 99' } + }, + 2001 + ) + ) +} + +describe('background task outcome evidence', () => { + it('SF1 keeps multiline stdout out of the task sentence and preserves the description across frames', async () => { + const fixture = await openAcpFixtureRig() + await launch(fixture) + const output = `PASS a.test.js\nPASS b.test.js\n${'log line\n'.repeat(3000)}` + fixture.apply( + fixture.lane().notification( + '_x.ai/task_completed', + { + sessionId: 'session-1', + update: { + sessionUpdate: 'task_completed', + task_snapshot: { + task_id: taskId, + command: 'npm test', + output, + exit_code: 0 + } + } + }, + 3000 + ) + ) + const [task] = await taskRows(fixture) + expect(task?.block).toMatchObject({ + state: 'done', + label: 'Sleep then write bg-marker.txt', + summary: '' + }) + expect(task && backgroundTaskFallbackText(task.block)).toBe( + 'Background command "Sleep then write bg-marker.txt" finished' + ) + expect(task?.row.body).toMatchObject({ + blocks: [ + { type: 'text', text: 'Background command "Sleep then write bg-marker.txt" finished' }, + expect.objectContaining({ type: 'background-task', state: 'done' }) + ] + }) + expect(JSON.stringify(task?.row.body)).not.toContain('PASS a.test.js') + }) + + it('uses the command when the provider never gives a description', async () => { + const fixture = await openAcpFixtureRig() + taskNotice(fixture, 'unnamed') + taskNotice(fixture, 'unnamed', false, true) + const [task] = await taskRows(fixture) + expect(task && backgroundTaskFallbackText(task.block)).toBe( + 'Background command "sleep 99" finished' + ) + }) + + it('replays the captured task and queue notices into one completed row beside its launch tool', async () => { + const fixture = await openAcpFixtureRig() + const tail = await launch(fixture) + const [before] = await taskRows(fixture) + expect(before?.block.state).toBe('working') + fixture.restart() + await fixture.feed(tail) + const [after] = await taskRows(fixture) + expect(await taskRows(fixture)).toHaveLength(1) + expect(after?.row.itemId).toBe(before?.row.itemId) + expect(after?.row.turnScope).toEqual(before?.row.turnScope) + expect(after?.block).toMatchObject({ + state: 'done', + label: 'Sleep then write bg-marker.txt', + parentToolUseId: 'call-1' + }) + fixture.apply([{ type: 'session.ended', verdict: { state: 'unverifiable' } }]) + expect((await taskRows(fixture))[0]?.block.state).toBe('done') + }) + + it.each(['partial', 'restart', 'eviction'] as const)( + 'SF2 settles named tasks on a completed kill call after %s', + async (boundary) => { + const fixture = await openAcpFixtureRig() + await launch(fixture) + taskNotice(fixture, 'other-task') + const [before] = await taskRows(fixture) + tool(fixture, { + toolCallId: 'kill-1', + status: 'in_progress', + _meta: { 'x.ai/tool': { name: 'kill_command_or_subagent' } }, + rawInput: { task_id: taskId, task_ids: [taskId, 'other-task'] } + }) + await fixture.rig.rows() + if (boundary === 'restart') { + fixture.restart() + } + if (boundary === 'eviction') { + for (let index = 0; index < 70; index += 1) { + // Pressure only the translator cache; the target still goes through the journal. + fixture.lane().notification( + 'session/update', + { + sessionId: 'session-1', + _meta: { promptId: 'kill-turn' }, + update: { + sessionUpdate: 'tool_call', + toolCallId: `unrelated-${index}`, + status: 'in_progress', + name: 'read_file', + rawInput: { content: 'x'.repeat(40000) }, + rawOutput: 'y'.repeat(40000) + } + }, + 2000 + ) + } + await fixture.rig.rows() + } + tool(fixture, { toolCallId: 'kill-1', status: 'completed' }) + const tasks = await taskRows(fixture) + expect(tasks.map((task) => task.block.state)).toEqual(['idle', 'idle']) + expect(tasks[0]?.row.itemId).toBe(before?.row.itemId) + expect(tasks[0]?.row.turnScope).toEqual(before?.row.turnScope) + expect(tasks[0] && backgroundTaskFallbackText(tasks[0].block)).toBe( + 'Background command "Sleep then write bg-marker.txt" was stopped' + ) + taskNotice(fixture, taskId) + taskNotice(fixture, taskId, false, true) + expect((await taskRows(fixture))[0]?.block.state).toBe('idle') + } + ) + + it.each(['pending', 'in_progress', 'failed'] as const)( + 'does not infer a stop from a %s kill call', + async (status) => { + const fixture = await openAcpFixtureRig() + await launch(fixture) + tool(fixture, { + toolCallId: 'kill-1', + name: 'kill_command_or_subagent', + status, + rawInput: { task_id: taskId } + }) + expect((await taskRows(fixture))[0]?.block.state).toBe('working') + } + ) + + it.each(['launch', 'notification'] as const)( + 'SF3 treats replayed %s evidence as unverifiable and accepts a later completion', + async (source) => { + const fixture = await openAcpFixtureRig() + fixture.lane().beginLoad() + if (source === 'launch') { + tool( + fixture, + { + toolCallId: 'historic-tool', + status: 'completed', + rawInput: { description: 'Historical task' }, + rawOutput: { + type: 'BackgroundTaskStarted', + task_id: taskId, + task_type: 'bash', + command: 'sleep 99' + } + }, + true + ) + } else { + taskNotice(fixture, taskId, true) + } + fixture.finishLoad() + expect((await taskRows(fixture))[0]?.block.state).toBe('unverifiable') + fixture.restart() + taskNotice(fixture, taskId, false, true) + expect((await taskRows(fixture))[0]?.block.state).toBe('done') + } + ) + + it.each(['notification', 'kill'] as const)( + 'SF3 preserves the explicit %s outcome included in adopted history', + async (completion) => { + const fixture = await openAcpFixtureRig() + fixture.lane().beginLoad() + tool( + fixture, + { + toolCallId: 'historic-tool', + status: 'completed', + rawOutput: { + type: 'BackgroundTaskStarted', + task_id: taskId, + task_type: 'bash', + command: 'sleep 99' + } + }, + true + ) + if (completion === 'notification') { + taskNotice(fixture, taskId, true, true) + } else { + tool( + fixture, + { + toolCallId: 'historic-kill', + name: 'kill_command_or_subagent', + status: 'completed', + rawInput: { task_id: taskId } + }, + true + ) + } + fixture.finishLoad() + expect((await taskRows(fixture))[0]?.block.state).toBe( + completion === 'notification' ? 'done' : 'idle' + ) + } + ) + + it('SF3 adopts a missing task into unfinished journal history without claiming it is live', async () => { + const fixture = await openAcpFixtureRig() + fixture.apply( + fixture.lane().notification( + 'session/update', + { + sessionId: 'session-1', + _meta: { promptId: 'historic-turn' }, + update: { sessionUpdate: 'tool_call', toolCallId: 'historic-tool', status: 'in_progress' } + }, + 1000 + ) + ) + await fixture.rig.rows() + fixture.lane().beginLoad() + tool( + fixture, + { + toolCallId: 'historic-tool', + status: 'completed', + rawOutput: { + type: 'BackgroundTaskStarted', + task_id: taskId, + task_type: 'bash', + command: 'sleep 99' + } + }, + true + ) + fixture.finishLoad() + expect((await taskRows(fixture))[0]?.block.state).toBe('unverifiable') + }) +}) diff --git a/src/main/acp/acp-timeline-background-tasks.test.ts b/src/main/acp/acp-timeline-background-tasks.test.ts index a25a37020093..eb1c926f251b 100644 --- a/src/main/acp/acp-timeline-background-tasks.test.ts +++ b/src/main/acp/acp-timeline-background-tasks.test.ts @@ -9,6 +9,12 @@ afterEach(closeProviderTimelineRigs) const taskId = '01a10366-1e7f-7191-bcd1-fac24585851f' +async function runningFrames() { + return (await readAcpFixture('s6-background')).filter( + (frame) => frame.message.method !== '_x.ai/task_completed' + ) +} + async function taskRows(fixture: Awaited>) { return (await fixture.rig.rows()).flatMap((row) => { if (row.body.kind !== 'message') { @@ -53,7 +59,7 @@ describe('Grok background tasks through the shared timeline', () => { 'renders the recorded task outside turn settlement and marks it unverifiable on session %s after restart', async (boundary) => { const fixture = await openAcpFixtureRig() - await fixture.feed(await readAcpFixture('s6-background')) + await fixture.feed(await runningFrames()) const [before] = await taskRows(fixture) expect(before?.block).toMatchObject({ taskId, @@ -96,13 +102,13 @@ describe('Grok background tasks through the shared timeline', () => { 'joins %s task notifications and the tool result into one row and keeps its origin after restart', async (prefix) => { const fixture = await openAcpFixtureRig() - const frames = await readAcpFixture('s6-background') + const frames = await runningFrames() await fixture.feed(frames.slice(0, 5)) fixture.apply(fixture.lane().notification(`${prefix}task_backgrounded`, started(), 1010)) await fixture.feed(frames.slice(5)) const [before] = await taskRows(fixture) expect(await taskRows(fixture)).toHaveLength(1) - expect(before?.block.label).toBe('sleep 15; echo bg-done > bg-marker.txt') + expect(before?.block.label).toBe('Sleep then write bg-marker.txt') fixture.restart() fixture.apply( fixture @@ -118,7 +124,7 @@ describe('Grok background tasks through the shared timeline', () => { expect(after?.row.turnScope).toEqual(before?.row.turnScope) expect(after?.block).toMatchObject({ state: 'done', - summary: 'Finished', + summary: '', label: before?.block.label, parentToolUseId: 'call-1' }) @@ -138,7 +144,7 @@ describe('Grok background tasks through the shared timeline', () => { 'preserves explicit task completion and the provider outcome %j', async (snapshot, state) => { const fixture = await openAcpFixtureRig() - await fixture.feed(await readAcpFixture('s6-background')) + await fixture.feed(await runningFrames()) fixture.apply(fixture.lane().notification('x.ai/task_completed', completed(snapshot), 2000)) expect((await taskRows(fixture))[0]?.block.state).toBe(state) } @@ -146,7 +152,7 @@ describe('Grok background tasks through the shared timeline', () => { it('recovers partial task metadata from the journal after bounded snapshots are evicted', async () => { const fixture = await openAcpFixtureRig() - await fixture.feed(await readAcpFixture('s6-background')) + await fixture.feed(await runningFrames()) for (let index = 0; index < 130; index += 1) { fixture.apply( fixture @@ -168,7 +174,7 @@ describe('Grok background tasks through the shared timeline', () => { it('places a first task notice after restart beside its original tool while a different turn is active', async () => { const fixture = await openAcpFixtureRig() - await fixture.feed((await readAcpFixture('s6-background')).slice(0, 5)) + await fixture.feed((await runningFrames()).slice(0, 5)) fixture.apply( fixture.lane().notification( 'session/update', diff --git a/src/main/acp/acp-timeline-translator.ts b/src/main/acp/acp-timeline-translator.ts index a99036e1a8c1..1a93fe55dcf9 100644 --- a/src/main/acp/acp-timeline-translator.ts +++ b/src/main/acp/acp-timeline-translator.ts @@ -42,7 +42,7 @@ export type AcpTimelineTranslatorOptions = { export class AcpTimelineTranslator { private readonly dialect: AcpDialect private readonly prompts: AcpPromptTurns - private readonly tools = new AcpToolTimeline() + private readonly tools: AcpToolTimeline private readonly backgroundTasks: AcpBackgroundTaskTimeline private readonly messages = new AcpTurnMessages() private readonly started = new BoundedMap({ maxEntries: 128 }) @@ -52,6 +52,7 @@ export class AcpTimelineTranslator { constructor(private readonly options: AcpTimelineTranslatorOptions) { this.dialect = options.dialect ?? GENERIC_ACP_DIALECT + this.tools = new AcpToolTimeline(options.journalItems) this.backgroundTasks = new AcpBackgroundTaskTimeline(options.journalItems, (callId) => this.tools.turn(callId) ) @@ -225,7 +226,7 @@ export class AcpTimelineTranslator { } const join = { thread: this.options.sessionId, ...(turn === undefined ? {} : { turn }) } if (extension?.backgroundTasks) { - events.push(...this.backgroundTasks.translate(extension.backgroundTasks, join)) + events.push(...this.backgroundTasks.translate(extension.backgroundTasks, join, isReplay)) } if (extension?.usage) { events.push(...this.context.update(extension.usage, join)) diff --git a/src/main/acp/acp-tool-timeline.ts b/src/main/acp/acp-tool-timeline.ts index 5cde7b29e0b0..1567a871740a 100644 --- a/src/main/acp/acp-tool-timeline.ts +++ b/src/main/acp/acp-tool-timeline.ts @@ -1,6 +1,12 @@ import { createPatch } from 'diff' import { z } from 'zod' -import type { AgentJournalToolCallItem } from '../../shared/agent-session-journal-types' +import { parseAgentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import type { + AgentJournalRenderItem, + AgentJournalToolCallItem +} from '../../shared/agent-session-journal-types' +import { acpJournalToolTurn } from './acp-journal-turns' +import { providerTimelineKeyPart } from '../native-chat/agent-session-timeline/provider-timeline-identity' import { boundPayload, boundToolInput, @@ -74,12 +80,15 @@ export class AcpToolTimeline { private readonly tools = new Map() private bytes = 0 + constructor(private readonly journalItems: () => readonly AgentJournalRenderItem[] = () => []) {} + translate( update: ToolCallUpdate, dialect: AcpDialect, join: ProviderTimelineJoin ): ProviderTimelineEvent[] { - const previous = this.tools.get(update.toolCallId) + const previous = + this.tools.get(update.toolCallId) ?? this.persisted(update.toolCallId, join.thread) const output = outputText(update) const state = update.status === 'completed' @@ -154,6 +163,23 @@ export class AcpToolTimeline { return this.tools.get(toolCallId)?.turn } + private persisted(callId: string, thread: string | undefined): ToolSnapshot | undefined { + const rows = this.journalItems() + const suffix = `${thread ? `${providerTimelineKeyPart(thread)}/` : ''}${providerTimelineKeyPart(`tool:${callId}`)}` + const row = rows.find((item) => { + const identity = parseAgentJournalItemKey(item.itemId) + return ( + item.body.kind === 'tool-call' && + item.body.callId === callId && + identity?.provider === 'legacy' && + identity.recordId.endsWith(`:${suffix}`) + ) + }) + return row?.body.kind === 'tool-call' + ? { body: row.body, turn: acpJournalToolTurn(rows, callId) } + : undefined + } + end(turn: string): void { for (const [key, snapshot] of this.tools) { if (snapshot.turn === turn) { diff --git a/src/main/acp/fixtures/s6-background.jsonl b/src/main/acp/fixtures/s6-background.jsonl index 927b68df55e2..5b1837955172 100644 --- a/src/main/acp/fixtures/s6-background.jsonl +++ b/src/main/acp/fixtures/s6-background.jsonl @@ -1,11 +1,18 @@ {"direction":"out","process":"p1","message":{"jsonrpc":"2.0","id":3,"method":"session/prompt","params":{"sessionId":"session-1","prompt":[{"type":"text","text":"Fixture prompt"}],"_meta":{"promptId":"prompt:p1:3"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/queue/changed","params":{"sessionId":"session-1","entries":[{"id":"prompt:p1:3","version":0,"kind":"prompt","text":"Start this shell command as a BACKGROUND task (do not wait for it): sleep 15; echo bg-done > bg-marker.txt . Then immediately end your turn with a one-line reply. When the background task finishes later, tell me.","position":0}]}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/queue/changed","params":{"sessionId":"session-1","entries":[],"runningPromptId":"prompt:p1:3","runningText":"Start this shell command as a BACKGROUND task (do not wait for it): sleep 15; echo bg-done > bg-marker.txt . Then immediately end your turn with a one-line reply. When the background task finishes later, tell me.","runningKind":"prompt"}}} {"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"I'll"}},"_meta":{"promptId":"prompt:p1:3","turnStartMs":1000}}}} {"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" start"}},"_meta":{"promptId":"prompt:p1:3","turnStartMs":1000}}}} {"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call","toolCallId":"call-1","title":"run_terminal_command","rawInput":{"command":"sleep 15; echo bg-done > bg-marker.txt","description":"Sleep then write bg-marker.txt","block_until_ms":0},"_meta":{"x.ai/tool":{"name":"run_terminal_command","kind":"execute"}}},"_meta":{"promptId":"prompt:p1:3","turnStartMs":1000}}}} {"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-1","kind":"execute","title":"Execute `sleep 15; echo bg-done > bg-marker.txt`","content":[{"type":"content","content":{"type":"text","text":"Sleep then write bg-marker.txt"}}],"locations":[],"rawInput":{"variant":"Bash","command":"sleep 15; echo bg-done > bg-marker.txt","description":"Sleep then write bg-marker.txt","is_background":false,"block_until_ms":0},"_meta":{"x.ai/tool":{"name":"run_terminal_command","kind":"execute"}}},"_meta":{"promptId":"prompt:p1:3","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/task_backgrounded","params":{"sessionId":"session-1","update":{"sessionUpdate":"task_backgrounded","tool_call_id":"call-1","task_id":"01a10366-1e7f-7191-bcd1-fac24585851f","command":"sleep 15; echo bg-done > bg-marker.txt","cwd":"/workspace/project","output_file":"/workspace/file-1","description":"Sleep then write bg-marker.txt"},"_meta":{"eventId":"session-1-24","agentTimestampMs":1000}}}} {"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-1","status":"completed","title":"[bg] sleep 15; echo bg-done > bg-marker.txt (01a10366)","content":[{"type":"content","content":{"type":"text","text":"Background task 01a10366-1e7f-7191-bcd1-fac24585851f started"}}],"rawOutput":{"type":"BackgroundTaskStarted","task_id":"01a10366-1e7f-7191-bcd1-fac24585851f","task_type":"bash","output_file":"/workspace/file-1","status":"running","command":"sleep 15; echo bg-done > bg-marker.txt","summary":"Background task 01a10366-1e7f-7191-bcd1-fac24585851f started","retrieval_hint":"Use get_command_or_subagent_output with task_ids=[\"01a10366-1e7f-7191-bcd1-fac24585851f\"] when you need the output."}},"_meta":{"promptId":"prompt:p1:3","turnStartMs":1000}}}} {"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"turn_completed","prompt_id":"prompt:p1:3","stop_reason":"end_turn","usage":{"inputTokens":69127,"outputTokens":207,"cachedReadTokens":35968,"cacheCreationTokens":0},"elapsed_ms":5774}}}} {"direction":"in","process":"p1","message":{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn","_meta":{"usage":{"inputTokens":69127,"outputTokens":207,"cachedReadTokens":35968,"cacheCreationTokens":0}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/queue/changed","params":{"sessionId":"session-1","entries":[]}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/queue/changed","params":{"sessionId":"session-1","entries":[]}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/queue/changed","params":{"sessionId":"session-1","entries":[],"runningPromptId":"task-completed-2","runningText":"\nBackground task \"01a10366-1e7f-7191-bcd1-fac24585851f\" completed (exit code: 0).\nDescription: Sleep then write bg-marker.txt | Duration: 15.0s\nUse get_command_or_subagent_output(\"01a10366-1e7f-7191-bcd1-fac24585851f\") to see the full output.\n","runningKind":"prompt"}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/task_completed","params":{"sessionId":"session-1","update":{"sessionUpdate":"task_completed","task_snapshot":{"task_id":"01a10366-1e7f-7191-bcd1-fac24585851f","command":"sleep 15; echo bg-done > bg-marker.txt","cwd":"/workspace/project","start_time":{"secs_since_epoch":1791058386,"nanos_since_epoch":559567000},"end_time":{"secs_since_epoch":1791058401,"nanos_since_epoch":567941000},"output":"","output_file":"/workspace/file-1","truncated":false,"output_total_bytes":0,"exit_code":0,"signal":null,"completed":true,"kind":"bash","block_waited":false,"explicitly_killed":false,"kill_result_delivered":false,"owner_session_id":"session-1","description":"Sleep then write bg-marker.txt","is_backgrounded":true},"will_wake":true},"_meta":{"eventId":"session-1-47","agentTimestampMs":1000}}}} {"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"The"}},"_meta":{"promptId":"task-completed-2","turnStartMs":1000}}}} {"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" background"}},"_meta":{"promptId":"task-completed-2","turnStartMs":1000}}}} {"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"turn_completed","prompt_id":"task-completed-2","stop_reason":"end_turn","usage":{"inputTokens":34928,"outputTokens":219,"cachedReadTokens":34688,"cacheCreationTokens":0},"elapsed_ms":4520}}}} From dec3ecbb3ab55055e7959f875f989dd2e53878c3 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 03:43:12 -0700 Subject: [PATCH 32/62] Honor background task outcomes carried by replayed tool results --- .../acp/acp-dialects/grok-background-tasks.ts | 57 ++++++++- ...-timeline-background-task-evidence.test.ts | 8 +- ...p-timeline-background-task-results.test.ts | 111 ++++++++++++++++++ src/main/acp/acp-tool-timeline.ts | 10 +- 4 files changed, 173 insertions(+), 13 deletions(-) create mode 100644 src/main/acp/acp-timeline-background-task-results.test.ts diff --git a/src/main/acp/acp-dialects/grok-background-tasks.ts b/src/main/acp/acp-dialects/grok-background-tasks.ts index 3c19c5e49669..3726c8b25327 100644 --- a/src/main/acp/acp-dialects/grok-background-tasks.ts +++ b/src/main/acp/acp-dialects/grok-background-tasks.ts @@ -33,8 +33,44 @@ const completedSchema = z.object({ task_snapshot: taskSchema }) const startedOutputSchema = taskSchema.extend({ type: z.literal('BackgroundTaskStarted') }) +const taskResultSchema = taskSchema.extend({ + status: z.string().optional(), + outcome: z.string().optional() +}) +const resultOutputSchema = z.object({ + type: z.enum(['TaskOutput', 'KillTask']), + Result: taskResultSchema.optional(), + MultiResult: z.object({ results: z.array(taskResultSchema) }).optional() +}) type GrokTask = z.infer +function completedState(task: GrokTask): NativeChatBackgroundTaskBlock['state'] { + return task.explicitly_killed + ? 'idle' + : task.signal != null || task.error?.trim() || (task.exit_code != null && task.exit_code !== 0) + ? 'blocked' + : 'done' +} + +function resultState( + task: z.infer +): NativeChatBackgroundTaskBlock['state'] | undefined { + if (task.explicitly_killed || ['killed', 'stopped', 'cancelled'].includes(task.status ?? '')) { + return 'idle' + } + if (task.error?.trim() || ['failed', 'error'].includes(task.status ?? '')) { + return 'blocked' + } + if ( + task.exit_code != null || + task.signal != null || + ['completed', 'success', 'succeeded'].includes(task.status ?? '') + ) { + return completedState(task) + } + return ['pending', 'running'].includes(task.status ?? '') ? 'working' : undefined +} + function snapshot( task: GrokTask, state: NativeChatBackgroundTaskBlock['state'] @@ -78,6 +114,20 @@ export function grokToolBackgroundTasks( update: ToolCallUpdate, tool: AgentJournalToolCallItem ): AcpBackgroundTaskUpdate[] { + const result = resultOutputSchema.safeParse(update.rawOutput) + if (result.success) { + const tasks = + result.data.MultiResult?.results ?? (result.data.Result ? [result.data.Result] : []) + return tasks.flatMap((task) => { + if (result.data.type === 'KillTask') { + return update.status === 'completed' && task.outcome === 'killed' + ? [snapshot(task, 'idle')] + : [] + } + const state = resultState(task) + return state && !task.command?.startsWith('[subagent:') ? [snapshot(task, state)] : [] + }) + } const input = taskInputSchema.safeParse(tool.input) if (tool.name === 'kill_command_or_subagent' && update.status === 'completed' && input.success) { // Inference: a successful kill call settles its named tasks without a completion notice. @@ -125,10 +175,5 @@ export function grokBackgroundTaskNotification( } } const task = update.task_snapshot - const state = task.explicitly_killed - ? 'idle' - : task.signal != null || task.error?.trim() || (task.exit_code != null && task.exit_code !== 0) - ? 'blocked' - : 'done' - return { disposition: 'map', backgroundTasks: [snapshot(task, state)] } + return { disposition: 'map', backgroundTasks: [snapshot(task, completedState(task))] } } diff --git a/src/main/acp/acp-timeline-background-task-evidence.test.ts b/src/main/acp/acp-timeline-background-task-evidence.test.ts index 023f6d6d5860..7b98bcce2a0d 100644 --- a/src/main/acp/acp-timeline-background-task-evidence.test.ts +++ b/src/main/acp/acp-timeline-background-task-evidence.test.ts @@ -157,6 +157,7 @@ describe('background task outcome evidence', () => { _meta: { promptId: 'kill-turn' }, update: { sessionUpdate: 'tool_call', + title: 'Read file', toolCallId: `unrelated-${index}`, status: 'in_progress', name: 'read_file', @@ -278,7 +279,12 @@ describe('background task outcome evidence', () => { { sessionId: 'session-1', _meta: { promptId: 'historic-turn' }, - update: { sessionUpdate: 'tool_call', toolCallId: 'historic-tool', status: 'in_progress' } + update: { + sessionUpdate: 'tool_call', + title: 'Historical launch', + toolCallId: 'historic-tool', + status: 'in_progress' + } }, 1000 ) diff --git a/src/main/acp/acp-timeline-background-task-results.test.ts b/src/main/acp/acp-timeline-background-task-results.test.ts new file mode 100644 index 000000000000..49cba692cd36 --- /dev/null +++ b/src/main/acp/acp-timeline-background-task-results.test.ts @@ -0,0 +1,111 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { isBackgroundTaskBlock } from '../../shared/native-chat-types' +import { closeProviderTimelineRigs } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import { openAcpFixtureRig } from './acp-timeline-fixture.test-support' + +afterEach(closeProviderTimelineRigs) + +describe('background task results in adopted history', () => { + it.each([ + ['running', undefined, 'unverifiable'], + ['completed', 0, 'done'], + ['completed', undefined, 'done'], + ['failed', 1, 'blocked'], + ['stopped', undefined, 'idle'], + ['unknown', undefined, 'unverifiable'] + ] as const)( + 'settles replay from a TaskOutput result with status %s and exit code %s', + async (status, exitCode, state) => { + const fixture = await openAcpFixtureRig() + const lane = fixture.lane() + lane.beginLoad() + for (const [index, rawOutput] of [ + { type: 'BackgroundTaskStarted', task_id: 'historic-task', command: 'npm test' }, + { + type: 'TaskOutput', + Result: { + task_id: 'historic-task', + command: 'npm test', + status, + exit_code: exitCode, + output: 'PASS a.test.js\nPASS b.test.js\n' + } + } + ].entries()) { + fixture.apply( + lane.notification( + 'session/update', + { + sessionId: 'session-1', + _meta: { isReplay: true, promptId: 'historic-turn' }, + update: { + sessionUpdate: 'tool_call_update', + toolCallId: `tool-${index}`, + status: 'completed', + rawOutput + } + }, + 1000 + index + ) + ) + } + fixture.finishLoad() + const tasks = (await fixture.rig.rows()).flatMap((row) => + row.body.kind === 'message' ? row.body.blocks.filter(isBackgroundTaskBlock) : [] + ) + expect(tasks).toHaveLength(1) + expect(tasks[0]).toMatchObject({ state, label: 'npm test' }) + expect(tasks[0]?.summary ?? '').not.toContain('PASS') + } + ) + + it('uses each structured kill result rather than inferring a stop for unsuccessful targets', async () => { + const fixture = await openAcpFixtureRig() + const lane = fixture.lane() + for (const id of ['killed-task', 'kept-task']) { + fixture.apply( + lane.notification( + '_x.ai/task_backgrounded', + { + sessionId: 'session-1', + update: { sessionUpdate: 'task_backgrounded', task_id: id, command: 'sleep 99' } + }, + 1000 + ) + ) + } + fixture.apply( + lane.notification( + 'session/update', + { + sessionId: 'session-1', + _meta: { promptId: 'kill-turn' }, + update: { + sessionUpdate: 'tool_call_update', + toolCallId: 'kill-1', + status: 'completed', + name: 'kill_command_or_subagent', + rawInput: { task_ids: ['killed-task', 'kept-task'] }, + rawOutput: { + type: 'KillTask', + MultiResult: { + results: [ + { task_id: 'killed-task', outcome: 'killed' }, + { task_id: 'kept-task', outcome: 'not_killed' } + ] + } + } + } + }, + 1100 + ) + ) + const tasks = (await fixture.rig.rows()).flatMap((row) => + row.body.kind === 'message' ? row.body.blocks.filter(isBackgroundTaskBlock) : [] + ) + expect(tasks.map((task) => [task.taskId, task.state])).toEqual([ + ['killed-task', 'idle'], + ['kept-task', 'working'] + ]) + }) +}) diff --git a/src/main/acp/acp-tool-timeline.ts b/src/main/acp/acp-tool-timeline.ts index 1567a871740a..515e62c7cadc 100644 --- a/src/main/acp/acp-tool-timeline.ts +++ b/src/main/acp/acp-tool-timeline.ts @@ -167,13 +167,11 @@ export class AcpToolTimeline { const rows = this.journalItems() const suffix = `${thread ? `${providerTimelineKeyPart(thread)}/` : ''}${providerTimelineKeyPart(`tool:${callId}`)}` const row = rows.find((item) => { + if (item.body.kind !== 'tool-call' || item.body.callId !== callId) { + return false + } const identity = parseAgentJournalItemKey(item.itemId) - return ( - item.body.kind === 'tool-call' && - item.body.callId === callId && - identity?.provider === 'legacy' && - identity.recordId.endsWith(`:${suffix}`) - ) + return identity?.provider === 'legacy' && identity.recordId.endsWith(`:${suffix}`) }) return row?.body.kind === 'tool-call' ? { body: row.body, turn: acpJournalToolTurn(rows, callId) } From ebb07e6461a8eb0d23a54811ccaa571a7b34253d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 17:05:07 -0700 Subject: [PATCH 33/62] Keep the journal store under its line limit after the main merge --- .../native-chat/agent-session-journal/journal-store.ts | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/src/main/native-chat/agent-session-journal/journal-store.ts b/src/main/native-chat/agent-session-journal/journal-store.ts index c101c21fd556..19a8b61a62cd 100644 --- a/src/main/native-chat/agent-session-journal/journal-store.ts +++ b/src/main/native-chat/agent-session-journal/journal-store.ts @@ -69,7 +69,7 @@ import type { JournalEpochController } from './journal-epoch-controller' import { JournalWriteQueue } from './journal-write-queue' import { createJournalStoreCollaborators } from './journal-store-collaborators' import { journalStoreLoadedFields } from './journal-store-open' -import type { JournalItemAppender, JournalResolvedItem } from './journal-item-appender' +import type { JournalItemAppender } from './journal-item-appender' import type { JournalLifecycleBatchAppender } from './journal-lifecycle-batch-appender' import type { JournalStopMarks } from './journal-stop-marks' @@ -306,12 +306,8 @@ export class AgentSessionJournal { } /** An upsert whose row is chosen from the fold at its own turn in the queue; null writes nothing. */ - appendResolvedItem( - resolve: () => JournalResolvedItem | null, - options: JournalItemAppendOptions - ): Promise { - return this.itemAppender.appendResolved(resolve, options) - } + appendResolvedItem: JournalItemAppender['appendResolved'] = (resolve, options) => + this.itemAppender.appendResolved(resolve, options) appendTombstone( identity: AgentJournalItemIdentity, From 03b9f2f3d2b358d7ea3e64e0a0f680feda58f6b0 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 00:13:40 -0700 Subject: [PATCH 34/62] Drop the provider item reference, join index and identity schemes from the transition PR Nothing in production reaches the state they defended (an assembler that lost its memory while its child keeps streaming the same turn), and the stored Codex id was positional. The legacy identity scheme moves to the assembler PR with its first caller; the Codex scheme and any persisted reference wait for Codex to move onto the assembler. The Codex ordinal counter goes back to codex/, since no neutral code imports it. --- config/scripts/ci-shard-timings.json | 2 +- .../codex/codex-provider-timeline-identity.ts | 47 --- .../codex-structured-item-translation.test.ts | 28 +- .../codex-structured-item-translation.ts | 8 +- .../codex/codex-structured-journal-items.ts | 4 +- .../codex-structured-journal-settlement.ts | 4 +- ...-structured-journal-translation-restore.ts | 7 +- ...ctured-journal-translation-streams.test.ts | 4 +- ...ex-structured-journal-translation-turns.ts | 2 +- src/main/codex/codex-thread-item-identity.ts | 8 +- .../codex-turn-ordinals.test.ts} | 25 +- .../codex-turn-ordinals.ts} | 19 +- .../journal-item-fold.ts | 10 +- .../journal-provider-item-ref.test.ts | 75 ----- .../agent-session-journal/journal-reducer.ts | 3 - .../journal-render-item.ts | 1 - .../journal-row-builders.ts | 5 +- .../journal-row-schema.ts | 12 +- .../journal-store-contracts.ts | 5 +- .../agent-session-journal/journal-store.ts | 2 - .../provider-timeline-identity.ts | 138 -------- .../provider-timeline-joins.test.ts | 231 ------------- .../provider-timeline-joins.ts | 306 ------------------ .../provider-timeline-message-places.ts | 122 ------- .../structured-agent-session-event-sink.ts | 2 - ...ed-agent-session-journal-append-options.ts | 1 - src/shared/agent-session-journal-types.ts | 9 - ...n-provider-item-ref-downgrade.unit.test.ts | 127 -------- 28 files changed, 59 insertions(+), 1148 deletions(-) delete mode 100644 src/main/codex/codex-provider-timeline-identity.ts rename src/main/{native-chat/agent-session-timeline/provider-turn-message-ordinals.test.ts => codex/codex-turn-ordinals.test.ts} (64%) rename src/main/{native-chat/agent-session-timeline/provider-turn-message-ordinals.ts => codex/codex-turn-ordinals.ts} (88%) delete mode 100644 src/main/native-chat/agent-session-journal/journal-provider-item-ref.test.ts delete mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-identity.ts delete mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-joins.test.ts delete mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-joins.ts delete mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-message-places.ts delete mode 100644 tests/e2e/cross-version-wire/agent-session-provider-item-ref-downgrade.unit.test.ts diff --git a/config/scripts/ci-shard-timings.json b/config/scripts/ci-shard-timings.json index fb242a4788c8..73d0226943b8 100644 --- a/config/scripts/ci-shard-timings.json +++ b/config/scripts/ci-shard-timings.json @@ -1307,7 +1307,7 @@ "src/main/codex/codex-trust-grant-ledger.test.ts": 54, "src/main/codex/codex-trust-grant-main-thread-boundary.test.ts": 43, "src/main/codex/codex-trust-grant-telemetry.test.ts": 70, - "src/main/native-chat/agent-session-timeline/provider-turn-message-ordinals.test.ts": 96, + "src/main/codex/codex-turn-ordinals.test.ts": 96, "src/main/codex/codex-user-hook-trust-rebase-client.test.ts": 104, "src/main/codex/codex-user-hook-trust-moves.test.ts": 127, "src/main/codex/codex-wsl-hook-install-plan.test.ts": 51, diff --git a/src/main/codex/codex-provider-timeline-identity.ts b/src/main/codex/codex-provider-timeline-identity.ts deleted file mode 100644 index 7da2bbd33aae..000000000000 --- a/src/main/codex/codex-provider-timeline-identity.ts +++ /dev/null @@ -1,47 +0,0 @@ -// Codex's persisted identities, spelled for the shared timeline assembler. -// -// The same rows Codex writes today (`codexItemIdentity`, `codexTurnLifecycleIdentity`): a user or -// assistant message inside a turn keys by (thread, turn, message ordinal), which survives a resume -// because the ordinal counts messages only; anything else keys by its Codex item id; a turn keys by -// its Codex turn id. Codex ids name one conversation for good, so the namespace spells nothing. - -import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' -import type { ProviderTimelineIdentityScheme } from '../native-chat/agent-session-timeline/provider-timeline-identity' -import { - codexTurnLifecycleIdentity, - codexTurnUserItemId -} from './codex-structured-journal-translation-turns' - -export function createCodexProviderTimelineIdentityScheme(input: { - sessionId: string - primaryThreadId: () => string | null -}): ProviderTimelineIdentityScheme { - const turn: ProviderTimelineIdentityScheme['turn'] = (address) => - codexTurnLifecycleIdentity(input.sessionId, address.key.value) - return { - ordinalMessages: true, - messageSlot: (identity) => - identity.provider === 'codex' - ? { place: `${identity.threadId}\u001f${identity.turnId}`, ordinal: identity.ordinal } - : null, - turn, - turnId: (address) => address.key.value, - turnOpener: (address) => { - const thread = input.primaryThreadId() - return thread === null - ? agentJournalItemKey(turn(address)) - : codexTurnUserItemId(thread, address.key.value) - }, - item: (address) => { - const threadId = address.thread ?? input.primaryThreadId() ?? '' - return address.itemClass === 'message' && address.turn && address.messageOrdinal !== null - ? { - provider: 'codex', - threadId, - turnId: address.turn.value, - ordinal: address.messageOrdinal - } - : { provider: 'orca', clientMessageId: `codex-item:${threadId}:${address.key.value}` } - } - } -} diff --git a/src/main/codex/codex-structured-item-translation.test.ts b/src/main/codex/codex-structured-item-translation.test.ts index 29562552a02c..ad63d624f58b 100644 --- a/src/main/codex/codex-structured-item-translation.test.ts +++ b/src/main/codex/codex-structured-item-translation.test.ts @@ -11,9 +11,9 @@ import { codexJournalItem, codexMessageBlocks, codexStreamingJournalItem, - ProviderTurnMessageOrdinals, - MAX_PROVIDER_TURN_ORDINAL_BYTES, - MAX_PROVIDER_TURN_ORDINAL_ENTRIES, + CodexTurnOrdinals, + MAX_CODEX_TURN_ORDINAL_BYTES, + MAX_CODEX_TURN_ORDINAL_ENTRIES, isCodexMessageItemType, readCodexThreadItem, type CodexThreadItem @@ -59,7 +59,7 @@ const RESUMED_TURN: CodexThreadItem[] = [ ] function keysFor(items: CodexThreadItem[]): string[] { - const ordinals = new ProviderTurnMessageOrdinals() + const ordinals = new CodexTurnOrdinals() return items .filter((item) => isCodexMessageItemType(item.type)) .map((item) => @@ -71,15 +71,15 @@ function keysFor(items: CodexThreadItem[]): string[] { describe('codex turn ordinals', () => { it('bounds forgotten turn tombstones while retaining the recent window', () => { - const ordinals = new ProviderTurnMessageOrdinals() - const total = MAX_PROVIDER_TURN_ORDINAL_ENTRIES + 12 + const ordinals = new CodexTurnOrdinals() + const total = MAX_CODEX_TURN_ORDINAL_ENTRIES + 12 for (let index = 0; index < total; index += 1) { const turnId = `turn-${index}` expect(ordinals.ordinalFor('thread-many', turnId, 'item-0')).toBe(0) ordinals.forgetTurn('thread-many', turnId) } - expect(ordinals.forgottenTurnCount).toBe(MAX_PROVIDER_TURN_ORDINAL_ENTRIES) + expect(ordinals.forgottenTurnCount).toBe(MAX_CODEX_TURN_ORDINAL_ENTRIES) // The newest completed turn still keeps its counter for a late frame. expect(ordinals.ordinalFor('thread-many', `turn-${total - 1}`, 'item-late')).toBe(1) // The oldest turn was deterministically evicted and starts a fresh key. @@ -87,7 +87,7 @@ describe('codex turn ordinals', () => { }) it('releases a forgotten turn without ever reusing an ordinal it assigned', () => { - const ordinals = new ProviderTurnMessageOrdinals() + const ordinals = new CodexTurnOrdinals() expect(ordinals.ordinalFor('thread-1', 'turn-1', 'item-1')).toBe(0) expect(ordinals.ordinalFor('thread-1', 'turn-1', 'item-2')).toBe(1) @@ -102,12 +102,12 @@ describe('codex turn ordinals', () => { }) it('bounds aggregate provider identifier bytes retained by one active turn', () => { - const ordinals = new ProviderTurnMessageOrdinals() + const ordinals = new CodexTurnOrdinals() for (let index = 0; index < 3_000; index += 1) { ordinals.ordinalFor('thread', 'turn', `${index}:${'x'.repeat(512)}`) } - expect(ordinals.bytes).toBeLessThanOrEqual(MAX_PROVIDER_TURN_ORDINAL_BYTES) + expect(ordinals.bytes).toBeLessThanOrEqual(MAX_CODEX_TURN_ORDINAL_BYTES) }) }) @@ -117,7 +117,7 @@ describe('codex item identity', () => { }) it('numbers messages 0,1,2 on both sides — the projection skips the dropped command', () => { - const ordinals = new ProviderTurnMessageOrdinals() + const ordinals = new CodexTurnOrdinals() const live = LIVE_TURN.map((item) => codexItemIdentity({ threadId: THREAD_ID, turnId: TURN_ID, item, ordinals }) ) @@ -136,7 +136,7 @@ describe('codex item identity', () => { }) it('assigns an ordinal once and reuses it, so a delta and its completion upsert one row', () => { - const ordinals = new ProviderTurnMessageOrdinals() + const ordinals = new CodexTurnOrdinals() ordinals.ordinalFor(THREAD_ID, TURN_ID, 'item-0') expect(ordinals.ordinalFor(THREAD_ID, TURN_ID, 'item-1')).toBe(1) @@ -144,14 +144,14 @@ describe('codex item identity', () => { }) it('restarts numbering per turn', () => { - const ordinals = new ProviderTurnMessageOrdinals() + const ordinals = new CodexTurnOrdinals() ordinals.ordinalFor(THREAD_ID, TURN_ID, 'item-0') expect(ordinals.ordinalFor(THREAD_ID, 'turn-2', 'item-1')).toBe(0) }) it('keys a non-message item and a turnless message in the orca namespace', () => { - const ordinals = new ProviderTurnMessageOrdinals() + const ordinals = new CodexTurnOrdinals() const command = codexItemIdentity({ threadId: THREAD_ID, turnId: TURN_ID, diff --git a/src/main/codex/codex-structured-item-translation.ts b/src/main/codex/codex-structured-item-translation.ts index 3f4229cb6e54..25b821d90d9f 100644 --- a/src/main/codex/codex-structured-item-translation.ts +++ b/src/main/codex/codex-structured-item-translation.ts @@ -28,10 +28,10 @@ export { type CodexThreadItem } from './codex-thread-item-identity' export { - ProviderTurnMessageOrdinals, - MAX_PROVIDER_TURN_ORDINAL_BYTES, - MAX_PROVIDER_TURN_ORDINAL_ENTRIES -} from '../native-chat/agent-session-timeline/provider-turn-message-ordinals' + CodexTurnOrdinals, + MAX_CODEX_TURN_ORDINAL_BYTES, + MAX_CODEX_TURN_ORDINAL_ENTRIES +} from './codex-turn-ordinals' // Codex thread items → journal item bodies. diff --git a/src/main/codex/codex-structured-journal-items.ts b/src/main/codex/codex-structured-journal-items.ts index 22bb16c8ef08..ec6ac67df5d4 100644 --- a/src/main/codex/codex-structured-journal-items.ts +++ b/src/main/codex/codex-structured-journal-items.ts @@ -7,7 +7,7 @@ import { requiresTerminalSettlement } from '../native-chat/agent-session-journal import { codexItemIdentity, codexJournalItem, - ProviderTurnMessageOrdinals, + CodexTurnOrdinals, readCodexThreadItem, type CodexThreadItem } from './codex-structured-item-translation' @@ -35,7 +35,7 @@ import { readCodexDispatchEcho } from './codex-structured-dispatch-echo' import type { CodexRowAttribution } from './codex-subagent-linkage' export class CodexJournalItems { - readonly ordinals = new ProviderTurnMessageOrdinals() + readonly ordinals = new CodexTurnOrdinals() readonly activeItems = new Map() readonly streams private readonly identities = new Map() diff --git a/src/main/codex/codex-structured-journal-settlement.ts b/src/main/codex/codex-structured-journal-settlement.ts index e7d0a376eca8..131de9824aae 100644 --- a/src/main/codex/codex-structured-journal-settlement.ts +++ b/src/main/codex/codex-structured-journal-settlement.ts @@ -17,7 +17,7 @@ import { codexJournalItem, codexStreamingJournalItem, type CodexThreadItem, - type ProviderTurnMessageOrdinals + type CodexTurnOrdinals } from './codex-structured-item-translation' import type { CodexStructuredItemStreams } from './codex-structured-item-streams' import type { CodexHelperName } from './codex-collab-agent-item-translation' @@ -56,7 +56,7 @@ export function settleCodexJournalSession(input: { pendingPrompts: ReadonlyMap currentTurnIds: ReadonlyMap> primaryThreadId: string | null - ordinals: ProviderTurnMessageOrdinals + ordinals: CodexTurnOrdinals /** Terminal lifecycle for a turn the provider left running when it ended; null for a turn a * conversation command claimed, whose record the host settles. */ settledTurnLifecycle: (threadId: string, turnId: string) => AgentJournalTurnLifecycle | null diff --git a/src/main/codex/codex-structured-journal-translation-restore.ts b/src/main/codex/codex-structured-journal-translation-restore.ts index a3b4652f366e..9f7efc15ae4c 100644 --- a/src/main/codex/codex-structured-journal-translation-restore.ts +++ b/src/main/codex/codex-structured-journal-translation-restore.ts @@ -1,8 +1,5 @@ import type { AgentJournalTurnLifecycle } from '../../shared/agent-session-journal-types' -import { - readCodexThreadItem, - type ProviderTurnMessageOrdinals -} from './codex-structured-item-translation' +import { readCodexThreadItem, type CodexTurnOrdinals } from './codex-structured-item-translation' import type { CodexJournalCompactions } from './codex-structured-journal-compactions' import type { CodexJournalItems } from './codex-structured-journal-items' import { @@ -58,7 +55,7 @@ export function restoreCodexJournalThread(input: { threadId: string thread: Record currentTurnIds: Map> - ordinals: ProviderTurnMessageOrdinals + ordinals: CodexTurnOrdinals handleItem: (event: CodexHistoryItemEvent) => CodexJournalTranslationAdmission /** Absent when the caller has no session identity to key lifecycle rows by. */ restoreTurnLifecycle?: ( diff --git a/src/main/codex/codex-structured-journal-translation-streams.test.ts b/src/main/codex/codex-structured-journal-translation-streams.test.ts index 7bb88edc3a6f..54a2c017aa05 100644 --- a/src/main/codex/codex-structured-journal-translation-streams.test.ts +++ b/src/main/codex/codex-structured-journal-translation-streams.test.ts @@ -6,7 +6,7 @@ import type { import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' import { projectStructuredItemsToNativeChat } from '../../shared/structured-agent-session-projection' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' -import { ProviderTurnMessageOrdinals } from './codex-structured-item-translation' +import { CodexTurnOrdinals } from './codex-structured-item-translation' import { createCodexJournalTranslator, MAX_CODEX_GENERIC_BOOKKEEPING_ENTRIES, @@ -164,7 +164,7 @@ describe('codex journal translation', () => { }) it('releases a turn ordinal map when the turn completes', () => { - const spy = vi.spyOn(ProviderTurnMessageOrdinals.prototype, 'forgetTurn') + const spy = vi.spyOn(CodexTurnOrdinals.prototype, 'forgetTurn') try { const { translator } = translatorWith() translator.handle(TURN_STARTED) diff --git a/src/main/codex/codex-structured-journal-translation-turns.ts b/src/main/codex/codex-structured-journal-translation-turns.ts index dc955f70385c..33f138c6578d 100644 --- a/src/main/codex/codex-structured-journal-translation-turns.ts +++ b/src/main/codex/codex-structured-journal-translation-turns.ts @@ -8,7 +8,7 @@ import { } from '../../shared/agent-session-journal-types' import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' import { agentJournalTurnBody } from '../../shared/agent-session-turn-record' -import { CODEX_USER_MESSAGE_ORDINAL } from './codex-thread-item-identity' +import { CODEX_USER_MESSAGE_ORDINAL } from './codex-turn-ordinals' import type { StructuredAgentSessionEventSink, StructuredAgentSessionSinkAdmission diff --git a/src/main/codex/codex-thread-item-identity.ts b/src/main/codex/codex-thread-item-identity.ts index 663b497f7731..0488e5c00e98 100644 --- a/src/main/codex/codex-thread-item-identity.ts +++ b/src/main/codex/codex-thread-item-identity.ts @@ -1,5 +1,5 @@ import type { AgentJournalItemIdentity } from '../../shared/agent-session-journal-types' -import type { ProviderTurnMessageOrdinals } from '../native-chat/agent-session-timeline/provider-turn-message-ordinals' +import type { CodexTurnOrdinals } from './codex-turn-ordinals' // Codex thread items → durable journal identities. // @@ -15,10 +15,6 @@ import type { ProviderTurnMessageOrdinals } from '../native-chat/agent-session-t // including ones this build does not model — is skipped identically on both // sides, which is what makes the key survive a Codex release that adds one. -/** Codex records the user message first in a turn, so a restored submission is - * ordinal 0 of `(threadId, turnId)`. */ -export const CODEX_USER_MESSAGE_ORDINAL = 0 - /** Only these carry a durable `(threadId, turnId, ordinal)` identity. */ const CODEX_MESSAGE_ITEM_TYPES = new Set(['userMessage', 'agentMessage']) @@ -54,7 +50,7 @@ export function codexItemIdentity(input: { threadId: string turnId: string | null item: CodexThreadItem - ordinals: ProviderTurnMessageOrdinals + ordinals: CodexTurnOrdinals }): AgentJournalItemIdentity { const { item, turnId } = input if (turnId && isCodexMessageItemType(item.type)) { diff --git a/src/main/native-chat/agent-session-timeline/provider-turn-message-ordinals.test.ts b/src/main/codex/codex-turn-ordinals.test.ts similarity index 64% rename from src/main/native-chat/agent-session-timeline/provider-turn-message-ordinals.test.ts rename to src/main/codex/codex-turn-ordinals.test.ts index b070b449f264..5ae12011a855 100644 --- a/src/main/native-chat/agent-session-timeline/provider-turn-message-ordinals.test.ts +++ b/src/main/codex/codex-turn-ordinals.test.ts @@ -1,17 +1,16 @@ import { expect, it } from 'vitest' import { - ProviderTurnMessageOrdinals, - MAX_PROVIDER_TURN_ORDINAL_BYTES, - MAX_PROVIDER_TURN_ORDINAL_ENTRIES -} from './provider-turn-message-ordinals' + CodexTurnOrdinals, + MAX_CODEX_TURN_ORDINAL_BYTES, + MAX_CODEX_TURN_ORDINAL_ENTRIES +} from './codex-turn-ordinals' it('does not rescan the forgotten window for each new streamed item', () => { - const ordinals = new ProviderTurnMessageOrdinals() - for (let index = 0; index < MAX_PROVIDER_TURN_ORDINAL_ENTRIES; index += 1) { + const ordinals = new CodexTurnOrdinals() + for (let index = 0; index < MAX_CODEX_TURN_ORDINAL_ENTRIES; index += 1) { ordinals.ordinalFor('thread', String(index), 'item') ordinals.forgetTurn('thread', String(index)) } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this test counts reads of the class's private turn map, whose entries carry `active`. const turns = (ordinals as unknown as { turns: Map }).turns let reads = 0 for (const turn of turns.values()) { @@ -30,30 +29,30 @@ it('does not rescan the forgotten window for each new streamed item', () => { expect(ordinals.ordinalFor('thread', 'live', String(index))).toBe(index) } expect(reads).toBe(0) - expect(ordinals.forgottenTurnCount).toBe(MAX_PROVIDER_TURN_ORDINAL_ENTRIES) + expect(ordinals.forgottenTurnCount).toBe(MAX_CODEX_TURN_ORDINAL_ENTRIES) }) it('retains ordinal continuity on late reactivation and evicts oldest forgotten turns', () => { - const ordinals = new ProviderTurnMessageOrdinals() + const ordinals = new CodexTurnOrdinals() expect(ordinals.ordinalFor('t', 'first', 'a')).toBe(0) ordinals.forgetTurn('t', 'first') expect(ordinals.ordinalFor('t', 'first', 'b')).toBe(1) expect(ordinals.forgottenTurnCount).toBe(0) ordinals.forgetTurn('t', 'first') - for (let index = 0; index < MAX_PROVIDER_TURN_ORDINAL_ENTRIES; index += 1) { + for (let index = 0; index < MAX_CODEX_TURN_ORDINAL_ENTRIES; index += 1) { ordinals.ordinalFor('t', String(index), 'a') ordinals.forgetTurn('t', String(index)) } - expect(ordinals.forgottenTurnCount).toBe(MAX_PROVIDER_TURN_ORDINAL_ENTRIES) + expect(ordinals.forgottenTurnCount).toBe(MAX_CODEX_TURN_ORDINAL_ENTRIES) expect(ordinals.ordinalFor('t', 'first', 'c')).toBe(0) }) it('keeps byte eviction bounded for active and forgotten turns', () => { - const ordinals = new ProviderTurnMessageOrdinals() + const ordinals = new CodexTurnOrdinals() for (let index = 0; index < 4000; index += 1) { ordinals.ordinalFor('thread', 'live', `${index}-${'x'.repeat(240)}`) } - expect(ordinals.bytes).toBeLessThanOrEqual(MAX_PROVIDER_TURN_ORDINAL_BYTES) + expect(ordinals.bytes).toBeLessThanOrEqual(MAX_CODEX_TURN_ORDINAL_BYTES) ordinals.forgetTurn('thread', 'live') expect(ordinals.bytes).toBeLessThan(100) expect(ordinals.forgottenTurnCount).toBe(1) diff --git a/src/main/native-chat/agent-session-timeline/provider-turn-message-ordinals.ts b/src/main/codex/codex-turn-ordinals.ts similarity index 88% rename from src/main/native-chat/agent-session-timeline/provider-turn-message-ordinals.ts rename to src/main/codex/codex-turn-ordinals.ts index 9405ff9281ef..7e21b900d965 100644 --- a/src/main/native-chat/agent-session-timeline/provider-turn-message-ordinals.ts +++ b/src/main/codex/codex-turn-ordinals.ts @@ -1,11 +1,18 @@ -import { boundPayload, digestPayload } from '../agent-session-journal/journal-payload-bounds' +import { + boundPayload, + digestPayload +} from '../native-chat/agent-session-journal/journal-payload-bounds' + +/** Codex records the user message first in a turn, so a restored submission is + * ordinal 0 of `(threadId, turnId)`. */ +export const CODEX_USER_MESSAGE_ORDINAL = 0 /** Maximum forgotten turn keys retained for late-frame reconciliation. */ -export const MAX_PROVIDER_TURN_ORDINAL_ENTRIES = 256 -export const MAX_PROVIDER_TURN_ORDINAL_BYTES = 512 * 1024 +export const MAX_CODEX_TURN_ORDINAL_ENTRIES = 256 +export const MAX_CODEX_TURN_ORDINAL_BYTES = 512 * 1024 /** Assigns stable message ordinals while retaining a bounded late-frame window. */ -export class ProviderTurnMessageOrdinals { +export class CodexTurnOrdinals { private readonly turns = new Map< string, { assigned: Map; next: number; active: boolean } @@ -39,7 +46,7 @@ export class ProviderTurnMessageOrdinals { } private trimForgotten(): void { - while (this.forgottenTurnCount > MAX_PROVIDER_TURN_ORDINAL_ENTRIES) { + while (this.forgottenTurnCount > MAX_CODEX_TURN_ORDINAL_ENTRIES) { const oldest = this.forgottenTurns.values().next().value if (!oldest) { break @@ -60,7 +67,7 @@ export class ProviderTurnMessageOrdinals { private trimBytes(currentTurnKey: string): void { this.trimForgotten() - while (this.retainedBytes > MAX_PROVIDER_TURN_ORDINAL_BYTES) { + while (this.retainedBytes > MAX_CODEX_TURN_ORDINAL_BYTES) { const forgotten = this.forgottenTurns.values().next().value const oldest = forgotten ?? this.turns.keys().next().value if (typeof oldest !== 'string') { diff --git a/src/main/native-chat/agent-session-journal/journal-item-fold.ts b/src/main/native-chat/agent-session-journal/journal-item-fold.ts index aa1a02e4a70a..10aee4a99c41 100644 --- a/src/main/native-chat/agent-session-journal/journal-item-fold.ts +++ b/src/main/native-chat/agent-session-journal/journal-item-fold.ts @@ -39,9 +39,6 @@ export function upsertJournalItem( return } state.derivedTurnScope.observe(itemId, isRootAgentJournalItem(next), existing?.body, next.body) - if (next.providerItemRef !== undefined && existing?.providerItemRef === undefined) { - state.providerItemRefs.set(next.providerItemRef, itemId) - } if (!existing) { state.items.set(itemId, next) state.itemFences.set(itemId, fence) @@ -68,9 +65,7 @@ export function upsertJournalItem( sequence: existing.sequence, ...(existing.sequenceIndex !== undefined ? { sequenceIndex: existing.sequenceIndex } : {}), observedAt: existing.observedAt, - turnScope: existing.turnScope ?? next.turnScope, - // The creating write's, as the scope is: a revision never re-points the row at another item. - ...(existing.providerItemRef === undefined ? {} : { providerItemRef: existing.providerItemRef }) + turnScope: existing.turnScope ?? next.turnScope }) state.tombstones.delete(itemId) } @@ -97,9 +92,6 @@ export function removeJournalItem( ) } state.tombstones.set(itemId, revision) - if (existing?.providerItemRef !== undefined) { - state.providerItemRefs.delete(existing.providerItemRef) - } state.items.delete(itemId) state.itemFences.delete(itemId) } diff --git a/src/main/native-chat/agent-session-journal/journal-provider-item-ref.test.ts b/src/main/native-chat/agent-session-journal/journal-provider-item-ref.test.ts deleted file mode 100644 index d80d5a539965..000000000000 --- a/src/main/native-chat/agent-session-journal/journal-provider-item-ref.test.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' -import type { AgentJournalItemIdentity } from '../../../shared/agent-session-journal-types' -import { createTrackedJournalOpener } from './journal-host-database-test-support' - -const journals = createTrackedJournalOpener() -const roots: string[] = [] - -afterEach(async () => { - await journals.closeAll() - for (const root of roots.splice(0)) { - await rm(root, { recursive: true, force: true }) - } -}) - -async function open(root?: string) { - const directory = root ?? (await mkdtemp(join(tmpdir(), 'orca-provider-item-ref-'))) - if (!root) { - roots.push(directory) - } - const journal = await journals.open({ - identity: { - sessionId: 'session-ref', - workspaceId: 'workspace-1', - hostId: 'local', - agent: 'codex', - providerHandle: { kind: 'codex', threadId: 'root' } - }, - stateDirectory: directory, - now: () => 1_000 - }) - return { journal, directory } -} - -const row: AgentJournalItemIdentity = { - provider: 'codex', - threadId: 'root', - turnId: 't1', - ordinal: 0 -} -const text = (value: string) => ({ - kind: 'message' as const, - role: 'assistant' as const, - blocks: [{ type: 'text' as const, text: value }] -}) -const scope = { kind: 'turn', turnItemId: 'turn:t1' } as const - -describe('a row’s provider item reference', () => { - it('is the creating write’s, kept by revisions and indexed for its producer', async () => { - const { journal } = await open() - await journal.appendItem(row, text('a'), { fence: 1, turnScope: scope, providerItemRef: 'm0' }) - await journal.appendItem(row, text('ab'), { fence: 1, turnScope: scope, providerItemRef: 'm9' }) - await journal.appendItem(row, text('abc'), { fence: 1, turnScope: scope }) - - expect(journal.item('codex:root:t1:0')).toMatchObject({ - providerItemRef: 'm0', - body: text('abc') - }) - expect(journal.itemIdForProviderItemRef('m0')).toBe('codex:root:t1:0') - expect(journal.itemIdForProviderItemRef('m9')).toBeNull() - }) - - it('survives a reopen, and leaves the index with its row', async () => { - const { journal, directory } = await open() - await journal.appendItem(row, text('a'), { fence: 1, turnScope: scope, providerItemRef: 'm0' }) - await journals.closeAll() - - const { journal: reopened } = await open(directory) - expect(reopened.itemIdForProviderItemRef('m0')).toBe('codex:root:t1:0') - await reopened.appendTombstone(row, { fence: 1 }) - expect(reopened.itemIdForProviderItemRef('m0')).toBeNull() - }) -}) diff --git a/src/main/native-chat/agent-session-journal/journal-reducer.ts b/src/main/native-chat/agent-session-journal/journal-reducer.ts index c4e892de8038..e2d1b1ef3ca3 100644 --- a/src/main/native-chat/agent-session-journal/journal-reducer.ts +++ b/src/main/native-chat/agent-session-journal/journal-reducer.ts @@ -56,8 +56,6 @@ export type JournalReducerState = { /** Provider item id → the submission slot that adopted it. Stops an accepted * echo from appending a second copy of the user's own message. */ aliases: Map - /** `providerItemRef` → the row that carries it, so a producer finds that row again in O(1). */ - providerItemRefs: Map appliedSettlementIds: Set /** Scope for rows stored without one; rebuilt by replay, never persisted. */ derivedTurnScope: JournalDerivedTurnScope @@ -82,7 +80,6 @@ export function createJournalReducerState(sessionId: string, epoch: string): Jou submissions: new Map(), receipts: new Map(), aliases: new Map(), - providerItemRefs: new Map(), appliedSettlementIds: new Set(), derivedTurnScope: new JournalDerivedTurnScope(), latestPersonTurnSequence: 0, diff --git a/src/main/native-chat/agent-session-journal/journal-render-item.ts b/src/main/native-chat/agent-session-journal/journal-render-item.ts index 76a7abf054ec..fd58b2c25e4e 100644 --- a/src/main/native-chat/agent-session-journal/journal-render-item.ts +++ b/src/main/native-chat/agent-session-journal/journal-render-item.ts @@ -35,7 +35,6 @@ export function journalRenderItem( ...(row.recovered ? { recoveredAt: row.ts } : {}), ...(row.recovered ? { recovered: row.recovered } : {}), turnScope, - ...(row.providerItemRef === undefined ? {} : { providerItemRef: row.providerItemRef }), ...agentJournalLinkageFields(producer) } } diff --git a/src/main/native-chat/agent-session-journal/journal-row-builders.ts b/src/main/native-chat/agent-session-journal/journal-row-builders.ts index 16bf1960e400..0865df3a41fa 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-builders.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-builders.ts @@ -49,8 +49,7 @@ export function journalItemRowBuilder( ts: options.observedAt ?? ts, recovered: options.recovered, linkage: options, - turnScope: options.turnScope, - ...(options.providerItemRef === undefined ? {} : { providerItemRef: options.providerItemRef }) + turnScope: options.turnScope }) } @@ -250,7 +249,6 @@ export function buildJournalItemRow(input: { recovered?: true linkage?: AgentJournalProducerLinkage turnScope: AgentJournalTurnScope - providerItemRef?: string }): JournalItemRow { const itemId = agentJournalItemKey(input.identity) const resolved = input.state.aliases.get(itemId) ?? itemId @@ -270,7 +268,6 @@ export function buildJournalItemRow(input: { ...journalRowBase(input.state.epoch, input.seq, input.fence, input.ts, [body]), ...(input.recovered ? { recovered: input.recovered } : {}), turnScope: input.turnScope, - ...(input.providerItemRef === undefined ? {} : { providerItemRef: input.providerItemRef }), ...agentJournalLinkageFields(input.linkage) } } diff --git a/src/main/native-chat/agent-session-journal/journal-row-schema.ts b/src/main/native-chat/agent-session-journal/journal-row-schema.ts index e8d1746ceb8d..9276b1a8c05f 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-schema.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-schema.ts @@ -20,7 +20,6 @@ import { type AgentJournalItemBody, type AgentJournalMessageItem, type AgentJournalProducerLinkage, - type AgentJournalProviderItemRef, type AgentJournalTurnScope, type AgentSessionProviderHandle } from '../../../shared/agent-session-journal-types' @@ -52,9 +51,6 @@ type JournalRowBase = AgentJournalProducerLinkage & { * for the reason linkage does. Absent on rows from hosts that predate it: the reducer * derives one for them on read. */ turnScope?: AgentJournalTurnScope - /** On an item row: the provider's reference for the item it creates. Rides the base for the - * reason linkage does. */ - providerItemRef?: AgentJournalProviderItemRef } /** First row of every epoch: binds the epoch to a provider handle and records why it opened. */ @@ -253,13 +249,7 @@ export function parseJournalRow(line: string): JournalRowParse { * survives is a real one: the reader scopes on PRESENCE, so `''` or a * non-string left in place would hide the row from its own author for good. */ function dropUnusableProducerLinkage(record: Record): void { - for (const field of [ - 'agentId', - 'parentAgentId', - 'providerParentRef', - 'producerKind', - 'providerItemRef' - ]) { + for (const field of ['agentId', 'parentAgentId', 'providerParentRef', 'producerKind']) { const value = record[field] if (value !== undefined && (typeof value !== 'string' || value.length === 0)) { delete record[field] diff --git a/src/main/native-chat/agent-session-journal/journal-store-contracts.ts b/src/main/native-chat/agent-session-journal/journal-store-contracts.ts index b5a960360368..a85b3adc3172 100644 --- a/src/main/native-chat/agent-session-journal/journal-store-contracts.ts +++ b/src/main/native-chat/agent-session-journal/journal-store-contracts.ts @@ -62,10 +62,7 @@ export type JournalItemLinkageVisitor = ( itemId: string, sequence: number, body: AgentJournalItemBody, - attribution: AgentJournalProducerLinkage & { - turnScope?: AgentJournalTurnScope - providerItemRef?: string - } + attribution: AgentJournalProducerLinkage & { turnScope?: AgentJournalTurnScope } ) => void export type JournalLifecycleBatchInput = { diff --git a/src/main/native-chat/agent-session-journal/journal-store.ts b/src/main/native-chat/agent-session-journal/journal-store.ts index 19a8b61a62cd..d12257447cfd 100644 --- a/src/main/native-chat/agent-session-journal/journal-store.ts +++ b/src/main/native-chat/agent-session-journal/journal-store.ts @@ -230,8 +230,6 @@ export class AgentSessionJournal { /** One reduced item with its attribution, for a writer that needs the turn a row joined. */ item = (itemId: string): AgentJournalRenderItem | null => this.state.items.get(itemId) ?? null - /** The row that carries this `providerItemRef`, if any. */ - itemIdForProviderItemRef = (ref: string) => this.state.providerItemRefs.get(ref) ?? null /** Visits reduced items with the producer that wrote each, for a producer re-deriving what an * earlier run of this session left. */ diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-identity.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-identity.ts deleted file mode 100644 index 77286d029055..000000000000 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-identity.ts +++ /dev/null @@ -1,138 +0,0 @@ -// How the assembler's addresses become persisted journal identities. -// -// The assembler decides WHICH rows are the same row: it resolves every provider join to an -// address — the provider session's namespace, the key, the thread and turn the item belongs to, -// the item's class, and its place among its turn's messages — or mints a key when the provider -// names nothing. A scheme only spells an address as an `AgentJournalItemIdentity`. It must be pure, -// and it is the one place a provider's persisted identity shape lives, so moving a provider to a -// new identity arm is a change here. - -import type { - AgentJournalItemBody, - AgentJournalItemIdentity, - AgentType -} from '../../../shared/agent-session-journal-types' -import { boundPayload, digestPayload } from '../agent-session-journal/journal-payload-bounds' - -/** A key the provider vouched for, or one the assembler minted because it named nothing. - * A minted value is unique per acquisition, so it never needs a namespace. */ -export type ProviderTimelineKey = { source: 'provider' | 'minted'; value: string } - -export type ProviderTimelineTurnAddress = { - /** The provider session whose ids the key belongs to. */ - namespace: string - key: ProviderTimelineKey -} - -/** Which family an item belongs to; families never share a key space. Streamed text and full - * item snapshots are one family, so a provider-named message is one row however it arrives. */ -export type ProviderTimelineItemFamily = 'item' | 'request' | 'frame' - -/** What kind of thing an item is, derived from what it carries: `message` is user or assistant - * prose; every other class is its row kind (`reasoning` text included). */ -export type ProviderTimelineItemClass = - | 'message' - | 'reasoning' - | Exclude - | 'frame' - -export type ProviderTimelineItemAddress = { - namespace: string - family: ProviderTimelineItemFamily - key: ProviderTimelineKey - /** The provider thread it came from; null when the provider named none. */ - thread: string | null - /** The provider turn it belongs to; null for a row outside any turn. */ - turn: ProviderTimelineKey | null - itemClass: ProviderTimelineItemClass - /** Its place among its (thread, turn)'s `message` items, by first write; null for any other, and - * for every item of a scheme without `ordinalMessages`. */ - messageOrdinal: number | null - /** 1 for the first request under a key; a key reused after it settled is the next one. */ - incarnation: number -} - -export type ProviderTimelineMessageSlot = { place: string; ordinal: number } - -export type ProviderTimelineIdentityScheme = { - /** Messages inside a turn are keyed by their place among the turn's messages, not by their key. - * Such an identity must spell only (thread, turn, ordinal), and its row carries the key as - * `providerItemRef`, since nothing else could find it again. */ - ordinalMessages: boolean - /** Reads an ordinal message identity back: its (thread, turn) place, spelled by the scheme, and - * its ordinal; null for any other identity. Required with `ordinalMessages`. */ - messageSlot?(identity: AgentJournalItemIdentity): ProviderTimelineMessageSlot | null - turn(address: ProviderTimelineTurnAddress): AgentJournalItemIdentity - /** The id the turn row carries and a client's Stop names. */ - turnId(address: ProviderTimelineTurnAddress): string - /** The user item a turn names when no send of Orca's opened it; absent ⇒ the turn row itself. */ - turnOpener?(address: ProviderTimelineTurnAddress): string - /** With a null `messageOrdinal` it must spell only (namespace, family, key, thread, incarnation), - * so a row is found again from its key alone. */ - item(address: ProviderTimelineItemAddress): AgentJournalItemIdentity -} - -export function providerTimelineItemClass(body: AgentJournalItemBody): ProviderTimelineItemClass { - if (body.kind !== 'message') { - return body.kind - } - return body.role === 'user' || body.role === 'assistant' ? 'message' : 'reasoning' -} - -const MAX_KEY_PART_BYTES = 256 - -/** A provider id as a bounded identity part: escaped so `:` cannot forge another part, and - * digest-suffixed when long so two long ids never share a prefix-only spelling. */ -export function providerTimelineKeyPart(value: string): string { - const encoded = encodeURIComponent(value) - if (Buffer.byteLength(encoded, 'utf8') <= MAX_KEY_PART_BYTES) { - return encoded - } - const suffix = `#${digestPayload(value).slice(0, 24)}` - const head = boundPayload(encoded, { - inlineHeadBytes: MAX_KEY_PART_BYTES - Buffer.byteLength(suffix, 'utf8') - }).head - return `${head}${suffix}` -} - -/** A key as one bounded string inside its namespace (and thread, for a per-thread key). */ -export function spellProviderTimelineKey( - namespace: string, - key: ProviderTimelineKey, - thread: string | null = null -): string { - // A provider's item ids are its own per thread, so a subagent thread's ids are spelled apart. - const scope = thread === null ? '' : `${providerTimelineKeyPart(thread)}/` - return key.source === 'provider' - ? `p:${providerTimelineKeyPart(namespace)}:${scope}${providerTimelineKeyPart(key.value)}` - : `m:${providerTimelineKeyPart(key.value)}` -} - -/** - * The scheme for a provider with no identity arm of its own: the existing `legacy` arm, so no row - * shape changes. Turn rows keep the `turn-lifecycle:` record prefix the other lanes write. - * Provider keys are spelled inside their namespace, and apart from minted ones. - */ -export function createLegacyProviderTimelineIdentityScheme(input: { - agent: AgentType - sessionId: string -}): ProviderTimelineIdentityScheme { - const identity = (recordId: string): AgentJournalItemIdentity => ({ - provider: 'legacy', - agent: input.agent, - sessionId: input.sessionId, - recordId - }) - return { - ordinalMessages: false, - turn: (address) => - identity(`turn-lifecycle:${spellProviderTimelineKey(address.namespace, address.key)}`), - turnId: (address) => spellProviderTimelineKey(address.namespace, address.key), - item: (address) => - identity( - `${address.family}:${spellProviderTimelineKey(address.namespace, address.key, address.family === 'request' ? null : address.thread)}${ - address.incarnation > 1 ? `#${address.incarnation}` : '' - }` - ) - } -} diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-joins.test.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-joins.test.ts deleted file mode 100644 index 6a3f1e8d9d3f..000000000000 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-joins.test.ts +++ /dev/null @@ -1,231 +0,0 @@ -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' -import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' -import { - AGENT_JOURNAL_THREAD_SCOPE, - type AgentJournalApprovalItem, - type AgentJournalItemIdentity -} from '../../../shared/agent-session-journal-types' -import { createCodexProviderTimelineIdentityScheme } from '../../codex/codex-provider-timeline-identity' -import { createTrackedJournalOpener } from '../agent-session-journal/journal-host-database-test-support' -import type { AgentSessionJournal } from '../agent-session-journal/journal-store' -import { createLegacyProviderTimelineIdentityScheme } from './provider-timeline-identity' -import { ProviderTimelineJoins, type ProviderTimelineItemJoin } from './provider-timeline-joins' - -const journals = createTrackedJournalOpener() -const roots: string[] = [] - -afterEach(async () => { - await journals.closeAll() - for (const root of roots.splice(0)) { - await rm(root, { recursive: true, force: true }) - } -}) - -async function openJournal(): Promise { - const root = await mkdtemp(join(tmpdir(), 'orca-provider-timeline-joins-')) - roots.push(root) - return journals.open({ - identity: { - sessionId: 'session-joins', - workspaceId: 'workspace-1', - hostId: 'local', - agent: 'codex', - providerHandle: { kind: 'codex', threadId: 'root' } - }, - stateDirectory: root, - now: () => 1_000 - }) -} - -const codexJoins = () => - new ProviderTimelineJoins({ - scheme: createCodexProviderTimelineIdentityScheme({ - sessionId: 'session-joins', - primaryThreadId: () => 'root' - }), - generation: 'gen-1', - namespace: 'ns' - }) - -const message = (id: string): ProviderTimelineItemJoin => ({ - family: 'item', - key: { source: 'provider', value: id }, - thread: 'root' -}) - -const inTurn = (turn: string) => ({ - thread: 'root', - turn: { source: 'provider', value: turn } as const, - scope: { kind: 'turn', turnItemId: `turn:${turn}` } as const -}) - -const codexSlot = (turn: string, ordinal: number, threadId = 'root'): AgentJournalItemIdentity => ({ - provider: 'codex', - threadId, - turnId: turn, - ordinal -}) - -async function write( - journal: AgentSessionJournal, - row: { identity: AgentJournalItemIdentity; ref?: string }, - text: string -) { - await journal.appendItem( - row.identity, - { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text }] }, - { - fence: 1, - turnScope: { kind: 'turn', turnItemId: 'turn:t1' }, - ...(row.ref === undefined ? {} : { providerItemRef: row.ref }) - } - ) -} - -describe('provider timeline joins', () => { - it('finds an ordinal message again from the reference its row carries, after a restart', async () => { - const journal = await openJournal() - const before = codexJoins() - const m0 = before.place(message('m0'), 'message', inTurn('t1'), journal) - await write(journal, m0, 'first') - const m1 = before.place(message('m1'), 'message', inTurn('t1'), journal) - await write(journal, m1, 'second') - expect([m0.itemId, m1.itemId]).toEqual(['codex:root:t1:0', 'codex:root:t1:1']) - - const after = codexJoins() - expect(after.find(message('m1'), journal)).toMatchObject({ - itemId: 'codex:root:t1:1', - scope: { kind: 'turn', turnItemId: 'turn:t1' } - }) - // A new message takes the next free place, past every row the turn holds. - expect(after.place(message('m2'), 'message', inTurn('t1'), journal).itemId).toBe( - 'codex:root:t1:2' - ) - }) - - it('skips a place an echoed send holds through its submission alias', async () => { - const journal = await openJournal() - await journal.appendSubmission({ - clientMessageId: 'send-1', - payloadFingerprint: 'fp', - body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hi' }] }, - fence: 1 - }) - await journal.resolveDispatch({ - clientMessageId: 'send-1', - state: 'accepted', - providerIdentity: { provider: 'codex', threadId: 'root', turnId: 't1', ordinal: 0 }, - fence: 1 - }) - expect(codexJoins().place(message('reply'), 'message', inTurn('t1'), journal).itemId).toBe( - 'codex:root:t1:1' - ) - }) - - it('continues past the highest ordinal the journal holds, never into a gap below it', async () => { - const journal = await openJournal() - await write(journal, { identity: codexSlot('t1', 2), ref: 'item:old' }, 'old') - // Another turn and another thread keep their own places. - await write(journal, { identity: codexSlot('t2', 7) }, 'other turn') - await write(journal, { identity: codexSlot('t1', 9, 'sub') }, 'subagent') - const joins = codexJoins() - expect(joins.place(message('new'), 'message', inTurn('t1'), journal).itemId).toBe( - 'codex:root:t1:3' - ) - expect(joins.place(message('next'), 'message', inTurn('t1'), journal).itemId).toBe( - 'codex:root:t1:4' - ) - }) - - it('continues past an echoed send above the highest row', async () => { - const journal = await openJournal() - await write(journal, { identity: codexSlot('t1', 0) }, 'first') - await journal.appendSubmission({ - clientMessageId: 'send-1', - payloadFingerprint: 'fp', - body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hi' }] }, - fence: 1 - }) - await journal.resolveDispatch({ - clientMessageId: 'send-1', - state: 'accepted', - providerIdentity: codexSlot('t1', 4), - fence: 1 - }) - expect(codexJoins().place(message('reply'), 'message', inTurn('t1'), journal).itemId).toBe( - 'codex:root:t1:5' - ) - }) - - it('forgets what it read from an epoch the journal replaced', async () => { - const journal = await openJournal() - const joins = codexJoins() - const m0 = joins.place(message('m0'), 'message', inTurn('t1'), journal) - await write(journal, m0, 'first') - expect(joins.find(message('m0'), journal)?.itemId).toBe('codex:root:t1:0') - await journal.replaceEpochItems('handle_forked', 1, []) - expect(joins.find(message('m0'), journal)).toBeNull() - expect(joins.place(message('m1'), 'message', inTurn('t1'), journal).itemId).toBe( - 'codex:root:t1:0' - ) - }) - - it('finds a row whose identity spells its key without any reference', async () => { - const journal = await openJournal() - const scheme = createLegacyProviderTimelineIdentityScheme({ - agent: 'grok', - sessionId: 'session-joins' - }) - const joins = () => new ProviderTimelineJoins({ scheme, generation: 'g', namespace: 'ns' }) - const tool = joins().place(message('tool'), 'tool-call', inTurn('t1'), journal) - expect(tool.ref).toBeUndefined() - await write(journal, tool, 'x') - expect(joins().find(message('tool'), journal)?.itemId).toBe(tool.itemId) - expect(joins().find({ ...message('tool'), thread: 'other' }, journal)).toBeNull() - }) - - it('reads the current request incarnation back from the rows', async () => { - const journal = await openJournal() - const scheme = createLegacyProviderTimelineIdentityScheme({ - agent: 'grok', - sessionId: 'session-joins' - }) - const joins = () => new ProviderTimelineJoins({ scheme, generation: 'g', namespace: 'ns' }) - const key = { source: 'provider', value: 'p1' } as const - const thread = { thread: null, turn: null, scope: AGENT_JOURNAL_THREAD_SCOPE } - const approval: AgentJournalApprovalItem = { - kind: 'approval', - title: 'Run?', - detail: null, - options: [], - resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } - } - const before = joins() - for (let incarnation = 1; incarnation <= 2; incarnation += 1) { - const row = before.nextRequest(key, 'approval', thread, journal) - expect(row.incarnation).toBe(incarnation) - await journal.appendItem(row.identity, approval, { - fence: 1, - turnScope: AGENT_JOURNAL_THREAD_SCOPE - }) - } - expect(joins().request(key, journal)).toMatchObject({ - incarnation: 2, - itemId: agentJournalItemKey( - scheme.item({ - namespace: 'ns', - family: 'request', - key, - thread: null, - turn: null, - itemClass: 'approval', - messageOrdinal: null, - incarnation: 2 - }) - ) - }) - }) -}) diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-joins.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-joins.ts deleted file mode 100644 index 7cb48073933d..000000000000 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-joins.ts +++ /dev/null @@ -1,306 +0,0 @@ -// Provider joins → journal rows: the assembler's canonical join index. -// -// Every row the assembler writes is found again through here, and every entry is a cache of a -// fact the journal holds. A row whose identity spells its provider key is found by recomputing -// that identity; a row whose identity does not (a message keyed by its place among its turn's -// messages) carries the key as `providerItemRef`, which the journal indexes. So a miss — after a -// restart or an eviction — recovers the original row; it never re-places it. Message ordinals and -// request incarnations are read back from the rows that hold them. Only transition resolvers call -// the allocating members, so allocation happens in journal order and a refused event allocates -// nothing. - -import { - agentJournalItemKey, - parseAgentJournalItemKey -} from '../../../shared/agent-session-journal-item-key' -import { - AGENT_JOURNAL_THREAD_SCOPE, - type AgentJournalItemIdentity, - type AgentJournalRenderItem, - type AgentJournalTurnScope -} from '../../../shared/agent-session-journal-types' -import { BoundedMap } from '../../../shared/bounded-map' -import type { StructuredAgentSessionTransitionJournal } from '../agent-session-wire/structured-agent-session-transition' -import { - spellProviderTimelineKey, - type ProviderTimelineIdentityScheme, - type ProviderTimelineItemAddress, - type ProviderTimelineItemClass, - type ProviderTimelineItemFamily, - type ProviderTimelineKey, - type ProviderTimelineTurnAddress -} from './provider-timeline-identity' -import { ProviderTimelineMessagePlaces } from './provider-timeline-message-places' - -const MAX_JOINED_ROWS = 1_024 -const MAX_JOINED_ROW_BYTES = 1024 * 1024 - -/** One provider item as the provider names it. Item keys are the provider's per thread. */ -export type ProviderTimelineItemJoin = { - family: ProviderTimelineItemFamily - key: ProviderTimelineKey - thread: string | null -} - -export type ProviderTimelineRow = { - identity: AgentJournalItemIdentity - itemId: string - scope: AgentJournalTurnScope - /** The join's reference, on a row whose identity does not spell its key. */ - ref?: string - /** Which request under its key, 1 for the first. */ - incarnation: number -} - -export type ProviderTimelineTurnRef = { - address: ProviderTimelineTurnAddress - identity: AgentJournalItemIdentity - itemId: string - turnId: string -} - -/** Where a new row goes: the provider thread and turn, and the journal turn scope. */ -export type ProviderTimelinePlacement = { - thread: string | null - turn: ProviderTimelineKey | null - scope: AgentJournalTurnScope -} - -type Journal = StructuredAgentSessionTransitionJournal | null - -function bytes(value: string): number { - return Buffer.byteLength(value, 'utf8') -} - -export class ProviderTimelineJoins { - private readonly rows = new BoundedMap({ - maxEntries: MAX_JOINED_ROWS, - maxBytes: MAX_JOINED_ROW_BYTES, - sizeOf: (row, key) => bytes(key) + 2 * bytes(row.itemId) + 64 - }) - private readonly places: ProviderTimelineMessagePlaces - /** The journal epoch every cache entry was read from; a replaced epoch holds other rows. */ - private epoch: string | null = null - private serial = 0 - - constructor( - private readonly deps: { - scheme: ProviderTimelineIdentityScheme - generation: string - namespace: string - } - ) { - this.places = new ProviderTimelineMessagePlaces({ - scheme: deps.scheme, - namespace: () => this.deps.namespace - }) - } - - get namespace(): string { - return this.deps.namespace - } - - /** A new provider session: its ids name new rows. Minted keys stay unique as they are. */ - reset(namespace: string): void { - this.deps.namespace = namespace - this.rows.clear() - this.places.clear() - } - - /** Drops every cache read from an epoch the journal has since replaced (a rewind, an import). */ - private sync(journal: Journal): void { - if (!journal || journal.epoch === this.epoch) { - return - } - if (this.epoch !== null) { - this.rows.clear() - this.places.clear() - } - this.epoch = journal.epoch - } - - /** A key unique to this acquisition, for something the provider names nothing. */ - mint(kind: string): ProviderTimelineKey { - this.serial += 1 - return { source: 'minted', value: `${this.deps.generation}:${kind}${this.serial}` } - } - - /** A turn's row. `namespace` defaults to the current one; a planner names the one it expects. */ - turn(key: ProviderTimelineKey, namespace = this.deps.namespace): ProviderTimelineTurnRef { - const address = { namespace, key } - const identity = this.deps.scheme.turn(address) - return { - address, - identity, - itemId: agentJournalItemKey(identity), - turnId: this.deps.scheme.turnId(address) - } - } - - /** The user item a turn names when no send of Orca's opened it. */ - turnOpener(turn: ProviderTimelineTurnRef): string { - return this.deps.scheme.turnOpener?.(turn.address) ?? turn.itemId - } - - /** The join as one bounded string: what rows carry as `providerItemRef` and caches key by. */ - reference(join: ProviderTimelineItemJoin, namespace = this.deps.namespace): string { - const thread = join.family === 'request' ? null : join.thread - return `${join.family}:${spellProviderTimelineKey(namespace, join.key, thread)}` - } - - /** The row the join already names, from memory or the fold; null when the journal holds none. */ - find( - join: ProviderTimelineItemJoin, - journal: Journal, - namespace = this.deps.namespace - ): ProviderTimelineRow | null { - this.sync(journal) - const ref = this.reference(join, namespace) - const cached = this.rows.get(ref) - // A planner ahead of a reset asks about a namespace whose rows this index does not spell yet. - if (cached || !journal || namespace !== this.deps.namespace) { - return cached ?? null - } - const itemId = - journal.itemIdForProviderItemRef(ref) ?? agentJournalItemKey(this.spelled(join, 1)) - const row = rowOf(journal.item(itemId), 1) - if (row) { - this.rows.set(ref, row) - } - return row - } - - /** A new row for a join no row names yet. Allocates its message ordinal: resolvers only. */ - place( - join: ProviderTimelineItemJoin, - itemClass: ProviderTimelineItemClass, - placement: ProviderTimelinePlacement, - journal: Journal - ): ProviderTimelineRow { - this.sync(journal) - const ordinal = - this.deps.scheme.ordinalMessages && itemClass === 'message' && placement.turn - ? this.places.next(placement.thread, placement.turn, journal) - : null - const identity = this.deps.scheme.item({ - ...this.address(join, 1), - turn: placement.turn, - itemClass, - messageOrdinal: ordinal - }) - const ref = this.reference(join) - const row: ProviderTimelineRow = { - identity, - itemId: agentJournalItemKey(identity), - scope: placement.scope, - ...(ordinal === null ? {} : { ref }), - incarnation: 1 - } - this.rows.set(ref, row) - return row - } - - /** An echoed send takes its message's place without a row (its send is the bubble). */ - reserveEcho( - join: ProviderTimelineItemJoin, - placement: ProviderTimelinePlacement, - journal: Journal - ) { - if (!this.deps.scheme.ordinalMessages || !placement.turn || this.find(join, journal)) { - return - } - const row = this.place(join, 'message', placement, journal) - this.places.reserve(row.itemId) - } - - /** The request under `key` the journal holds last: the highest incarnation with a row. */ - request( - key: ProviderTimelineKey, - journal: Journal, - namespace = this.deps.namespace - ): ProviderTimelineRow | null { - this.sync(journal) - const join = { family: 'request' as const, key, thread: null } - const ref = this.reference(join, namespace) - const cached = this.rows.get(ref) - if (cached || !journal || namespace !== this.deps.namespace) { - return cached ?? null - } - let current: ProviderTimelineRow | null = null - for (let incarnation = 1; ; incarnation += 1) { - const itemId = agentJournalItemKey(this.spelled(join, incarnation)) - // A scheme that spells no incarnation names one row for every request under the key. - const row = itemId === current?.itemId ? null : rowOf(journal.item(itemId), incarnation) - if (!row) { - break - } - current = row - } - if (current) { - this.rows.set(ref, current) - } - return current - } - - /** The next request under `key`: resolvers only. */ - nextRequest( - key: ProviderTimelineKey, - itemClass: ProviderTimelineItemClass, - placement: ProviderTimelinePlacement, - journal: Journal - ): ProviderTimelineRow { - const join = { family: 'request' as const, key, thread: null } - const incarnation = (this.request(key, journal)?.incarnation ?? 0) + 1 - const identity = this.deps.scheme.item({ - ...this.address(join, incarnation), - thread: placement.thread, - turn: placement.turn, - itemClass - }) - const row = { - identity, - itemId: agentJournalItemKey(identity), - scope: placement.scope, - incarnation - } - this.rows.set(this.reference(join), row) - return row - } - - private address( - join: ProviderTimelineItemJoin, - incarnation: number - ): ProviderTimelineItemAddress { - return { - namespace: this.deps.namespace, - family: join.family, - key: join.key, - thread: join.thread, - turn: null, - itemClass: 'status', - messageOrdinal: null, - incarnation - } - } - - private spelled(join: ProviderTimelineItemJoin, incarnation: number): AgentJournalItemIdentity { - return this.deps.scheme.item(this.address(join, incarnation)) - } -} - -function rowOf( - item: AgentJournalRenderItem | null, - incarnation: number -): ProviderTimelineRow | null { - const identity = item ? parseAgentJournalItemKey(item.itemId) : null - if (!item || !identity) { - return null - } - return { - identity, - itemId: item.itemId, - scope: item.turnScope ?? AGENT_JOURNAL_THREAD_SCOPE, - ...(item.providerItemRef === undefined ? {} : { ref: item.providerItemRef }), - incarnation - } -} diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-message-places.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-message-places.ts deleted file mode 100644 index e5350c39c93a..000000000000 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-message-places.ts +++ /dev/null @@ -1,122 +0,0 @@ -// Where a new message goes among its (thread, turn)'s messages, for a scheme that keys messages -// by that place. Read back from the journal on a miss, past every place a row or an echoed send -// holds, so a restart continues the sequence the journal already has. - -import { - agentJournalItemKey, - parseAgentJournalItemKey -} from '../../../shared/agent-session-journal-item-key' -import type { AgentJournalItemIdentity } from '../../../shared/agent-session-journal-types' -import { BoundedMap } from '../../../shared/bounded-map' -import type { StructuredAgentSessionTransitionJournal } from '../agent-session-wire/structured-agent-session-transition' -import { - providerTimelineKeyPart, - spellProviderTimelineKey, - type ProviderTimelineIdentityScheme, - type ProviderTimelineKey -} from './provider-timeline-identity' - -const MAX_TURN_ORDINALS = 256 -/** Echoed sends whose slot no row holds yet; a lane records each on its send soon after. */ -const MAX_RESERVED_ECHOES = 64 - -type Journal = StructuredAgentSessionTransitionJournal | null - -function bytes(value: string): number { - return Buffer.byteLength(value, 'utf8') -} - -export class ProviderTimelineMessagePlaces { - /** The next ordinal per place. */ - private readonly ordinals = new BoundedMap({ - maxEntries: MAX_TURN_ORDINALS, - maxBytes: MAX_TURN_ORDINALS * 1024, - sizeOf: (_next, place) => bytes(place) + 8 - }) - private readonly echoes = new BoundedMap({ - maxEntries: MAX_RESERVED_ECHOES, - maxBytes: MAX_RESERVED_ECHOES * 1024, - sizeOf: (_held, itemId) => bytes(itemId) + 8 - }) - - constructor( - private readonly deps: { - scheme: ProviderTimelineIdentityScheme - namespace: () => string - } - ) {} - - clear(): void { - this.ordinals.clear() - this.echoes.clear() - } - - /** Holds a place no row takes (an echoed send's). */ - reserve(itemId: string): void { - this.echoes.set(itemId, true) - } - - /** The next place in (thread, turn): allocating, so resolvers only. */ - next(thread: string | null, turn: ProviderTimelineKey, journal: Journal): number { - const place = `${providerTimelineKeyPart(thread ?? '')}\u001f${spellProviderTimelineKey( - this.deps.namespace(), - turn - )}` - let ordinal = this.ordinals.get(place) ?? this.highWater(thread, turn, journal) - // Another writer of the same turn may have taken places since this one cached its mark. - while (this.taken(agentJournalItemKey(this.slot(thread, turn, ordinal)), journal)) { - ordinal += 1 - } - this.ordinals.set(place, ordinal + 1) - return ordinal - } - - /** One past the highest ordinal the journal holds in the place, like the ordinal counter it - * continues: a gap below it (a removed or never-imported row) is never filled. */ - private highWater(thread: string | null, turn: ProviderTimelineKey, journal: Journal): number { - const read = this.deps.scheme.messageSlot - const place = read?.(this.slot(thread, turn, 0))?.place - if (!read || place === undefined || !journal) { - return 0 - } - let next = 0 - const consider = (itemId: string | null) => { - const identity = itemId === null ? null : parseAgentJournalItemKey(itemId) - const slot = identity ? read(identity) : null - if (slot?.place === place) { - next = Math.max(next, slot.ordinal + 1) - } - } - journal.visitItems((itemId) => consider(itemId)) - // An echoed send holds its place through its submission alias, with no row of its own. - for (const submission of journal.submissions()) { - consider(submission.providerItemId) - } - return next - } - - private slot( - thread: string | null, - turn: ProviderTimelineKey, - ordinal: number - ): AgentJournalItemIdentity { - return this.deps.scheme.item({ - namespace: this.deps.namespace(), - family: 'item', - key: turn, - thread, - turn, - itemClass: 'message', - messageOrdinal: ordinal, - incarnation: 1 - }) - } - - private taken(itemId: string, journal: Journal): boolean { - return ( - this.echoes.has(itemId) || - (journal !== null && - (journal.itemBody(itemId) !== null || journal.canonicalItemId(itemId) !== itemId)) - ) - } -} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.ts index 5cdabdce713d..8e997bf8477f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.ts @@ -51,8 +51,6 @@ export type StructuredAgentSessionPublishOptions = Pick< /** An item write states which turn its row belongs to; the write that creates the row decides. */ export type StructuredAgentSessionItemAppendOptions = StructuredAgentSessionAppendOptions & { turnScope: AgentJournalTurnScope - /** The provider's reference for the item, on the write that creates its row. */ - providerItemRef?: string } export type StructuredAgentSessionLifecycleJournal = Pick< diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-journal-append-options.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-journal-append-options.ts index 57bb482f7c2b..e7ce667ed2b6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-journal-append-options.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-journal-append-options.ts @@ -16,7 +16,6 @@ export function structuredAgentSessionJournalAppendOptions( fence, ...(options.observedAt === undefined ? {} : { observedAt: options.observedAt }), turnScope: options.turnScope, - ...(options.providerItemRef === undefined ? {} : { providerItemRef: options.providerItemRef }), ...agentJournalLinkageFields(options) } } diff --git a/src/shared/agent-session-journal-types.ts b/src/shared/agent-session-journal-types.ts index 271053b1b214..e5f8125a5c0f 100644 --- a/src/shared/agent-session-journal-types.ts +++ b/src/shared/agent-session-journal-types.ts @@ -360,16 +360,8 @@ export const AGENT_JOURNAL_THREAD_SCOPE: AgentJournalTurnScope = { kind: 'thread /** Who produced a row and which turn it belongs to: what every item write states. */ export type AgentJournalRowAttribution = AgentJournalProducerLinkage & { turnScope: AgentJournalTurnScope - providerItemRef?: AgentJournalProviderItemRef } -/** The provider's own reference for the item a row is, as its producer joined it, on a row whose - * identity does not spell it (a Codex message keys by its turn ordinal, and Codex renumbers its - * item ids on resume). Set by the write that creates the row and kept by every revision, like the - * turn scope. Only its producer reads it back, to find the row again after a restart; no client - * depends on it. */ -export type AgentJournalProviderItemRef = string - /** Where the journal placed an item: the sequence of the row that created it, * then its place among that row's writes. The timeline's only ordering key. */ export type AgentJournalPosition = { @@ -395,7 +387,6 @@ export type AgentJournalRenderItem = AgentJournalProducerLinkage & { recoveredAt?: number /** Absent only from a host that predates it. */ turnScope?: AgentJournalTurnScope - providerItemRef?: AgentJournalProviderItemRef } // ─── Submissions ──────────────────────────────────────────────────────────── diff --git a/tests/e2e/cross-version-wire/agent-session-provider-item-ref-downgrade.unit.test.ts b/tests/e2e/cross-version-wire/agent-session-provider-item-ref-downgrade.unit.test.ts deleted file mode 100644 index ff0ecc5fed33..000000000000 --- a/tests/e2e/cross-version-wire/agent-session-provider-item-ref-downgrade.unit.test.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { mkdtempSync, rmSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { expect, test } from 'vitest' -import type { AgentJournalItemIdentity } from '../../../src/shared/agent-session-journal-types' -import { createTrackedJournalOpener } from '../../../src/main/native-chat/agent-session-journal/journal-host-database-test-support' -import type { JournalRow } from '../../../src/main/native-chat/agent-session-journal/journal-row-schema' -import { importReleaseCheckoutModule, materializeReleaseCheckout } from './release-checkout' - -// A release that predates `providerItemRef` on journal rows and render items. -const BASELINE_REF = 'v1.4.218' - -const SESSION = 'session-provider-item-ref' - -function message(ordinal: number): AgentJournalItemIdentity { - return { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal } -} - -function releaseExport(module: Record, name: string): T { - const value = module[name] - if (value === undefined) { - throw new Error(`the pinned release exports no ${name}`) - } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: an export the pinned release has; each caller names the shape it uses, and a changed one fails the test. - return value as T -} - -type OldRenderItem = { itemId: string; [field: string]: unknown } - -// Loads a real old build, including cold extraction and transforms. -test('an older host and client render a row carrying a provider item reference as if it had none', async () => { - const directory = mkdtempSync(join(tmpdir(), 'orca-provider-item-ref-downgrade-')) - const journals = createTrackedJournalOpener() - try { - // This build: the same message twice, the first carrying the reference. - const journal = await journals.open({ - identity: { - sessionId: SESSION, - workspaceId: 'ws-1', - hostId: 'host-1', - agent: 'codex', - providerHandle: { kind: 'codex', threadId: 'thread-1' } - }, - stateDirectory: directory, - now: () => 1_000 - }) - const body = { - kind: 'message' as const, - role: 'assistant' as const, - blocks: [{ type: 'text' as const, text: 'Hello' }] - } - const scope = { kind: 'thread' as const } - await journal.appendItem(message(0), body, { - fence: 1, - turnScope: scope, - providerItemRef: 'item:p:thread-1/item-1' - }) - await journal.appendItem(message(1), body, { fence: 1, turnScope: scope }) - const since = journal.readSince({ epoch: journal.epoch, sequence: 0 }) - if (!since.ok) { - throw new Error(`expected rows, got reset ${since.reset}`) - } - const rows: JournalRow[] = since.rows - const current = journal.snapshot().items - expect(current[0]?.providerItemRef).toBe('item:p:thread-1/item-1') - - const checkout = await materializeReleaseCheckout(BASELINE_REF) - const [reducer, schemas, projection] = await Promise.all( - [ - 'src/main/native-chat/agent-session-journal/journal-reducer.ts', - 'src/shared/agent-session-journal-schemas.ts', - 'src/shared/structured-agent-session-projection.ts' - ].map((path) => importReleaseCheckoutModule(checkout, path)) - ) - const createState = releaseExport<(sessionId: string, epoch: string) => unknown>( - reducer, - 'createJournalReducerState' - ) - const applyRow = releaseExport<(state: unknown, row: JournalRow) => void>( - reducer, - 'applyJournalRow' - ) - const render = releaseExport<(state: unknown) => { items: OldRenderItem[] }>( - reducer, - 'renderJournalState' - ) - const renderItemSchema = releaseExport<{ parse: (value: unknown) => OldRenderItem }>( - schemas, - 'AgentJournalRenderItemSchema' - ) - const project = releaseExport<(item: unknown) => unknown>( - projection, - 'projectStructuredItemToNativeChat' - ) - - // The older host, after a downgrade, folds the rows as it always did. - const state = createState(SESSION, journal.epoch) - for (const row of rows) { - applyRow(state, row) - } - const folded = render(state).items - expect(folded.map((item) => item.itemId)).toEqual([ - 'codex:thread-1:turn-1:0', - 'codex:thread-1:turn-1:1' - ]) - const [first, second] = folded - if (!first || !second) { - throw new Error('the older host folded both rows') - } - expect(first).not.toHaveProperty('providerItemRef') - const same = ({ itemId: _id, sequence: _seq, ...rest }: OldRenderItem) => rest - expect(same(first)).toEqual(same(second)) - - // The older client reads this host's render item without the field, and renders it the same. - const [withRef, withoutRef] = current.map((item) => renderItemSchema.parse(item)) - if (!withRef || !withoutRef) { - throw new Error('the older client read both items') - } - expect(withRef).not.toHaveProperty('providerItemRef') - expect(project(withRef)).toEqual( - project({ ...withoutRef, itemId: withRef.itemId, sequence: withRef.sequence }) - ) - } finally { - await journals.closeAll() - rmSync(directory, { recursive: true, force: true }) - } -}) From 7d048776c85f0ef254cd9f674e1c51844e0c0103 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 00:13:40 -0700 Subject: [PATCH 35/62] Write a resolved settlement in one transaction through enqueueRows A settlement too large for one row now commits all its rows or none, through the journal's existing all-or-nothing write, instead of a row-by-row writer. Every row is built before any commits, so a settlement naming one item twice is refused before anything is written. --- .../journal-lifecycle-batch-appender.ts | 37 ++++++++--- .../journal-row-writer.ts | 61 +++++++------------ .../journal-store-collaborators.ts | 1 - 3 files changed, 52 insertions(+), 47 deletions(-) diff --git a/src/main/native-chat/agent-session-journal/journal-lifecycle-batch-appender.ts b/src/main/native-chat/agent-session-journal/journal-lifecycle-batch-appender.ts index 9f1e0cce218f..cc300a7b7497 100644 --- a/src/main/native-chat/agent-session-journal/journal-lifecycle-batch-appender.ts +++ b/src/main/native-chat/agent-session-journal/journal-lifecycle-batch-appender.ts @@ -1,13 +1,16 @@ +import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' import type { AgentJournalCursor } from '../../../shared/agent-session-journal-types' import type { JournalReducerState } from './journal-reducer' import { partitionJournalLifecycleMutations } from './journal-lifecycle-batch-partition' -import { journalLifecycleBatchRowBuilder } from './journal-row-builders' +import { + journalLifecycleBatchRowBuilder, + type JournalLifecycleMutationInput +} from './journal-row-builders' import type { JournalLifecycleBatchInput, JournalResolvedLifecycleBatchInput } from './journal-store-contracts' import type { JournalRow } from './journal-row-schema' -import type { JournalRowWriter } from './journal-row-writer' import { journalQueuedRejectionRowBuilders } from './journal-pending-submission-recovery' const SETTLEMENT_ALREADY_APPLIED = new Error('journal_settlement_already_applied') @@ -18,8 +21,9 @@ export class JournalLifecycleBatchAppender { state: () => JournalReducerState cursor: () => AgentJournalCursor enqueue: (build: (seq: number, ts: number) => JournalRow) => Promise - enqueueEach: JournalRowWriter['enqueueEach'] - enqueueRows: JournalRowWriter['enqueueRows'] + enqueueRows: ( + plan: () => readonly ((seq: number, ts: number) => JournalRow)[] + ) => Promise } ) {} @@ -75,11 +79,15 @@ export class JournalLifecycleBatchAppender { } /** Chooses the mutations at its turn in the queue; one too large for a row becomes consecutive - * rows that nothing else lands between, in the order resolved. */ + * rows, committed in one transaction, in the order resolved. */ appendResolved(input: JournalResolvedLifecycleBatchInput): Promise { return this.deps - .enqueueEach(() => - partitionJournalLifecycleMutations(input.settlementId, input.resolve()) + .enqueueRows(() => { + const mutations = input.resolve() + // Every chunk is built before any commits, so a second chunk naming the same item would + // reuse the first chunk's revision. + this.assertDistinctItems(mutations) + return partitionJournalLifecycleMutations(input.settlementId, mutations) .filter((chunk) => !this.wasApplied(chunk.settlementId)) .map((chunk) => journalLifecycleBatchRowBuilder( @@ -89,13 +97,26 @@ export class JournalLifecycleBatchAppender { input ) ) - ) + }) .then((rows) => { const last = rows.at(-1) return last ? { epoch: last.epoch, sequence: last.seq } : null }) } + private assertDistinctItems(mutations: readonly JournalLifecycleMutationInput[]): void { + const { aliases } = this.deps.state() + const seen = new Set() + for (const mutation of mutations) { + const itemId = agentJournalItemKey(mutation.identity) + const resolved = aliases.get(itemId) ?? itemId + if (seen.has(resolved)) { + throw new Error('journal_resolved_lifecycle_batch_names_item_twice') + } + seen.add(resolved) + } + } + private wasApplied(settlementId: string): boolean { return this.deps.state().appliedSettlementIds.has(settlementId) } diff --git a/src/main/native-chat/agent-session-journal/journal-row-writer.ts b/src/main/native-chat/agent-session-journal/journal-row-writer.ts index 312635c60a77..dde1a66741d1 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-writer.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-writer.ts @@ -46,44 +46,29 @@ export class JournalRowWriter { hook?: JournalRowTransactionHook, receipt?: JournalOperationReceipt ): Promise { - return this.deps.serialize(() => this.write(build, hook, receipt)) - } - - /** Rows chosen at this write's turn in the queue and written back to back, so no other write - * lands between them; each commits alone and is built after the one before it is adopted, so a - * later row may revise an earlier one. `enqueueRows` is the all-or-nothing form. */ - enqueueEach( - resolve: () => readonly ((seq: number, ts: number) => JournalRow)[] - ): Promise { - return this.deps.serialize(() => resolve().map((build) => this.write(build))) - } - - private write( - build: (seq: number, ts: number) => JournalRow, - hook?: JournalRowTransactionHook, - receipt?: JournalOperationReceipt - ): JournalRow { - assertJournalWritable(this.deps.readOnly(), this.deps.sessionId) - const row = build(this.deps.nextSequence(), this.deps.now()) - assertJournalFence(row.fence, this.deps.highestFence()) - try { - // One INSERT: the chat's epoch pointer moves only when the epoch does. - this.deps.database().transaction((db) => { - insertJournalRow(db, this.deps.sessionId, row) - hook?.(db, row) - receipt?.write(db) - this.runBookkeeping(db, row) - }) - } catch (error) { - this.deps.rolledBack?.() - throw error - } - // COMMIT landed, so the row is durable: adopt it before anything that can - // fail. Rejecting here instead would leave the next append reusing a - // sequence the table already holds. The ledger first: it cannot throw, the fold can. - receipt?.committed() - this.deps.commit(row) - return row + return this.deps.serialize(() => { + assertJournalWritable(this.deps.readOnly(), this.deps.sessionId) + const row = build(this.deps.nextSequence(), this.deps.now()) + assertJournalFence(row.fence, this.deps.highestFence()) + try { + // One INSERT: the chat's epoch pointer moves only when the epoch does. + this.deps.database().transaction((db) => { + insertJournalRow(db, this.deps.sessionId, row) + hook?.(db, row) + receipt?.write(db) + this.runBookkeeping(db, row) + }) + } catch (error) { + this.deps.rolledBack?.() + throw error + } + // COMMIT landed, so the row is durable: adopt it before anything that can + // fail. Rejecting here instead would leave the next append reusing a + // sequence the table already holds. The ledger first: it cannot throw, the fold can. + receipt?.committed() + this.deps.commit(row) + return row + }) } /** Several rows in ONE transaction, in order, planned once the lane is this append's: none is diff --git a/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts b/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts index 6a77b8001032..ba8796964692 100644 --- a/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts +++ b/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts @@ -125,7 +125,6 @@ export function createJournalStoreCollaborators(host: JournalStoreHost): Journal state: host.state, cursor: host.cursor, enqueue: host.enqueue, - enqueueEach: (resolve) => rowWriter.enqueueEach(resolve), enqueueRows: (plan) => rowWriter.enqueueRows(plan) }) } From e1c3ccdaa3c93c99c08d95764b979948f3d162b0 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 00:13:40 -0700 Subject: [PATCH 36/62] Drop the transition's landing report; keep the turn-row write fire-and-forget Nothing reads which steps of a transition wrote. A failed step fails the sink, leaving the steps before it written; the header says so, and tests cover it plus a settlement whose second row fails inside the transaction. writeAgentJournalTurnRow returns nothing again, as on main. --- .../agent-journal-turn-row-revision.ts | 9 +- ...tructured-agent-session-transition.test.ts | 119 +++++++++++++++--- .../structured-agent-session-transition.ts | 24 +--- 3 files changed, 108 insertions(+), 44 deletions(-) diff --git a/src/main/native-chat/agent-session-timeline/agent-journal-turn-row-revision.ts b/src/main/native-chat/agent-session-timeline/agent-journal-turn-row-revision.ts index 69356c406784..dfede8c46f7c 100644 --- a/src/main/native-chat/agent-session-timeline/agent-journal-turn-row-revision.ts +++ b/src/main/native-chat/agent-session-timeline/agent-journal-turn-row-revision.ts @@ -24,8 +24,7 @@ import { estimateStructuredAgentSessionItemBytes } from '../agent-session-wire/s import type { StructuredAgentSessionEventSink, StructuredAgentSessionRevisionJournal, - StructuredAgentSessionRevisionOptions, - StructuredAgentSessionSinkAdmission + StructuredAgentSessionRevisionOptions } from '../agent-session-wire/structured-agent-session-event-sink' /** The row a write revises: a known one, or the newest turn in the journal. */ @@ -203,7 +202,7 @@ export function writeAgentJournalTurnRow( target: AgentJournalTurnRowTarget, write: AgentJournalTurnRowWrite, { publish, options: delivery = {} }: AgentJournalTurnRowDelivery -): StructuredAgentSessionSinkAdmission { +): void { // A turn record belongs to no turn. const options = { ...delivery, turnScope: AGENT_JOURNAL_THREAD_SCOPE } const revise = publish ? sink.tryReviseResolvedItemAndPublish : sink.tryReviseResolvedItem @@ -214,10 +213,10 @@ export function writeAgentJournalTurnRow( sink.publish() } } - return { accepted: true } + return } const reservedBytes = agentJournalTurnRowReservedBytes(target, write) - return revise.call( + revise.call( sink, reservedBytes, (journal) => resolveAgentJournalTurnRowWrite(journal, target, write, reservedBytes), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-transition.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-transition.test.ts index 1b242dd8d983..f752a2147afc 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-transition.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-transition.test.ts @@ -8,7 +8,11 @@ import { type AgentJournalItemIdentity, type AgentJournalToolCallItem } from '../../../shared/agent-session-journal-types' -import { createTrackedJournalOpener } from '../agent-session-journal/journal-host-database-test-support' +import { + createTrackedJournalOpener, + openTestJournalHostDatabase +} from '../agent-session-journal/journal-host-database-test-support' +import type { JournalLifecycleMutationInput } from '../agent-session-journal/journal-row-builders' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { createAgentSessionDeltaCoalescer } from './agent-session-delta-coalescer' import { @@ -38,6 +42,15 @@ function tool(name: string, state: AgentJournalToolCallItem['state']): AgentJour return { kind: 'tool-call', name, input: { name }, state } } +function failedTool(id: AgentJournalItemIdentity): JournalLifecycleMutationInput { + return { + kind: 'item', + identity: id, + body: tool('read', 'failed'), + turnScope: AGENT_JOURNAL_THREAD_SCOPE + } +} + function itemStep( resolve: Extract['resolve'] ): StructuredAgentSessionTransitionStep { @@ -69,7 +82,7 @@ async function rig(watermarks: Partial = { watermarks }) const bind = () => deferred.bind({ journal, fence: 1, publish: () => publishes.push(1) }) - return { journal, deferred, sink: deferred.sink, publishes, bind } + return { root, journal, deferred, sink: deferred.sink, publishes, bind } } describe('structured agent-session transitions', () => { @@ -108,10 +121,9 @@ describe('structured agent-session transitions', () => { expect(publishes).toHaveLength(1) }) - it('places a row where its resolver says, and hears every landing', async () => { + it('places a row where its resolver says', async () => { const { journal, deferred, sink, bind } = await rig() bind() - const landed: (readonly boolean[])[] = [] const scope = { kind: 'turn', turnItemId: 'turn-row' } as const sink.tryAppendTransition?.({ lifecycle: false, @@ -120,19 +132,44 @@ describe('structured agent-session transitions', () => { itemStep(() => ({ identity: identity('placed'), body: tool('read', 'running'), - options: { turnScope: scope, providerItemRef: 'item:read' } + options: { turnScope: scope } })), itemStep(() => null) - ], - landed: (wrote) => landed.push(wrote) + ] }) await deferred.drained() expect(journal.item(agentJournalItemKey(identity('placed')))).toMatchObject({ - turnScope: scope, - providerItemRef: 'item:read' + turnScope: scope }) - expect(landed).toEqual([[true, false]]) + }) + + it('admitted whole is not executed whole: a failed step keeps the ones before it and fails the sink', async () => { + const { journal, deferred, sink, publishes, bind } = await rig() + bind() + sink.tryAppendTransition?.({ + lifecycle: false, + publish: true, + steps: [ + itemStep(() => ({ identity: identity('kept'), body: tool('read', 'running') })), + itemStep(() => { + throw new Error('resolver failed') + }) + ] + }) + + await expect(deferred.drained()).resolves.toMatchObject({ ok: false }) + expect(journal.snapshot().items.map((item) => item.itemId)).toEqual([ + agentJournalItemKey(identity('kept')) + ]) + expect(publishes).toEqual([]) + expect( + sink.tryAppendTransition?.({ + lifecycle: false, + publish: false, + steps: [itemStep(() => null)] + }) + ).toEqual({ accepted: false, reason: 'failed' }) }) it('refuses a transition whole, so none of its steps ever lands', async () => { @@ -165,14 +202,10 @@ describe('structured agent-session transitions', () => { sink.tryAppendItem?.(identity('done'), tool('read', 'completed'), { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) - let wrote: readonly boolean[] = [] expect( sink.tryAppendTransition?.({ lifecycle: true, publish: true, - landed: (result) => { - wrote = result - }, steps: [ { kind: 'settlement', @@ -213,20 +246,50 @@ describe('structured agent-session transitions', () => { ).toMatchObject({ state: 'completed' }) // Two batch rows, back to back, between the last append before it and the first after it. expect(sequenceOf('after') - sequenceOf('done')).toBe(3) - expect(wrote).toEqual([true]) expect(publishes).toHaveLength(1) }) + it('leaves no row of a settlement durable when a later one of its rows fails to write', async () => { + const { root, journal, deferred, sink, bind } = await rig() + bind() + const running = Array.from({ length: 250 }, (_, index) => identity(`tool-${index}`)) + for (const id of running) { + sink.tryAppendItem?.(id, tool('read', 'running'), { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) + } + await deferred.drained() + const before = journal.cursor().sequence + // The settlement needs two rows; the second one's insert aborts inside the transaction. + openTestJournalHostDatabase(root).db.exec(`CREATE TEMP TRIGGER fail_second_chunk +BEFORE INSERT ON main.journal_rows WHEN NEW.seq = ${before + 2} +BEGIN SELECT RAISE(ABORT, 'second chunk refused'); END`) + sink.tryAppendTransition?.({ + lifecycle: true, + publish: true, + steps: [ + { + kind: 'settlement', + settlementId: 'settle-all', + reservedBytes: 1, + resolve: () => running.map(failedTool) + } + ] + }) + + await expect(deferred.drained()).resolves.toMatchObject({ ok: false }) + expect(journal.cursor().sequence).toBe(before) + expect( + journal + .snapshot() + .items.filter((item) => item.body.kind === 'tool-call' && item.body.state === 'failed') + ).toEqual([]) + }) + it('writes and announces nothing when a step resolves to nothing', async () => { const { journal, deferred, sink, publishes, bind } = await rig() bind() - let wrote: readonly boolean[] = [] sink.tryAppendTransition?.({ lifecycle: true, publish: true, - landed: (result) => { - wrote = result - }, steps: [ itemStep(() => null), { kind: 'settlement', settlementId: 'none', reservedBytes: 1, resolve: () => [] } @@ -235,11 +298,27 @@ describe('structured agent-session transitions', () => { await deferred.drained() expect(journal.snapshot().items).toEqual([]) - expect(wrote).toEqual([false, false]) expect(publishes).toEqual([]) }) }) +describe('resolved lifecycle batches', () => { + it('refuses, before writing anything, a settlement that names one item twice', async () => { + const { journal } = await rig() + const before = journal.cursor().sequence + const twice = identity('twice') + + await expect( + journal.appendResolvedLifecycleBatch({ + settlementId: 'twice', + fence: 1, + resolve: () => [failedTool(identity('once')), failedTool(twice), failedTool(twice)] + }) + ).rejects.toThrow('journal_resolved_lifecycle_batch_names_item_twice') + expect(journal.cursor().sequence).toBe(before) + }) +}) + describe('coalescer text a caller writes itself', () => { it('reports unwritten streams and owes nothing once the caller marks them written', () => { const emitted: string[] = [] diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-transition.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-transition.ts index c67dab93edef..a185695d5ea5 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-transition.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-transition.ts @@ -6,6 +6,8 @@ // all. At execution its steps are issued in the same tick, so they sit together in the journal's // write queue, and each step resolves against the fold with every earlier write landed — the // steps before it included — so what a step writes is decided by the journal, not by memory. +// Admitted whole is not executed whole: a step that fails leaves the steps before it written, and +// fails the sink, so nothing more is admitted after it. import type { AgentJournalItemBody, @@ -21,18 +23,10 @@ import type { import type { StructuredAgentSessionSinkQueue } from './structured-agent-session-event-sink-queue' import { structuredAgentSessionJournalAppendOptions } from './structured-agent-session-journal-append-options' -/** What a transition step reads: rows by key or provider reference, every row, their turns, and - * the sends whose echo holds a provider item's place. */ +/** What a transition step reads: rows by key, every row, and the turns they joined. */ export type StructuredAgentSessionTransitionJournal = Pick< AgentSessionJournal, - | 'epoch' - | 'submissions' - | 'visitItems' - | 'itemBody' - | 'item' - | 'itemIdForProviderItemRef' - | 'canonicalItemId' - | 'visitItemsWithLinkage' + 'epoch' | 'visitItems' | 'itemBody' | 'item' | 'visitItemsWithLinkage' > export type StructuredAgentSessionTransitionStep = @@ -69,8 +63,6 @@ export type StructuredAgentSessionTransition = { lifecycle: boolean /** Announce the writes once they land, when any step wrote. */ publish: boolean - /** Which steps wrote a row, once every step has landed or one failed (then none counts). */ - landed?: (wrote: readonly boolean[]) => void } const STEP_OVERFLOW = 'structured agent-session transition step exceeded its reserved size' @@ -143,13 +135,7 @@ function transitionAppend( }) .then((landed) => landed !== null) ) - let wrote: boolean[] = transition.steps.map(() => false) - try { - wrote = await Promise.all(writes) - } finally { - // Heard even when a write failed, so a writer's speculation about it always ends. - transition.landed?.(wrote) - } + const wrote = await Promise.all(writes) if (transition.publish && wrote.some(Boolean)) { bound.publish() } From f3f2ccc7d7db44dbf9043befae9b67b2d121200e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 00:16:44 -0700 Subject: [PATCH 37/62] Generate open ACP enums and check the generated schema offline A newer or vendor enum value (tool kind, tool status, option kind, stop reason) no longer fails the whole message: generated enums accept the known literals plus any other string, typed so callers can still narrow on the known ones. The generated header now records the pinned input digests, the generator digest and a body hash, so `verify:acp-protocol` catches a stale or hand-edited file without network access; it runs in lint and the PR workflow. --- .github/workflows/pr.yml | 4 + config/scripts/acp/generate-protocol.mjs | 138 ++++++++++++------ package.json | 4 +- .../acp/acp-session-notifications.test.ts | 11 +- .../acp/generated/acp-protocol.generated.ts | 39 +++-- 5 files changed, 136 insertions(+), 60 deletions(-) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index ed9a25732bbe..7a9b6b876fce 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -349,6 +349,10 @@ jobs: if: needs.code_paths.outputs.static_analysis == 'true' run: pnpm run verify:rpc-params-catalog + - name: Verify the generated ACP protocol schema + if: needs.code_paths.outputs.static_analysis == 'true' + run: pnpm run verify:acp-protocol + - name: Verify bundled skill guides if: needs.code_paths.outputs.static_analysis == 'true' run: pnpm run verify:bundled-skill-guides diff --git a/config/scripts/acp/generate-protocol.mjs b/config/scripts/acp/generate-protocol.mjs index 9e154fde5052..54d3d30b8e64 100644 --- a/config/scripts/acp/generate-protocol.mjs +++ b/config/scripts/acp/generate-protocol.mjs @@ -7,9 +7,46 @@ const release = 'schema-v1.21.0' const legacyRelease = 'v0.11.6' const repository = 'https://github.com/agentclientprotocol/agent-client-protocol' const output = fileURLToPath(new URL('../../../src/main/acp/generated/', import.meta.url)) -const check = process.argv.includes('--check') +const outputFile = 'acp-protocol.generated.ts' +// --check is offline (lint/CI): the header records every input digest and the body hash. +// --check-online regenerates from the pinned downloads and compares byte-for-byte. +const checkOnline = process.argv.includes('--check-online') +const check = checkOnline || process.argv.includes('--check') const definitions = {} -const header = `// Generated by config/scripts/acp/generate-protocol.mjs; do not edit.\n// ACP ${release}, legacy model API ${legacyRelease}; SPDX-License-Identifier: Apache-2.0.\n` +const inputs = { + schema: '7f77702b34e0a0558e77220e9007bf8ee161a976bb8ac5021aba1b7e7b2c5708', + legacy: 'b3cf8687d979c98c009f0fbcf8f0c237645b82ac2d2f0a3ebca683f963c3d581', + license: 'f250d08cee4549b22b3b4aaaf3a743473336fd280316df5d0340717e5127a221' +} +const sha256 = (text) => createHash('sha256').update(text.replace(/\r\n/g, '\n')).digest('hex') +const generatorDigest = sha256(await readFile(import.meta.filename, 'utf8')) +const header = `// Generated by config/scripts/acp/generate-protocol.mjs; do not edit. Regenerate: pnpm run generate:acp-protocol\n// ACP ${release}, legacy model API ${legacyRelease}; SPDX-License-Identifier: Apache-2.0.\n// Inputs sha256: schema ${inputs.schema}, legacy ${inputs.legacy}, license ${inputs.license}, generator ${generatorDigest}\n` +const bodyDigestPrefix = '// Body sha256: ' + +async function checkOffline() { + const text = (await readFile(resolve(output, outputFile), 'utf8')).replace(/\r\n/g, '\n') + if (!text.startsWith(header)) { + throw new Error(`Stale generated file: ${outputFile} (inputs or generator changed; regenerate)`) + } + const rest = text.slice(header.length) + const newline = rest.indexOf('\n') + if (!rest.startsWith(bodyDigestPrefix) || newline === -1) { + throw new Error(`Stale generated file: ${outputFile} (missing body digest)`) + } + if (rest.slice(bodyDigestPrefix.length, newline) !== sha256(rest.slice(newline + 1))) { + throw new Error(`Generated file was edited by hand: ${outputFile}`) + } + for (const file of await readdir(output)) { + if ((file.endsWith('.gen.ts') || file.endsWith('.generated.ts')) && file !== outputFile) { + throw new Error(`Unexpected generated file: ${file}`) + } + } + console.log(`Checked ${outputFile} against pinned inputs offline`) +} +if (check && !checkOnline) { + await checkOffline() + process.exit(0) +} async function download(url, digest) { const response = await fetch(url) @@ -24,17 +61,11 @@ async function download(url, digest) { } const [schema, legacy, license] = await Promise.all([ - download( - `${repository}/releases/download/${release}/schema.unstable.json`, - '7f77702b34e0a0558e77220e9007bf8ee161a976bb8ac5021aba1b7e7b2c5708' - ), - download( - `${repository}/releases/download/${legacyRelease}/schema.unstable.json`, - 'b3cf8687d979c98c009f0fbcf8f0c237645b82ac2d2f0a3ebca683f963c3d581' - ), + download(`${repository}/releases/download/${release}/schema.unstable.json`, inputs.schema), + download(`${repository}/releases/download/${legacyRelease}/schema.unstable.json`, inputs.legacy), download( `https://raw.githubusercontent.com/agentclientprotocol/agent-client-protocol/${release}/LICENSE`, - 'f250d08cee4549b22b3b4aaaf3a743473336fd280316df5d0340717e5127a221' + inputs.license ) ]) Object.assign(definitions, JSON.parse(legacy).$defs, JSON.parse(schema).$defs) @@ -115,6 +146,29 @@ function visit(name) { } roots.forEach(visit) +// A named string enum: two or more string constants, optionally with an open `string` member. +function isStringEnum(value) { + const alternatives = value.oneOf ?? value.anyOf + return ( + Array.isArray(alternatives) && + alternatives.filter((alternative) => typeof alternative.const === 'string').length > 1 && + alternatives.every( + (alternative) => + typeof alternative.const === 'string' || + (alternative.type === 'string' && + Object.keys(alternative).every((key) => ['type', 'title', 'description'].includes(key))) + ) + ) +} + +// Enums stay open so a newer or vendor value reaches the caller instead of failing the message. +function openEnum(value) { + const known = (value.oneOf ?? value.anyOf).filter( + (alternative) => typeof alternative.const === 'string' + ) + return `z.union([${known.map((alternative) => `z.literal(${JSON.stringify(alternative.const)})`).join(',')},otherString])` +} + function expression(value) { if (value === true) { return 'z.unknown()' @@ -197,48 +251,44 @@ function expression(value) { return result } -const generated = new Map([ - [ - 'acp-protocol.generated.ts', - `${header}/*\n${license.trim()}\n*/\nimport { z } from 'zod'\nexport const ACP_SCHEMA_RELEASE = '${release}'\nexport const ACP_LEGACY_MODEL_SCHEMA_RELEASE = '${legacyRelease}'\nexport const ACP_PROTOCOL_VERSION = 1\n${ordered - .map( - (name) => - `export const ${name}Schema = ${expression(definitions[name])}\nexport type ${name} = z.infer\n` - ) - .join('\n')}` - ] -]) +const source = `${header}/*\n${license.trim()}\n*/\nimport { z } from 'zod'\nexport const ACP_SCHEMA_RELEASE = '${release}'\nexport const ACP_LEGACY_MODEL_SCHEMA_RELEASE = '${legacyRelease}'\nexport const ACP_PROTOCOL_VERSION = 1\n// An enum value this schema release does not name; \`string & {}\` keeps the known literals narrowable.\nconst otherString = z.custom((value) => typeof value === 'string')\n${ordered + .map( + (name) => + `export const ${name}Schema = ${isStringEnum(definitions[name]) ? openEnum(definitions[name]) : expression(definitions[name])}\nexport type ${name} = z.infer\n` + ) + .join('\n')}` // Use the repository formatter without spawning a platform-dependent executable shim. const { format } = await import('oxfmt') +const formatted = await format(outputFile, source, { + singleQuote: true, + semi: false, + printWidth: 100, + trailingComma: 'none' +}) +if (formatted.errors.length || !formatted.code.startsWith(header)) { + throw new Error(`Formatting failed for ${outputFile}`) +} +const body = formatted.code.slice(header.length) +const code = `${header}${bodyDigestPrefix}${sha256(body)}\n${body}` await mkdir(output, { recursive: true }) -for (const [file, source] of generated) { - const formatted = await format(file, source, { - singleQuote: true, - semi: false, - printWidth: 100, - trailingComma: 'none' - }) - if (formatted.errors.length) { - throw new Error(`Formatting failed for ${file}`) - } - const destination = resolve(output, file) - if (check) { - if ((await readFile(destination, 'utf8')) !== formatted.code) { - throw new Error(`Stale generated file: ${file}`) - } - } else { - await writeFile(destination, formatted.code) +const destination = resolve(output, outputFile) +if (check) { + if ((await readFile(destination, 'utf8')) !== code) { + throw new Error(`Stale generated file: ${outputFile}`) } +} else { + await writeFile(destination, code) } for (const file of await readdir(output)) { - if ((file.endsWith('.gen.ts') || file.endsWith('.generated.ts')) && !generated.has(file)) { - if (check || !(await readFile(resolve(output, file), 'utf8')).startsWith(header)) { + if ((file.endsWith('.gen.ts') || file.endsWith('.generated.ts')) && file !== outputFile) { + const generatedHere = (await readFile(resolve(output, file), 'utf8')).startsWith( + '// Generated by config/scripts/acp/generate-protocol.mjs' + ) + if (check || !generatedHere) { throw new Error(`Unexpected generated file: ${file}`) } await unlink(resolve(output, file)) } } -console.log( - `${check ? 'Checked' : 'Generated'} ${ordered.length} ACP definitions in ${generated.size} files` -) +console.log(`${check ? 'Checked' : 'Generated'} ${ordered.length} ACP definitions`) diff --git a/package.json b/package.json index 3c1979d5ff0e..e2b900443da3 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "audit:perf": "oxlint --config config/oxlint-performance-audit.json --format json src", "test:perf:contracts": "vitest run --config config/vitest.performance.config.ts", "format": "oxfmt --write .", - "lint": "oxlint && pnpm run audit:anti-slop && pnpm run audit:code-quality:native && pnpm run audit:code-quality:type-aware && pnpm run check:reliability-gates && pnpm run check:dead-classes && pnpm run check:max-lines-ratchet && pnpm run check:ts-nocheck-ratchet && pnpm run check:runtime-electron-ratchet && pnpm run check:readme-local-links && pnpm run check:node-runtime-pin && pnpm run verify:rpc-params-catalog && pnpm run verify:bundled-skill-guides && pnpm run verify:skill-bundle-manifest && pnpm run verify:localization-catalogs && pnpm run verify:localization-extraction && pnpm run verify:localization-coverage", + "lint": "oxlint && pnpm run audit:anti-slop && pnpm run audit:code-quality:native && pnpm run audit:code-quality:type-aware && pnpm run check:reliability-gates && pnpm run check:dead-classes && pnpm run check:max-lines-ratchet && pnpm run check:ts-nocheck-ratchet && pnpm run check:runtime-electron-ratchet && pnpm run check:readme-local-links && pnpm run check:node-runtime-pin && pnpm run verify:rpc-params-catalog && pnpm run verify:acp-protocol && pnpm run verify:bundled-skill-guides && pnpm run verify:skill-bundle-manifest && pnpm run verify:localization-catalogs && pnpm run verify:localization-extraction && pnpm run verify:localization-coverage", "audit:code-quality": "pnpm run audit:code-quality:native && pnpm run audit:code-quality:type-aware && pnpm run audit:react-doctor", "audit:code-quality:native": "oxlint --config config/oxlint-code-quality-native-plugins.json src config tests mobile --deny-warnings", "audit:code-quality:type-aware": "oxlint --type-aware --config config/oxlint-code-quality-type-aware.json src config tests --deny-warnings", @@ -50,6 +50,8 @@ "check:feature-wall-assets": "node config/scripts/check-feature-wall-assets.mjs", "generate:rpc-params-catalog": "node config/scripts/generate-rpc-params-catalog.mjs", "verify:rpc-params-catalog": "node config/scripts/generate-rpc-params-catalog.mjs --check", + "generate:acp-protocol": "node config/scripts/acp/generate-protocol.mjs", + "verify:acp-protocol": "node config/scripts/acp/generate-protocol.mjs --check", "generate:bundled-skill-guides": "node config/scripts/generate-bundled-skill-guides.mjs --write", "verify:bundled-skill-guides": "node config/scripts/generate-bundled-skill-guides.mjs --check", "generate:skill-bundle-manifest": "node config/scripts/generate-skill-bundle-manifest.mjs --write", diff --git a/src/main/acp/acp-session-notifications.test.ts b/src/main/acp/acp-session-notifications.test.ts index b13796d2ea06..e71c3084f9ab 100644 --- a/src/main/acp/acp-session-notifications.test.ts +++ b/src/main/acp/acp-session-notifications.test.ts @@ -24,11 +24,16 @@ afterEach(() => describe('ACP session update compatibility', () => { it.each([ ['known', { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'hi' } }], + // Unfamiliar enum values stay typed: the generated enums are open. + ['known', { sessionUpdate: 'tool_call', toolCallId: 't', title: 'Search', kind: 'web_search' }], + ['known', { sessionUpdate: 'tool_call_update', toolCallId: 't', status: 'cancelled' }], [ - 'unrecognized', - { sessionUpdate: 'tool_call', toolCallId: 't', title: 'Search', kind: 'web_search' } + 'known', + { + sessionUpdate: 'plan', + entries: [{ content: 'Ship', priority: 'urgent', status: 'blocked' }] + } ], - ['unrecognized', { sessionUpdate: 'tool_call_update', toolCallId: 't', status: 'cancelled' }], ['unrecognized', { sessionUpdate: 'vendor_usage', tokens: 1 }], [ 'unrecognized', diff --git a/src/main/acp/generated/acp-protocol.generated.ts b/src/main/acp/generated/acp-protocol.generated.ts index 4438895c9e31..88b6479d41f5 100644 --- a/src/main/acp/generated/acp-protocol.generated.ts +++ b/src/main/acp/generated/acp-protocol.generated.ts @@ -1,5 +1,7 @@ -// Generated by config/scripts/acp/generate-protocol.mjs; do not edit. +// Generated by config/scripts/acp/generate-protocol.mjs; do not edit. Regenerate: pnpm run generate:acp-protocol // ACP schema-v1.21.0, legacy model API v0.11.6; SPDX-License-Identifier: Apache-2.0. +// Inputs sha256: schema 7f77702b34e0a0558e77220e9007bf8ee161a976bb8ac5021aba1b7e7b2c5708, legacy b3cf8687d979c98c009f0fbcf8f0c237645b82ac2d2f0a3ebca683f963c3d581, license f250d08cee4549b22b3b4aaaf3a743473336fd280316df5d0340717e5127a221, generator 320945b1687098a7566f15e853c4fb846d3aa0c9bf8b6b67acbd8508fc304e0b +// Body sha256: 02c2a5308636bb6383bfa67745d530680552e562f020b705430a40b4c08c2f8e /* Apache License Version 2.0, January 2004 @@ -196,6 +198,8 @@ import { z } from 'zod' export const ACP_SCHEMA_RELEASE = 'schema-v1.21.0' export const ACP_LEGACY_MODEL_SCHEMA_RELEASE = 'v0.11.6' export const ACP_PROTOCOL_VERSION = 1 +// An enum value this schema release does not name; `string & {}` keeps the known literals narrowable. +const otherString = z.custom((value) => typeof value === 'string') export const ProtocolVersionSchema = z.number().int().min(0).max(65535) export type ProtocolVersion = z.infer @@ -283,7 +287,8 @@ export type ClientNesCapabilities = z.infer export const PositionEncodingKindSchema = z.union([ z.literal('utf-16'), z.literal('utf-32'), - z.literal('utf-8') + z.literal('utf-8'), + otherString ]) export type PositionEncodingKind = z.infer @@ -398,7 +403,11 @@ export const NesDocumentDidOpenCapabilitiesSchema = z.looseObject({ }) export type NesDocumentDidOpenCapabilities = z.infer -export const TextDocumentSyncKindSchema = z.union([z.literal('full'), z.literal('incremental')]) +export const TextDocumentSyncKindSchema = z.union([ + z.literal('full'), + z.literal('incremental'), + otherString +]) export type TextDocumentSyncKind = z.infer export const NesDocumentDidChangeCapabilitiesSchema = z.looseObject({ @@ -645,7 +654,7 @@ export const SessionConfigOptionCategorySchema = z.union([ z.literal('model'), z.literal('model_config'), z.literal('thought_level'), - z.string() + otherString ]) export type SessionConfigOptionCategory = z.infer @@ -741,7 +750,7 @@ export const ResumeSessionResponseSchema = z.looseObject({ }) export type ResumeSessionResponse = z.infer -export const RoleSchema = z.union([z.literal('assistant'), z.literal('user')]) +export const RoleSchema = z.union([z.literal('assistant'), z.literal('user'), otherString]) export type Role = z.infer export const AnnotationsSchema = z.looseObject({ @@ -838,7 +847,8 @@ export const StopReasonSchema = z.union([ z.literal('max_tokens'), z.literal('max_turn_requests'), z.literal('refusal'), - z.literal('cancelled') + z.literal('cancelled'), + otherString ]) export type StopReason = z.infer @@ -889,7 +899,8 @@ export const ToolKindSchema = z.union([ z.literal('think'), z.literal('fetch'), z.literal('switch_mode'), - z.literal('other') + z.literal('other'), + otherString ]) export type ToolKind = z.infer @@ -897,7 +908,8 @@ export const ToolCallStatusSchema = z.union([ z.literal('pending'), z.literal('in_progress'), z.literal('completed'), - z.literal('failed') + z.literal('failed'), + otherString ]) export type ToolCallStatus = z.infer @@ -969,14 +981,16 @@ export type ToolCallUpdate = z.infer export const PlanEntryPrioritySchema = z.union([ z.literal('high'), z.literal('medium'), - z.literal('low') + z.literal('low'), + otherString ]) export type PlanEntryPriority = z.infer export const PlanEntryStatusSchema = z.union([ z.literal('pending'), z.literal('in_progress'), - z.literal('completed') + z.literal('completed'), + otherString ]) export type PlanEntryStatus = z.infer @@ -1102,7 +1116,7 @@ export const CompactionStatusSchema = z.union([ z.literal('completed'), z.literal('failed'), z.literal('cancelled'), - z.string() + otherString ]) export type CompactionStatus = z.infer @@ -1185,7 +1199,8 @@ export const PermissionOptionKindSchema = z.union([ z.literal('allow_once'), z.literal('allow_always'), z.literal('reject_once'), - z.literal('reject_always') + z.literal('reject_always'), + otherString ]) export type PermissionOptionKind = z.infer From 0db8b0c8f73b8aeef5db407b1ba1f64ebe307137 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 00:16:44 -0700 Subject: [PATCH 38/62] Land the ACP runtime contract the agent adapters use - Deliver notifications other than session/update through onExtensionNotification, in arrival order with session updates. - Accept _meta on prompt, setMode, setModel, setConfigOption and cancel. - cancel() always sends session/cancel once the session runs, since the agent can be in a turn it began itself; only a successful send is shared, so a failed write is retried. - Cancel aborts each open agent request's signal and lets its handler send its own answer; -32800 only when the handler rejects. - Permission requests validate only the session, tool call id and options; unreadable fields are dropped with a diagnostic, and any answer Orca cannot send is `cancelled` instead of a JSON-RPC error. Agent-started turns may ask; whether to show it is the caller's decision. - AcpAgentError marks the agent's own errors; AcpInvalidResponseError keeps the raw answer and validation issues for answers Orca could not read. - Lines over the size limit are classified by prefix (shared with the Codex reader): the owed request fails, an oversized agent request is answered with an error, and an unattributable response closes the connection. --- src/main/acp/acp-errors.ts | 37 +++- src/main/acp/acp-incoming-requests.ts | 48 +++-- src/main/acp/acp-json-rpc-peer.test.ts | 44 ++++- src/main/acp/acp-json-rpc-peer.ts | 60 +++--- src/main/acp/acp-oversized-lines.ts | 38 ++++ src/main/acp/acp-peer-limits.ts | 21 +++ src/main/acp/acp-permission-requests.test.ts | 120 ++++++++++++ src/main/acp/acp-permission-requests.ts | 161 +++++++++++------ src/main/acp/acp-prompt-cancel.ts | 46 +++++ src/main/acp/acp-session-agent-turns.test.ts | 148 +++++++++++++++ src/main/acp/acp-session-events.ts | 26 +++ src/main/acp/acp-session-lifecycle.test.ts | 24 ++- src/main/acp/acp-session-runtime.test.ts | 23 ++- src/main/acp/acp-session-runtime.ts | 171 +++++++++--------- .../codex/codex-app-server-record-dispatch.ts | 2 +- .../json-rpc-record-prefix.ts} | 0 16 files changed, 759 insertions(+), 210 deletions(-) create mode 100644 src/main/acp/acp-oversized-lines.ts create mode 100644 src/main/acp/acp-peer-limits.ts create mode 100644 src/main/acp/acp-permission-requests.test.ts create mode 100644 src/main/acp/acp-prompt-cancel.ts create mode 100644 src/main/acp/acp-session-agent-turns.test.ts create mode 100644 src/main/acp/acp-session-events.ts rename src/{main/codex/codex-app-server-record-prefix.ts => shared/json-rpc-record-prefix.ts} (100%) diff --git a/src/main/acp/acp-errors.ts b/src/main/acp/acp-errors.ts index 41aff9694e2e..edcc93de077f 100644 --- a/src/main/acp/acp-errors.ts +++ b/src/main/acp/acp-errors.ts @@ -11,7 +11,16 @@ export class AcpRpcError extends Error { } } -export class AcpAuthRequiredError extends AcpRpcError { +/** The agent's own error answer to a request: it read the request and refused it. Errors Orca + * raises about a request (a timeout, an unreadable answer, a closed connection) are not this. */ +export class AcpAgentError extends AcpRpcError { + constructor(code: number, message: string, data?: unknown) { + super(code, message, data) + this.name = 'AcpAgentError' + } +} + +export class AcpAuthRequiredError extends AcpAgentError { constructor( message: string, data?: unknown, @@ -22,6 +31,32 @@ export class AcpAuthRequiredError extends AcpRpcError { } } +/** Orca could not read the agent's answer; `data` keeps the raw answer, `issues` why it failed. */ +export class AcpInvalidResponseError extends AcpRpcError { + constructor( + message: string, + raw: unknown, + readonly issues?: unknown + ) { + super(-32603, message, raw) + this.name = 'AcpInvalidResponseError' + } +} + +/** A line from the agent exceeded the framing limit, so the message it carried was never read. */ +export class AcpFrameTooLargeError extends Error { + constructor( + readonly method: string | null, + readonly observedBytes: number, + readonly maxBytes: number + ) { + super( + `ACP${method ? ` ${method}` : ''} message exceeds ${maxBytes} byte limit (${observedBytes} bytes received)` + ) + this.name = 'AcpFrameTooLargeError' + } +} + export class AcpConnectionClosedError extends Error { constructor(message = 'ACP connection closed') { super(message) diff --git a/src/main/acp/acp-incoming-requests.ts b/src/main/acp/acp-incoming-requests.ts index b565c00c207a..92e50219e515 100644 --- a/src/main/acp/acp-incoming-requests.ts +++ b/src/main/acp/acp-incoming-requests.ts @@ -1,7 +1,12 @@ import { AcpRpcError } from './acp-errors' import type { AcpJsonRpcMessage, AcpPeerHandlers } from './acp-json-rpc-peer' -type OpenRequest = { method: string; controller: AbortController; abandon: () => void } +type OpenRequest = { + controller: AbortController + abandon: () => void + closed: boolean + cancelled: boolean +} export class AcpIncomingRequests { private readonly open = new Map() @@ -16,22 +21,20 @@ export class AcpIncomingRequests { close(error: Error): void { for (const request of this.open.values()) { + request.closed = true request.controller.abort(error) request.abandon() } this.open.clear() } - cancel(exceptMethod?: string): void { - for (const [id, request] of this.open) { - if (request.method === exceptMethod) { - continue + // The handler still answers (a permission answers `cancelled`); -32800 only if it rejects. + cancel(): void { + for (const request of this.open.values()) { + if (!request.cancelled) { + request.cancelled = true + request.controller.abort(new AcpRpcError(-32800, 'Request cancelled')) } - const error = new AcpRpcError(-32800, 'Request cancelled') - this.open.delete(id) - request.controller.abort(error) - request.abandon() - void this.sendError(id, error) } } @@ -41,7 +44,7 @@ export class AcpIncomingRequests { return } if (this.open.size >= this.capacity) { - void this.sendError(id, new AcpRpcError(-32603, 'ACP incoming request capacity exceeded')) + this.refuse(id, new AcpRpcError(-32603, 'ACP incoming request capacity exceeded')) return } const controller = new AbortController() @@ -49,9 +52,10 @@ export class AcpIncomingRequests { const abandoned = new Promise((_resolve, reject) => { abandon = () => reject(controller.signal.reason) }) - this.open.set(id, { method, controller, abandon }) + const request: OpenRequest = { controller, abandon, closed: false, cancelled: false } + this.open.set(id, request) const retire = (): void => { - if (this.open.get(id)?.controller === controller) { + if (this.open.get(id) === request) { this.open.delete(id) } } @@ -59,7 +63,7 @@ export class AcpIncomingRequests { abandoned, Promise.resolve().then(() => { if (controller.signal.aborted) { - return undefined + throw controller.signal.reason } if (!this.handler) { throw new AcpRpcError(-32601, `Unknown ACP client method: ${method}`) @@ -68,7 +72,7 @@ export class AcpIncomingRequests { }) ]) .then(async (result) => { - if (controller.signal.aborted) { + if (request.closed) { return } // The agent may reuse the id as soon as it reads the response. @@ -76,15 +80,17 @@ export class AcpIncomingRequests { await this.send({ jsonrpc: '2.0', id, result: result ?? null }) }) .catch(async (error) => { - if (controller.signal.aborted) { + if (request.closed) { return } retire() await this.sendError( id, - error instanceof AcpRpcError - ? error - : new AcpRpcError(-32603, error instanceof Error ? error.message : String(error)) + request.cancelled + ? new AcpRpcError(-32800, 'Request cancelled') + : error instanceof AcpRpcError + ? error + : new AcpRpcError(-32603, error instanceof Error ? error.message : String(error)) ) }) .finally(() => { @@ -93,6 +99,10 @@ export class AcpIncomingRequests { }) } + refuse(id: string | number | null, error: AcpRpcError): void { + void this.sendError(id, error) + } + private async sendError(id: string | number | null, error: AcpRpcError): Promise { try { await this.send({ diff --git a/src/main/acp/acp-json-rpc-peer.test.ts b/src/main/acp/acp-json-rpc-peer.test.ts index 9ead2e2ddc0c..3d74a9c1c7cc 100644 --- a/src/main/acp/acp-json-rpc-peer.test.ts +++ b/src/main/acp/acp-json-rpc-peer.test.ts @@ -1,7 +1,12 @@ import { PassThrough, Writable } from 'node:stream' import { afterEach, describe, expect, it, vi } from 'vitest' import { AcpJsonRpcPeer, type AcpPeerHandlers, type AcpPeerOptions } from './acp-json-rpc-peer' -import { AcpConnectionClosedError, AcpRequestTimeoutError, AcpRpcError } from './acp-errors' +import { + AcpConnectionClosedError, + AcpFrameTooLargeError, + AcpRequestTimeoutError, + AcpRpcError +} from './acp-errors' import { AcpScriptedAgent, deferred, tick } from './acp-scripted-agent.test-support' const peers: AcpJsonRpcPeer[] = [] @@ -98,10 +103,45 @@ describe('ACP JSON-RPC peer', () => { expect(await peer.request('valid', {})).toBe('ok') expect(notified).toEqual(['✓']) expect(diagnostics).toContain('Ignored ACP line: invalid-json') - expect(diagnostics).toContain('Ignored ACP line: line-too-long') + expect(diagnostics).toContain('Ignored ACP line: line-too-long (unknown)') expect(diagnostics).toContain('Ignored invalid ACP JSON-RPC envelope') }) + it('settles whoever was owed a message that exceeded the line limit', async () => { + const diagnostics: string[] = [] + const { peer, agent } = fixture( + { onDiagnostic: (message) => diagnostics.push(message), onRequest: () => 'unused' }, + { maxLineBytes: 200 } + ) + const filler = 'x'.repeat(300) + const big = peer.request('session/load', {}) + agent.stdout.write(`{"jsonrpc":"2.0","id":1,"result":{"history":"${filler}"}}\n`) + const lost = await big.catch((error: unknown) => error) + expect(lost).toBeInstanceOf(AcpFrameTooLargeError) + expect(lost).toMatchObject({ method: 'session/load', maxBytes: 200 }) + const answer = new Promise((resolve) => { + agent.stdin.on('data', (chunk: string) => resolve(JSON.parse(chunk))) + }) + agent.stdout.write( + `{"jsonrpc":"2.0","id":"q","method":"session/request_permission","params":{"diff":"${filler}"}}\n` + ) + expect(await answer).toMatchObject({ id: 'q', error: { code: -32600 } }) + agent.stdout.write(`{"jsonrpc":"2.0","method":"session/update","params":{"x":"${filler}"}}\n`) + agent.stdout.write(`${filler}\n`) + await tick() + expect(peer.closed).toBe(false) + expect(diagnostics).toEqual([ + 'Ignored ACP line: line-too-long (response)', + 'Ignored ACP line: line-too-long (server-request)', + 'Ignored ACP line: line-too-long (notification)', + 'Ignored ACP line: line-too-long (unknown)' + ]) + const stranded = peer.request('session/prompt', {}) + agent.stdout.write(`{"jsonrpc":"2.0","id":2,"vendor":"${filler}"}\n`) + await expect(stranded).rejects.toBeInstanceOf(AcpFrameTooLargeError) + expect(peer.closed).toBe(true) + }) + it('rejects malformed matching responses instead of leaving calls pending', async () => { const { peer, agent } = fixture() const rejected = expect(peer.request('wait', {})).rejects.toMatchObject({ code: -32603 }) diff --git a/src/main/acp/acp-json-rpc-peer.ts b/src/main/acp/acp-json-rpc-peer.ts index 08df74ef89ae..2a2cb867b08c 100644 --- a/src/main/acp/acp-json-rpc-peer.ts +++ b/src/main/acp/acp-json-rpc-peer.ts @@ -1,12 +1,18 @@ import type { Readable, Writable } from 'node:stream' import { z } from 'zod' -import { isSafeTimerDelayMs } from '../../shared/timer-delay' import { createIncrementalNdjsonFramer, encodeNdjson } from '../../shared/main-process-ndjson-framer' -import { AcpConnectionClosedError, AcpRequestTimeoutError, AcpRpcError } from './acp-errors' +import { + AcpAgentError, + AcpConnectionClosedError, + AcpInvalidResponseError, + AcpRequestTimeoutError +} from './acp-errors' import { AcpIncomingRequests } from './acp-incoming-requests' +import { bounded, requestTimeout } from './acp-peer-limits' +import { settleOversizedAcpLine } from './acp-oversized-lines' import { AcpWriteQueue } from './acp-write-queue' import { detachAcpStreamErrorHandler } from './acp-stdio-error-boundary' @@ -42,6 +48,7 @@ export type AcpPeerOptions = { requestTimeoutMs?: number | null } type Pending = { + method: string resolve: (value: unknown) => void reject: (error: Error) => void timer?: ReturnType @@ -83,7 +90,15 @@ export class AcpJsonRpcPeer { ) this.framer = createIncrementalNdjsonFramer( (record) => this.dispatch(record), - (rejected) => this.diagnose(`Ignored ACP line: ${rejected.kind}`), + (rejected) => + rejected.kind === 'line-too-long' + ? settleOversizedAcpLine(rejected, { + rejectPending: (id, error) => this.rejectPending(id, error), + refuse: (id, error) => this.incoming.refuse(id, error), + close: (error) => this.close(error), + diagnose: (message) => this.diagnose(message) + }) + : this.diagnose(`Ignored ACP line: ${rejected.kind}`), { maxLineBytes: this.maxLineBytes } ) input.setEncoding('utf8') @@ -134,7 +149,7 @@ export class AcpJsonRpcPeer { controller.abort(error) reject(error) }, timeoutMs) - this.pending.set(id, { resolve, reject, timer }) + this.pending.set(id, { method, resolve, reject, timer }) void this.send({ jsonrpc: '2.0', id, method, params }, controller.signal).catch((error) => { const pending = this.pending.get(id) if (!pending) { @@ -151,8 +166,9 @@ export class AcpJsonRpcPeer { return this.send({ jsonrpc: '2.0', method, params }) } - cancelIncomingRequests(exceptMethod?: string): void { - this.incoming.cancel(exceptMethod) + /** Aborts every open agent request's signal; each handler still sends its own answer. */ + cancelIncomingRequests(): void { + this.incoming.cancel() } close(error: Error = new AcpConnectionClosedError()): void { @@ -210,12 +226,12 @@ export class AcpJsonRpcPeer { } } - private rejectInvalidResponse(id: number, raw: unknown): void { + private rejectPending(id: number, error: (method: string) => Error): void { const pending = this.pending.get(id) if (pending) { this.pending.delete(id) clearTimeout(pending.timer) - pending.reject(new AcpRpcError(-32603, 'Invalid ACP response envelope', raw)) + pending.reject(error(pending.method)) } } @@ -230,7 +246,7 @@ export class AcpJsonRpcPeer { .object({ id: z.number(), method: z.undefined().optional() }) .safeParse(record) if (response.success) { - this.rejectInvalidResponse(response.data.id, record) + this.rejectPending(response.data.id, invalidEnvelope(record)) } return } @@ -254,7 +270,7 @@ export class AcpJsonRpcPeer { if ('result' in frame === 'error' in frame) { this.diagnose('Ignored invalid ACP response') if (typeof frame.id === 'number') { - this.rejectInvalidResponse(frame.id, record) + this.rejectPending(frame.id, invalidEnvelope(record)) } return } @@ -271,10 +287,10 @@ export class AcpJsonRpcPeer { const parsedError = errorSchema.safeParse(frame.error) if (!parsedError.success) { this.diagnose('Invalid ACP error response') - pending.reject(new AcpRpcError(-32603, 'Invalid ACP error response', frame.error)) + pending.reject(new AcpInvalidResponseError('Invalid ACP error response', frame.error)) } else { const error = parsedError.data - pending.reject(new AcpRpcError(error.code, error.message, error.data)) + pending.reject(new AcpAgentError(error.code, error.message, error.data)) } } else { pending.resolve(frame.result) @@ -282,22 +298,6 @@ export class AcpJsonRpcPeer { } } -function bounded(value: number | undefined, fallback: number): number { - if (value === undefined) { - return fallback - } - if (!Number.isSafeInteger(value) || value <= 0) { - throw new Error('ACP limits must be positive finite integers') - } - return value -} - -function requestTimeout(value: number | null | undefined): number | null { - if (value == null) { - return null - } - if (!isSafeTimerDelayMs(value) || value <= 0) { - throw new Error('ACP timeouts must be positive finite timer durations') - } - return value +function invalidEnvelope(raw: unknown): () => Error { + return () => new AcpInvalidResponseError('Invalid ACP response envelope', raw) } diff --git a/src/main/acp/acp-oversized-lines.ts b/src/main/acp/acp-oversized-lines.ts new file mode 100644 index 000000000000..a4329b847821 --- /dev/null +++ b/src/main/acp/acp-oversized-lines.ts @@ -0,0 +1,38 @@ +import { classifyJsonRpcPrefix } from '../../shared/json-rpc-record-prefix' +import type { NdjsonRejectedRecord } from '../../shared/main-process-ndjson-framer' +import { AcpFrameTooLargeError, AcpRpcError } from './acp-errors' + +export type AcpOversizedLineTarget = { + rejectPending: (id: number, error: (method: string) => Error) => void + refuse: (id: string | number, error: AcpRpcError) => void + close: (error: Error) => void + diagnose: (message: string) => void +} + +/** The Codex reader's prefix classification: settle whoever was owed the message that was lost. */ +export function settleOversizedAcpLine( + rejected: NdjsonRejectedRecord & { kind: 'line-too-long' }, + target: AcpOversizedLineTarget +): void { + const { observedBytes, maxLineBytes } = rejected + const record = classifyJsonRpcPrefix(rejected.prefix) + target.diagnose(`Ignored ACP line: line-too-long (${record.kind})`) + if (record.kind === 'server-request') { + target.refuse( + record.id, + new AcpRpcError(-32600, `ACP request exceeds ${maxLineBytes} byte limit`, { + method: record.method, + observedBytes + }) + ) + } else if (record.kind === 'response') { + target.rejectPending( + record.id, + (method) => new AcpFrameTooLargeError(method, observedBytes, maxLineBytes) + ) + } else if (record.kind === 'response-unknown') { + // A numeric id with no readable result: any pending call could be the one that never settles. + target.close(new AcpFrameTooLargeError(null, observedBytes, maxLineBytes)) + } + // A notification or non-JSON-RPC output (a stray log line) settles nothing; closing would end the session. +} diff --git a/src/main/acp/acp-peer-limits.ts b/src/main/acp/acp-peer-limits.ts new file mode 100644 index 000000000000..51f98809472e --- /dev/null +++ b/src/main/acp/acp-peer-limits.ts @@ -0,0 +1,21 @@ +import { isSafeTimerDelayMs } from '../../shared/timer-delay' + +export function bounded(value: number | undefined, fallback: number): number { + if (value === undefined) { + return fallback + } + if (!Number.isSafeInteger(value) || value <= 0) { + throw new Error('ACP limits must be positive finite integers') + } + return value +} + +export function requestTimeout(value: number | null | undefined): number | null { + if (value == null) { + return null + } + if (!isSafeTimerDelayMs(value) || value <= 0) { + throw new Error('ACP timeouts must be positive finite timer durations') + } + return value +} diff --git a/src/main/acp/acp-permission-requests.test.ts b/src/main/acp/acp-permission-requests.test.ts new file mode 100644 index 000000000000..f51a7429853c --- /dev/null +++ b/src/main/acp/acp-permission-requests.test.ts @@ -0,0 +1,120 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { AcpSessionRuntime, type AcpSessionRuntimeOptions } from './acp-session-runtime' +import { AcpScriptedAgent, deferred, tick } from './acp-scripted-agent.test-support' +import type { RequestPermissionRequest } from './generated/acp-protocol.generated' + +const opened: { close: () => void }[] = [] +const startOptions = { cwd: '/runtime/project', mcpServers: [] } +const allow = { optionId: 'allow', name: 'Allow', kind: 'allow_once' } +const toolCall = { toolCallId: 'tool-1', title: 'Edit file' } +function fixture(options: AcpSessionRuntimeOptions = {}) { + const agent = new AcpScriptedAgent() + const diagnostics: string[] = [] + const runtime = new AcpSessionRuntime(agent.stdout, agent.stdin, { + onDiagnostic: (message) => diagnostics.push(message), + ...options + }) + opened.push(agent, runtime) + agent.on('initialize', (frame) => agent.reply(frame, { protocolVersion: 1 })) + agent.on('session/new', (frame) => agent.reply(frame, { sessionId: 'session-1' })) + agent.on('session/prompt', () => {}) + return { agent, runtime, diagnostics } +} +afterEach(() => + opened + .splice(0) + .toReversed() + .forEach((resource) => resource.close()) +) + +describe('ACP permission requests', () => { + it.each([ + ['a newer tool kind', { ...toolCall, kind: 'web_search' }, allow], + ['a newer tool status', { ...toolCall, status: 'cancelled' }, allow], + ['a newer option kind', toolCall, { ...allow, kind: 'allow_for_session' }] + ])('delivers a permission with %s to the caller unchanged', async (_label, call, option) => { + const asked = vi.fn((_request: RequestPermissionRequest) => ({ + outcome: { outcome: 'selected' as const, optionId: option.optionId } + })) + const { agent, runtime, diagnostics } = fixture({ onPermission: asked }) + await runtime.start(startOptions) + void runtime.prompt([{ type: 'text', text: 'hi' }]).catch(() => {}) + const params = { sessionId: 'session-1', toolCall: call, options: [option] } + expect(await agent.request('p', 'session/request_permission', params)).toMatchObject({ + result: { outcome: { outcome: 'selected', optionId: option.optionId } } + }) + expect(asked.mock.calls[0][0]).toEqual(params) + expect(diagnostics).toEqual([]) + }) + + it('delivers a permission whose other fields are unreadable, dropping only those fields', async () => { + const asked = vi.fn((_request: RequestPermissionRequest) => ({ + outcome: { outcome: 'cancelled' as const } + })) + const { agent, runtime, diagnostics } = fixture({ onPermission: asked }) + await runtime.start(startOptions) + void runtime.prompt([{ type: 'text', text: 'hi' }]).catch(() => {}) + const content = [{ type: 'content', content: { type: 'reasoning_summary', text: 'x' } }] + await agent.request('p', 'session/request_permission', { + sessionId: 'session-1', + toolCall: { ...toolCall, content, vendor: 'kept' }, + options: [{ optionId: 'allow', kind: 'allow_once' }, { name: 'No id' }] + }) + expect(asked.mock.calls[0][0]).toEqual({ + sessionId: 'session-1', + toolCall: { ...toolCall, vendor: 'kept' }, + options: [{ optionId: 'allow', name: 'allow', kind: 'allow_once' }] + }) + expect(diagnostics).toEqual([ + 'Delivered ACP permission request without unreadable fields: options.1, toolCall.content' + ]) + }) + + it.each([ + [ + 'the handler throws', + () => { + throw new Error('renderer gone') + }, + 'handler failed: Error: renderer gone' + ], + ['the handler answers nonsense', () => ({ outcome: 'yes' }), 'invalid handler response'] + ])('answers cancelled with a diagnostic when %s', async (_label, onPermission, problem) => { + // Reflect.apply returns any, so a deliberately ill-typed answer needs no type assertion. + const handler: AcpSessionRuntimeOptions['onPermission'] = (request, context) => + Reflect.apply(onPermission, undefined, [request, context]) + const { agent, runtime, diagnostics } = fixture({ onPermission: handler }) + await runtime.start(startOptions) + void runtime.prompt([{ type: 'text', text: 'hi' }]).catch(() => {}) + expect( + await agent.request('p', 'session/request_permission', { + sessionId: 'session-1', + toolCall, + options: [allow] + }) + ).toMatchObject({ result: { outcome: { outcome: 'cancelled' } } }) + expect(diagnostics).toEqual([`Answered ACP permission request cancelled: ${problem}`]) + }) + + it('lets a turn the agent began itself ask for permission and cancels it with session/cancel', async () => { + const asked = deferred() + const { agent, runtime } = fixture({ + onPermission: (_request, context) => { + asked.resolve(context.signal) + return new Promise(() => {}) + } + }) + await runtime.start(startOptions) + const answer = agent.request('p', 'session/request_permission', { + sessionId: 'session-1', + toolCall, + options: [allow] + }) + const signal = await asked.promise + await runtime.cancel() + expect(signal.aborted).toBe(true) + expect(await answer).toMatchObject({ result: { outcome: { outcome: 'cancelled' } } }) + await tick() + expect(agent.frames.filter((frame) => frame.method === 'session/cancel')).toHaveLength(1) + }) +}) diff --git a/src/main/acp/acp-permission-requests.ts b/src/main/acp/acp-permission-requests.ts index 4701359d11b1..c39f4a4c9022 100644 --- a/src/main/acp/acp-permission-requests.ts +++ b/src/main/acp/acp-permission-requests.ts @@ -1,7 +1,10 @@ -import { AcpRpcError } from './acp-errors' +import { z } from 'zod' import type { AcpRequestContext } from './acp-json-rpc-peer' import { + PermissionOptionSchema, + RequestPermissionRequestSchema, RequestPermissionResponseSchema, + ToolCallUpdateSchema, type RequestPermissionRequest, type RequestPermissionResponse } from './generated/acp-protocol.generated' @@ -12,64 +15,112 @@ export type AcpPermissionHandler = ( ) => RequestPermissionResponse | Promise const cancelled: RequestPermissionResponse = { outcome: { outcome: 'cancelled' } } -type OpenPermission = { sessionId: string; cancel: () => void } +const routingSchema = z.looseObject({ + sessionId: z.string(), + toolCall: z.looseObject({ toolCallId: z.string() }), + options: z.array(z.unknown()) +}) +const optionRoutingSchema = z.looseObject({ optionId: z.string() }) -export class AcpPermissionRequests { - private readonly open = new Set() - - cancel(sessionId: string): void { - for (const permission of this.open) { - if (permission.sessionId === sessionId) { - permission.cancel() - } +// Keeps every field the schema does not know or can read; drops (and names) the unreadable ones. +function readableFields( + shape: Record, + value: Record, + path: string, + dropped: string[] +): Record { + const kept: Record = {} + for (const [key, field] of Object.entries(value)) { + if (!Object.hasOwn(shape, key) || shape[key].safeParse(field).success) { + kept[key] = field + } else { + dropped.push(`${path}${key}`) } } + return kept +} - handle( - request: RequestPermissionRequest, - context: AcpRequestContext, - handler: AcpPermissionHandler | undefined - ): Promise { - if (!handler || context.signal.aborted) { - return Promise.resolve(cancelled) +/** Validates only what answering needs (session, tool call id, options with ids); null if unusable. */ +export function readAcpPermissionRequest( + params: unknown, + diagnose: (message: string) => void +): RequestPermissionRequest | null { + const strict = RequestPermissionRequestSchema.safeParse(params) + if (strict.success) { + return strict.data + } + const routing = routingSchema.safeParse(params) + if (!routing.success) { + return null + } + const { toolCall, options: offered, ...rest } = routing.data + const dropped: string[] = [] + const options = offered.flatMap((option, index) => { + const ids = optionRoutingSchema.safeParse(option) + if (!ids.success) { + dropped.push(`options.${index}`) + return [] } - return new Promise((resolve, reject) => { - const controller = new AbortController() - let settled = false - const finish = (response: RequestPermissionResponse | Error): void => { - if (settled) { - return - } - settled = true - this.open.delete(permission) - context.signal.removeEventListener('abort', permission.cancel) - controller.abort() - if (response instanceof Error) { - reject(response) - } else { - resolve(response) - } + const named = { + ...ids.data, + name: typeof ids.data.name === 'string' ? ids.data.name : ids.data.optionId + } + const parsed = PermissionOptionSchema.safeParse(named) + if (!parsed.success) { + dropped.push(`options.${index}`) + return [] + } + return [parsed.data] + }) + if (options.length === 0) { + return null + } + const request = RequestPermissionRequestSchema.safeParse({ + ...readableFields(RequestPermissionRequestSchema.shape, rest, '', dropped), + toolCall: readableFields(ToolCallUpdateSchema.shape, toolCall, 'toolCall.', dropped), + options + }) + if (!request.success) { + return null + } + diagnose(`Delivered ACP permission request without unreadable fields: ${dropped.join(', ')}`) + return request.data +} + +/** Asks the caller; any answer Orca cannot send (a throw, a bad reply, an unoffered option) is `cancelled`. */ +export function answerAcpPermission( + request: RequestPermissionRequest, + context: AcpRequestContext, + handler: AcpPermissionHandler | undefined, + diagnose: (message: string) => void +): Promise { + if (!handler || context.signal.aborted) { + return Promise.resolve(cancelled) + } + return new Promise((resolve) => { + const controller = new AbortController() + let settled = false + const finish = (response: RequestPermissionResponse, problem?: string): void => { + if (settled) { + return } - const permission: OpenPermission = { - sessionId: request.sessionId, - cancel: () => finish(cancelled) + settled = true + context.signal.removeEventListener('abort', onAbort) + controller.abort() + if (problem) { + diagnose(`Answered ACP permission request cancelled: ${problem}`) } - this.open.add(permission) - context.signal.addEventListener('abort', permission.cancel, { once: true }) - void Promise.resolve() - .then(() => { - if (controller.signal.aborted) { - return cancelled - } - return handler(request, { id: context.id, signal: controller.signal }) - }) - .then((response) => { - if (settled) { - return - } + resolve(response) + } + const onAbort = (): void => finish(cancelled) + context.signal.addEventListener('abort', onAbort, { once: true }) + void Promise.resolve() + .then(() => handler(request, { id: context.id, signal: controller.signal })) + .then( + (response) => { const parsed = RequestPermissionResponseSchema.safeParse(response) if (!parsed.success) { - finish(new AcpRpcError(-32603, 'Invalid permission handler response')) + finish(cancelled, 'invalid handler response') return } const outcome = parsed.data.outcome @@ -77,12 +128,12 @@ export class AcpPermissionRequests { outcome.outcome === 'selected' && !request.options.some((option) => option.optionId === outcome.optionId) ) { - finish(new AcpRpcError(-32603, 'Permission handler selected an unavailable option')) + finish(cancelled, 'handler selected an unavailable option') return } finish(parsed.data) - }) - .catch((error) => finish(error instanceof Error ? error : new Error(String(error)))) - }) - } + }, + (error) => finish(cancelled, `handler failed: ${String(error)}`) + ) + }) } diff --git a/src/main/acp/acp-prompt-cancel.ts b/src/main/acp/acp-prompt-cancel.ts new file mode 100644 index 000000000000..5208c6df1465 --- /dev/null +++ b/src/main/acp/acp-prompt-cancel.ts @@ -0,0 +1,46 @@ +import { AcpRequestTimeoutError } from './acp-errors' +import type { PromptResponse } from './generated/acp-protocol.generated' + +export type ActivePrompt = { + response: Promise + cancelling: boolean + cancelPromise?: Promise +} + +/** Sends `session/cancel` and waits, bounded, for Orca's prompt to settle; past the bound it closes. */ +export async function confirmAcpPromptCancel( + active: ActivePrompt, + connection: { + send: () => Promise + close: (error: Error) => void + closed: () => boolean + timeoutMs: number + } +): Promise { + let timer: ReturnType | undefined + const unconfirmed = new Promise((_resolve, reject) => { + timer = setTimeout(() => { + const error = new AcpRequestTimeoutError('session/cancel') + connection.close(error) + reject(error) + }, connection.timeoutMs) + }) + try { + await Promise.race([connection.send(), unconfirmed]).catch((error) => { + // Only a cancel that reached the agent is shared; a failed write is retried next call. + active.cancelPromise = undefined + active.cancelling = false + throw error + }) + await Promise.race([ + active.response.catch((error) => { + if (connection.closed()) { + throw error + } + }), + unconfirmed + ]) + } finally { + clearTimeout(timer) + } +} diff --git a/src/main/acp/acp-session-agent-turns.test.ts b/src/main/acp/acp-session-agent-turns.test.ts new file mode 100644 index 000000000000..474ba298184b --- /dev/null +++ b/src/main/acp/acp-session-agent-turns.test.ts @@ -0,0 +1,148 @@ +import { PassThrough } from 'node:stream' +import { afterEach, describe, expect, it } from 'vitest' +import { AcpAgentError, AcpInvalidResponseError } from './acp-errors' +import { AcpSessionRuntime, type AcpSessionRuntimeOptions } from './acp-session-runtime' +import { AcpScriptedAgent, type FakeFrame, tick } from './acp-scripted-agent.test-support' + +const opened: { close: () => void }[] = [] +const startOptions = { cwd: '/runtime/project', mcpServers: [] } +const prompt = [{ type: 'text', text: 'hello' }] as const +function fixture(options: AcpSessionRuntimeOptions = {}) { + const agent = new AcpScriptedAgent() + const runtime = new AcpSessionRuntime(agent.stdout, agent.stdin, options) + opened.push(agent, runtime) + agent.on('initialize', (frame) => agent.reply(frame, { protocolVersion: 1 })) + agent.on('session/new', (frame) => agent.reply(frame, { sessionId: 'session-1' })) + return { agent, runtime } +} +afterEach(() => + opened + .splice(0) + .toReversed() + .forEach((resource) => resource.close()) +) + +describe('ACP turns the agent begins and the runtime contract around them', () => { + it('delivers extension notifications in arrival order with session updates', async () => { + const order: string[] = [] + const { agent, runtime } = fixture({ + onExtensionNotification: (method, params) => order.push(`${method} ${JSON.stringify(params)}`) + }) + runtime.subscribe((event) => order.push(`update ${event.kind}`)) + await runtime.start(startOptions) + const completed = { sessionId: 'session-1', update: { type: 'turn_completed', prompt_id: 'p' } } + agent.stdout.write( + [ + { jsonrpc: '2.0', method: '_x.ai/session_notification', params: { started: true } }, + { + jsonrpc: '2.0', + method: 'session/update', + params: { + sessionId: 'session-1', + update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'x' } } + } + }, + { jsonrpc: '2.0', method: '_x.ai/session_notification', params: completed } + ] + .map((frame) => `${JSON.stringify(frame)}\n`) + .join('') + ) + await tick() + expect(order).toEqual([ + '_x.ai/session_notification {"started":true}', + 'update known', + `_x.ai/session_notification ${JSON.stringify(completed)}` + ]) + }) + + it('sends session/cancel with no prompt of Orca running, and _meta on every session call', async () => { + const { agent, runtime } = fixture() + agent.on('session/prompt', (frame) => agent.reply(frame, { stopReason: 'end_turn' })) + agent.on('session/set_mode', (frame) => agent.reply(frame, {})) + agent.on('session/set_model', (frame) => agent.reply(frame, {})) + agent.on('session/set_config_option', (frame) => agent.reply(frame, { configOptions: [] })) + await runtime.start(startOptions) + const meta = { traceId: 't' } + await runtime.prompt([...prompt], meta) + await runtime.setMode('plan', meta) + await runtime.setModel('model-1', meta) + await runtime.setConfigOption('effort', 'high', meta) + await runtime.cancel({ meta }) + await tick() + const sent = agent.frames.filter((frame) => frame.method?.startsWith('session/')) + expect(sent.map((frame) => frame.method)).toEqual([ + 'session/new', + 'session/prompt', + 'session/set_mode', + 'session/set_model', + 'session/set_config_option', + 'session/cancel' + ]) + for (const frame of sent.slice(1)) { + expect(frame.params).toMatchObject({ sessionId: 'session-1', _meta: meta }) + } + }) + + it("separates the agent's own errors from answers Orca could not read", async () => { + const { agent, runtime } = fixture() + await runtime.start(startOptions) + agent.on('session/set_mode', (frame) => agent.fail(frame, -32603, 'Agent broke', { why: 1 })) + const refused = await runtime.setMode('plan').catch((error: unknown) => error) + expect(refused).toBeInstanceOf(AcpAgentError) + expect(refused).toMatchObject({ code: -32603, data: { why: 1 } }) + agent.on('session/set_mode', (frame) => agent.reply(frame, 'not-an-object')) + const unreadable = await runtime.setMode('plan').catch((error: unknown) => error) + expect(unreadable).toBeInstanceOf(AcpInvalidResponseError) + expect(unreadable).not.toBeInstanceOf(AcpAgentError) + expect(unreadable).toMatchObject({ code: -32603, data: 'not-an-object' }) + expect(unreadable instanceof AcpInvalidResponseError && unreadable.issues).toBeTruthy() + }) + + it('completes a turn whose stop reason is newer than this schema', async () => { + const { agent, runtime } = fixture() + agent.on('session/prompt', (frame) => agent.reply(frame, { stopReason: 'context_exhausted' })) + await runtime.start(startOptions) + expect(await runtime.prompt([...prompt])).toEqual({ stopReason: 'context_exhausted' }) + }) + + it('retries a cancel whose write failed instead of returning the stale failure', async () => { + const agent = new AcpScriptedAgent() + const stdin = new PassThrough({ highWaterMark: 1 }) + const runtime = new AcpSessionRuntime(agent.stdout, stdin, { + peer: { maxQueuedWriteBytes: 1000 } + }) + opened.push(agent, runtime) + const frames: FakeFrame[] = [] + let promptFrame: FakeFrame | undefined + let buffer = '' + stdin.setEncoding('utf8').on('data', (chunk: string) => { + buffer += chunk + let newline: number + while ((newline = buffer.indexOf('\n')) !== -1) { + const frame: FakeFrame = JSON.parse(buffer.slice(0, newline)) + buffer = buffer.slice(newline + 1) + frames.push(frame) + if (frame.method === 'initialize') { + agent.reply(frame, { protocolVersion: 1 }) + } else if (frame.method === 'session/new') { + agent.reply(frame, { sessionId: 'session-1' }) + } else if (frame.method === 'session/prompt') { + promptFrame = frame + stdin.pause() + } else if (frame.method === 'session/cancel' && promptFrame) { + agent.reply(promptFrame, { stopReason: 'cancelled' }) + } + } + }) + await runtime.start(startOptions) + const turn = runtime.prompt([...prompt]) + await tick() + void runtime.setMode('y'.repeat(850)).catch(() => {}) + await expect(runtime.cancel()).rejects.toThrow(/capacity/) + stdin.resume() + await tick() + await runtime.cancel() + expect(await turn).toEqual({ stopReason: 'cancelled' }) + expect(frames.filter((frame) => frame.method === 'session/cancel')).toHaveLength(1) + }) +}) diff --git a/src/main/acp/acp-session-events.ts b/src/main/acp/acp-session-events.ts new file mode 100644 index 000000000000..47dd54c57a88 --- /dev/null +++ b/src/main/acp/acp-session-events.ts @@ -0,0 +1,26 @@ +import { z } from 'zod' +import { + SessionNotificationSchema, + type SessionNotification +} from './generated/acp-protocol.generated' + +const updateEnvelopeSchema = z.looseObject({ + sessionId: z.string(), + update: z.looseObject({ sessionUpdate: z.string() }) +}) + +export type AcpSessionEvent = + | { kind: 'known'; notification: SessionNotification } + | { kind: 'unrecognized'; sessionId: string; raw: z.infer } + +/** A `session/update`, typed when this build knows its kind; null when it is not one at all. */ +export function readAcpSessionEvent(params: unknown): AcpSessionEvent | null { + const envelope = updateEnvelopeSchema.safeParse(params) + if (!envelope.success) { + return null + } + const parsed = SessionNotificationSchema.safeParse(params) + return parsed.success + ? { kind: 'known', notification: parsed.data } + : { kind: 'unrecognized', sessionId: envelope.data.sessionId, raw: envelope.data } +} diff --git a/src/main/acp/acp-session-lifecycle.test.ts b/src/main/acp/acp-session-lifecycle.test.ts index 43f2815697da..092ed11e186c 100644 --- a/src/main/acp/acp-session-lifecycle.test.ts +++ b/src/main/acp/acp-session-lifecycle.test.ts @@ -247,24 +247,28 @@ describe('ACP caller-owned waits', () => { await expect(authenticated).resolves.toEqual({}) }) - it('lets cancel abort vendor decision hooks with request-cancelled instead of a timeout', async () => { - const entered = deferred() + it('lets cancel abort vendor hooks so each sends its own answer, or request-cancelled if it rejects', async () => { const { agent, runtime } = fixture({ - onRequest: (_method, _params, context) => { - entered.resolve(context.signal) - return new Promise(() => {}) - } + onRequest: (method, _params, context) => + new Promise((resolve, reject) => + context.signal.addEventListener('abort', () => + method === '_vendor/plan' + ? resolve({ outcome: 'abandoned' }) + : reject(new Error('stop')) + ) + ) }) agent.on('session/prompt', (frame) => agent.on('session/cancel', () => agent.reply(frame, { stopReason: 'cancelled' })) ) await runtime.start(startOptions) const pending = runtime.prompt([...prompt]) - const response = agent.request('question', '_vendor/question', {}) - const signal = await entered.promise + const question = agent.request('question', '_vendor/question', {}) + const plan = agent.request('plan', '_vendor/plan', {}) + await tick() await runtime.cancel() - expect(signal.aborted).toBe(true) - expect(await response).toMatchObject({ error: { code: -32800 } }) + expect(await question).toMatchObject({ error: { code: -32800 } }) + expect(await plan).toMatchObject({ result: { outcome: 'abandoned' } }) await pending }) diff --git a/src/main/acp/acp-session-runtime.test.ts b/src/main/acp/acp-session-runtime.test.ts index 068da3f4742f..b89e0b58059f 100644 --- a/src/main/acp/acp-session-runtime.test.ts +++ b/src/main/acp/acp-session-runtime.test.ts @@ -324,26 +324,35 @@ describe('ACP session runtime', () => { await expect(malformed.runtime.start(startOptions)).rejects.toMatchObject({ code: -32603 }) }) - it('rejects invalid permission requests and fails closed on an unavailable selected option', async () => { + it('rejects unroutable permission requests and answers an unavailable selection cancelled', async () => { + const diagnostics: string[] = [] const { runtime, agent } = fixture( {}, { - onPermission: () => ({ outcome: { outcome: 'selected', optionId: 'not-offered' } }) + onPermission: () => ({ outcome: { outcome: 'selected', optionId: 'not-offered' } }), + onDiagnostic: (message) => diagnostics.push(message) } ) agent.on('session/prompt', () => {}) await runtime.start(startOptions) - const prompt = runtime.prompt([...textPrompt]) - const rejected = expect(prompt).rejects.toBeInstanceOf(AcpConnectionClosedError) + void runtime.prompt([...textPrompt]).catch(() => {}) expect(await agent.request('invalid', 'session/request_permission', {})).toMatchObject({ error: { code: -32602 } }) + expect( + await agent.request('no-options', 'session/request_permission', { + ...permission, + options: [{ name: 'No id', kind: 'allow_once' }] + }) + ).toMatchObject({ error: { code: -32602 } }) expect( await agent.request('bad-selection', 'session/request_permission', permission) - ).toMatchObject({ error: { code: -32603 } }) + ).toMatchObject({ result: { outcome: { outcome: 'cancelled' } } }) + expect(diagnostics).toContain( + 'Answered ACP permission request cancelled: handler selected an unavailable option' + ) + // The session stays usable: a local permission failure is not a protocol failure. await expect(runtime.prompt([...textPrompt])).rejects.toThrow('already in progress') - runtime.close() - await rejected }) it('preserves unrecognized updates and isolates event listener failures', async () => { diff --git a/src/main/acp/acp-session-runtime.ts b/src/main/acp/acp-session-runtime.ts index 77e317362d13..7d185c108c4d 100644 --- a/src/main/acp/acp-session-runtime.ts +++ b/src/main/acp/acp-session-runtime.ts @@ -1,44 +1,51 @@ import type { Readable, Writable } from 'node:stream' -import { z } from 'zod' +import type { z } from 'zod' import { MAX_TIMER_DELAY_MS } from '../../shared/timer-delay' -import { AcpAuthRequiredError, AcpRequestTimeoutError, AcpRpcError } from './acp-errors' +import { + AcpAgentError, + AcpAuthRequiredError, + AcpInvalidResponseError, + AcpRpcError +} from './acp-errors' import { AcpJsonRpcPeer, type AcpPeerOptions, type AcpRequestContext } from './acp-json-rpc-peer' -import { AcpPermissionRequests, type AcpPermissionHandler } from './acp-permission-requests' +import { + answerAcpPermission, + readAcpPermissionRequest, + type AcpPermissionHandler +} from './acp-permission-requests' +import { readAcpSessionEvent, type AcpSessionEvent } from './acp-session-events' +import { confirmAcpPromptCancel, type ActivePrompt } from './acp-prompt-cancel' import { setupAcpSession, type AcpSessionStarted, type AcpSessionStartOptions } from './acp-session-setup' export type { AcpSessionStarted, AcpSessionStartOptions } from './acp-session-setup' +export type { AcpSessionEvent } from './acp-session-events' import { ACP_PROTOCOL_VERSION, InitializeResponseSchema, AuthenticateResponseSchema, PromptResponseSchema, - RequestPermissionRequestSchema, - SessionNotificationSchema, SetSessionModeResponseSchema, SetSessionModelResponseSchema, SetSessionConfigOptionResponseSchema, type InitializeRequest, type InitializeResponse, type AuthenticateResponse, + type CancelNotification, type PromptRequest, type PromptResponse, - type SessionNotification, type SetSessionConfigOptionRequest, type SetSessionConfigOptionResponse, + type SetSessionModeRequest, type SetSessionModeResponse, + type SetSessionModelRequest, type SetSessionModelResponse } from './generated/acp-protocol.generated' -const updateEnvelopeSchema = z.looseObject({ - sessionId: z.string(), - update: z.looseObject({ sessionUpdate: z.string() }) -}) -export type AcpSessionEvent = - | { kind: 'known'; notification: SessionNotification } - | { kind: 'unrecognized'; sessionId: string; raw: z.infer } +type Meta = PromptRequest['_meta'] +const withMeta = (meta: Meta): { _meta?: Meta } => (meta ? { _meta: meta } : {}) export type AcpSessionRuntimeOptions = { clientInfo?: InitializeRequest['clientInfo'] @@ -46,18 +53,15 @@ export type AcpSessionRuntimeOptions = { cancelTimeoutMs?: number onPermission?: AcpPermissionHandler onRequest?: (method: string, params: unknown, context: AcpRequestContext) => unknown + /** Agent notifications other than `session/update` (protocol extensions), delivered + * synchronously in arrival order with the `subscribe` events. */ + onExtensionNotification?: (method: string, params: unknown) => void onDiagnostic?: (message: string) => void onClose?: (error: Error) => void } -type ActivePrompt = { - response: Promise - cancelling: boolean - cancelPromise?: Promise -} export class AcpSessionRuntime { private readonly peer: AcpJsonRpcPeer - private readonly permissions = new AcpPermissionRequests() private readonly listeners = new Set<(event: AcpSessionEvent) => void>() private initialized?: Promise private starting?: Promise @@ -148,14 +152,14 @@ export class AcpSessionRuntime { return this.starting } - async prompt(prompt: PromptRequest['prompt']): Promise { + async prompt(prompt: PromptRequest['prompt'], meta?: Meta): Promise { if (this.activePrompt) { throw new Error('ACP prompt already in progress') } - const sessionId = this.sessionId() + const params: PromptRequest = { sessionId: this.sessionId(), prompt, ...withMeta(meta) } const active: ActivePrompt = { cancelling: false, - response: this.call('session/prompt', { sessionId, prompt }, PromptResponseSchema) + response: this.call('session/prompt', params, PromptResponseSchema) } this.activePrompt = active active.response = active.response.finally(() => { @@ -166,70 +170,64 @@ export class AcpSessionRuntime { return active.response } - cancel(): Promise { + /** Sends `session/cancel` whenever the session runs: the agent may be in a turn it began itself. + * With Orca's prompt running, also waits (bounded) for that prompt to settle. */ + cancel(options: { meta?: CancelNotification['_meta'] } = {}): Promise { if (!this.started) { return Promise.reject(new Error('ACP session has not started')) } - const sessionId = this.started.sessionId - this.permissions.cancel(sessionId) - this.peer.cancelIncomingRequests('session/request_permission') + const params: CancelNotification = { + sessionId: this.started.sessionId, + ...withMeta(options.meta) + } + this.peer.cancelIncomingRequests() const active = this.activePrompt if (!active) { - return Promise.resolve() + return this.peer.notify('session/cancel', params) } active.cancelling = true - active.cancelPromise ??= this.cancelActive(sessionId, active) + active.cancelPromise ??= confirmAcpPromptCancel(active, { + send: () => this.peer.notify('session/cancel', params), + close: (error) => this.peer.close(error), + closed: () => this.peer.closed, + timeoutMs: Math.min(this.options.cancelTimeoutMs ?? 10_000, MAX_TIMER_DELAY_MS) + }) return active.cancelPromise } - private async cancelActive(sessionId: string, active: ActivePrompt): Promise { - const timeoutMs = Math.min(this.options.cancelTimeoutMs ?? 10_000, MAX_TIMER_DELAY_MS) - let timer: ReturnType | undefined - try { - await Promise.race([ - this.peer.notify('session/cancel', { sessionId }).then(() => - active.response.catch((error) => { - if (this.peer.closed) { - throw error - } - }) - ), - new Promise((_resolve, reject) => { - timer = setTimeout(() => { - const error = new AcpRequestTimeoutError('session/cancel') - this.peer.close(error) - reject(error) - }, timeoutMs) - }) - ]) - } finally { - clearTimeout(timer) - } - } - - async setMode(modeId: string): Promise { - return this.call( - 'session/set_mode', - { sessionId: this.sessionId(), modeId }, - SetSessionModeResponseSchema - ) + async setMode(modeId: string, meta?: Meta): Promise { + const params: SetSessionModeRequest = { sessionId: this.sessionId(), modeId, ...withMeta(meta) } + return this.call('session/set_mode', params, SetSessionModeResponseSchema) } - async setModel(modelId: string): Promise { - return this.call( - 'session/set_model', - { sessionId: this.sessionId(), modelId }, - SetSessionModelResponseSchema - ) + async setModel(modelId: string, meta?: Meta): Promise { + const params: SetSessionModelRequest = { + sessionId: this.sessionId(), + modelId, + ...withMeta(meta) + } + return this.call('session/set_model', params, SetSessionModelResponseSchema) } async setConfigOption( configId: SetSessionConfigOptionRequest['configId'], - value: SetSessionConfigOptionRequest['value'] + value: SetSessionConfigOptionRequest['value'], + meta?: Meta ): Promise { const sessionId = this.sessionId() const request = typeof value === 'boolean' - ? ({ configId, value, sessionId, type: 'boolean' } satisfies SetSessionConfigOptionRequest) - : ({ configId, value, sessionId } satisfies SetSessionConfigOptionRequest) + ? ({ + configId, + value, + sessionId, + type: 'boolean', + ...withMeta(meta) + } satisfies SetSessionConfigOptionRequest) + : ({ + configId, + value, + sessionId, + ...withMeta(meta) + } satisfies SetSessionConfigOptionRequest) return this.call('session/set_config_option', request, SetSessionConfigOptionResponseSchema) } @@ -248,14 +246,18 @@ export class AcpSessionRuntime { private async call(method: string, params: unknown, schema: z.ZodType): Promise { const result = await this.peer.request(method, params, { timeoutMs: null }).catch((error) => { - if (error instanceof AcpRpcError && error.code === -32000) { + if (error instanceof AcpAgentError && error.code === -32000) { throw new AcpAuthRequiredError(error.message, error.data) } throw error }) const parsed = schema.safeParse(result) if (!parsed.success) { - throw new AcpRpcError(-32603, `Invalid ACP response: ${method}`) + throw new AcpInvalidResponseError( + `Invalid ACP response: ${method}`, + result, + parsed.error.issues + ) } return parsed.data } @@ -267,18 +269,16 @@ export class AcpSessionRuntime { } return this.options.onRequest(method, params, context) } - const parsed = RequestPermissionRequestSchema.safeParse(params) - if (!parsed.success) { + const diagnose = (message: string): void => this.diagnose(message) + const request = readAcpPermissionRequest(params, diagnose) + if (!request) { throw new AcpRpcError(-32602, 'Invalid ACP permission request') } - if ( - !this.activePrompt || - this.activePrompt.cancelling || - parsed.data.sessionId !== this.started?.sessionId - ) { + // Whether a turn the agent began itself may ask is the caller's call; the runtime cannot see it. + if (this.activePrompt?.cancelling || request.sessionId !== this.started?.sessionId) { return { outcome: { outcome: 'cancelled' } } } - return this.permissions.handle(parsed.data, context, this.options.onPermission) + return answerAcpPermission(request, context, this.options.onPermission, diagnose) } private diagnose(message: string): void { @@ -291,18 +291,19 @@ export class AcpSessionRuntime { private handleNotification(method: string, params: unknown): void { if (method !== 'session/update') { + try { + this.options.onExtensionNotification?.(method, params) + } catch (error) { + this.diagnose(`ACP extension listener failed: ${String(error)}`) + } return } - const envelope = updateEnvelopeSchema.safeParse(params) - if (!envelope.success) { + const event = readAcpSessionEvent(params) + if (!event) { this.diagnose('Ignored invalid ACP session update envelope') return } - const parsed = SessionNotificationSchema.safeParse(params) - const event: AcpSessionEvent = parsed.success - ? { kind: 'known', notification: parsed.data } - : { kind: 'unrecognized', sessionId: envelope.data.sessionId, raw: envelope.data } - if (!parsed.success && !this.reportedUpdateAnomaly) { + if (event.kind === 'unrecognized' && !this.reportedUpdateAnomaly) { this.reportedUpdateAnomaly = true this.diagnose('Forwarded unrecognized ACP session update') } diff --git a/src/main/codex/codex-app-server-record-dispatch.ts b/src/main/codex/codex-app-server-record-dispatch.ts index cbac80f3643e..078d672293ea 100644 --- a/src/main/codex/codex-app-server-record-dispatch.ts +++ b/src/main/codex/codex-app-server-record-dispatch.ts @@ -7,7 +7,7 @@ import { CodexAppServerUnsupportedError, isCodexMethodNotFoundError } from './codex-app-server-session' -import { classifyJsonRpcPrefix } from './codex-app-server-record-prefix' +import { classifyJsonRpcPrefix } from '../../shared/json-rpc-record-prefix' const OVERSIZED_REQUEST_ERROR_CODE = -32001 const MAX_REMEMBERED_TIMEOUTS = 64 diff --git a/src/main/codex/codex-app-server-record-prefix.ts b/src/shared/json-rpc-record-prefix.ts similarity index 100% rename from src/main/codex/codex-app-server-record-prefix.ts rename to src/shared/json-rpc-record-prefix.ts From a4df9df2f38d307492628a3d441b5c1657140da6 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 00:32:46 -0700 Subject: [PATCH 39/62] Run a transition's steps as a prefix; drop the paced flag and resolved options A failed step no longer lets the steps after it write: each step checks, at its own turn in the journal's queue, whether the write handed over just ahead of it completed, using the queue's count of completed write bodies (a promise would report the failure only after the next step ran). The sink fails only once every step has had its turn. The item step's `paced` size bypass and the resolver's replacement `options` are removed; nothing planned uses them. --- .../journal-item-appender.ts | 9 +- .../agent-session-journal/journal-store.ts | 5 + .../journal-write-queue.ts | 16 ++- ...tructured-agent-session-transition.test.ts | 106 +++++++++++++----- .../structured-agent-session-transition.ts | 78 ++++++++----- 5 files changed, 152 insertions(+), 62 deletions(-) diff --git a/src/main/native-chat/agent-session-journal/journal-item-appender.ts b/src/main/native-chat/agent-session-journal/journal-item-appender.ts index 479f0bc04a9d..146078d830c9 100644 --- a/src/main/native-chat/agent-session-journal/journal-item-appender.ts +++ b/src/main/native-chat/agent-session-journal/journal-item-appender.ts @@ -10,12 +10,7 @@ import type { JournalRow } from './journal-row-schema' const NOTHING_RESOLVED = new Error('journal_item_resolved_to_nothing') -export type JournalResolvedItem = { - identity: AgentJournalItemIdentity - body: AgentJournalItemBody - /** Replaces the call's options when only the fold could say where the row belongs. */ - options?: JournalItemAppendOptions -} +export type JournalResolvedItem = { identity: AgentJournalItemIdentity; body: AgentJournalItemBody } export class JournalItemAppender { constructor( @@ -53,7 +48,7 @@ export class JournalItemAppender { this.deps.state, resolved.identity, resolved.body, - resolved.options ?? options + options )(seq, ts) }) .then( diff --git a/src/main/native-chat/agent-session-journal/journal-store.ts b/src/main/native-chat/agent-session-journal/journal-store.ts index d12257447cfd..3cabb7bd53ef 100644 --- a/src/main/native-chat/agent-session-journal/journal-store.ts +++ b/src/main/native-chat/agent-session-journal/journal-store.ts @@ -208,6 +208,11 @@ export class AgentSessionJournal { return this.queue.owing } + /** Write bodies that returned so far; see `JournalWriteQueue.completedWrites`. */ + get completedWrites(): number { + return this.queue.completedWrites + } + cursor = (): AgentJournalCursor => ({ epoch: this.state.epoch, sequence: this.state.lastSequence diff --git a/src/main/native-chat/agent-session-journal/journal-write-queue.ts b/src/main/native-chat/agent-session-journal/journal-write-queue.ts index 74ef33472bd4..ae4f5c96d893 100644 --- a/src/main/native-chat/agent-session-journal/journal-write-queue.ts +++ b/src/main/native-chat/agent-session-journal/journal-write-queue.ts @@ -30,6 +30,7 @@ export class JournalWriteQueue { private closed = false /** Runs before the next write, and stays owed until it succeeds. */ private owed: (() => Promise) | null = null + private completed = 0 constructor(private readonly sessionId: string) {} @@ -41,9 +42,20 @@ export class JournalWriteQueue { if (this.closed) { return Promise.reject(this.closedError()) } + const counted = (): JournalWriteResult => { + const result = run() + this.completed++ + return result + } return this.owed !== null || this.lineBusy - ? this.join(run, this.owed !== null) - : this.runNow(run) + ? this.join(counted, this.owed !== null) + : this.runNow(counted) + } + + /** Write bodies that returned without throwing. Bodies run one at a time, so a body that reads + * this learns synchronously whether the write just ahead of it landed. */ + get completedWrites(): number { + return this.completed } /** Runs `read` after every write admitted before it, whatever each one's outcome, and ahead of any diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-transition.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-transition.test.ts index f752a2147afc..b4232642dee0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-transition.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-transition.test.ts @@ -1,7 +1,7 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' import { AGENT_JOURNAL_THREAD_SCOPE, @@ -52,11 +52,12 @@ function failedTool(id: AgentJournalItemIdentity): JournalLifecycleMutationInput } function itemStep( - resolve: Extract['resolve'] + resolve: Extract['resolve'], + reservedBytes = 4096 ): StructuredAgentSessionTransitionStep { return { kind: 'item', - reservedBytes: 4096, + reservedBytes, resolve, options: { turnScope: AGENT_JOURNAL_THREAD_SCOPE } } @@ -121,29 +122,6 @@ describe('structured agent-session transitions', () => { expect(publishes).toHaveLength(1) }) - it('places a row where its resolver says', async () => { - const { journal, deferred, sink, bind } = await rig() - bind() - const scope = { kind: 'turn', turnItemId: 'turn-row' } as const - sink.tryAppendTransition?.({ - lifecycle: false, - publish: true, - steps: [ - itemStep(() => ({ - identity: identity('placed'), - body: tool('read', 'running'), - options: { turnScope: scope } - })), - itemStep(() => null) - ] - }) - await deferred.drained() - - expect(journal.item(agentJournalItemKey(identity('placed')))).toMatchObject({ - turnScope: scope - }) - }) - it('admitted whole is not executed whole: a failed step keeps the ones before it and fails the sink', async () => { const { journal, deferred, sink, publishes, bind } = await rig() bind() @@ -172,6 +150,82 @@ describe('structured agent-session transitions', () => { ).toEqual({ accepted: false, reason: 'failed' }) }) + it('writes nothing after a step that overflows its reservation', async () => { + const { journal, deferred, sink, publishes, bind } = await rig() + bind() + const after = vi.fn(() => ({ identity: identity('third'), body: tool('read', 'running') })) + sink.tryAppendTransition?.({ + lifecycle: false, + publish: true, + steps: [ + itemStep(() => ({ identity: identity('first'), body: tool('read', 'running') })), + itemStep( + () => ({ identity: identity('second'), body: tool('x'.repeat(10_000), 'running') }), + 64 + ), + itemStep(after) + ] + }) + + await expect(deferred.drained()).resolves.toMatchObject({ ok: false }) + expect(journal.snapshot().items.map((item) => item.itemId)).toEqual([ + agentJournalItemKey(identity('first')) + ]) + expect(after).not.toHaveBeenCalled() + expect(publishes).toEqual([]) + }) + + it('opens no next-turn work after a settlement the journal refused', async () => { + const { journal, deferred, sink, bind } = await rig() + bind() + const old = identity('old-tool') + sink.tryAppendItem?.(old, tool('read', 'running'), { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) + await deferred.drained() + sink.tryAppendTransition?.({ + lifecycle: true, + publish: true, + steps: [ + itemStep(() => ({ identity: identity('before'), body: tool('read', 'completed') })), + // Names one item twice, which the journal refuses. + { + kind: 'settlement', + settlementId: 'settle', + reservedBytes: 1, + resolve: () => [failedTool(old), failedTool(old)] + }, + itemStep(() => ({ identity: identity('next-turn-work'), body: tool('read', 'running') })) + ] + }) + + await expect(deferred.drained()).resolves.toMatchObject({ ok: false }) + expect(journal.snapshot().items.map((item) => item.itemId)).toEqual([ + agentJournalItemKey(old), + agentJournalItemKey(identity('before')) + ]) + expect(journal.item(agentJournalItemKey(old))?.body).toMatchObject({ state: 'running' }) + }) + + it('writes nothing after a step whose row the database refused', async () => { + const { root, journal, deferred, sink, bind } = await rig() + bind() + // Refuses only the second step's row, so the third would land in its place. + openTestJournalHostDatabase(root).db.exec(`CREATE TEMP TRIGGER fail_second_step +BEFORE INSERT ON main.journal_rows WHEN instr(NEW.row_json, 'refused-step') > 0 +BEGIN SELECT RAISE(ABORT, 'second step refused'); END`) + const step = (recordId: string) => + itemStep(() => ({ identity: identity(recordId), body: tool(recordId, 'running') })) + sink.tryAppendTransition?.({ + lifecycle: false, + publish: true, + steps: [step('first'), step('refused-step'), step('third')] + }) + + await expect(deferred.drained()).resolves.toMatchObject({ ok: false }) + expect(journal.snapshot().items.map((item) => item.itemId)).toEqual([ + agentJournalItemKey(identity('first')) + ]) + }) + it('refuses a transition whole, so none of its steps ever lands', async () => { const { journal, deferred, sink, bind } = await rig({ maxQueuedOperations: 1 }) const step = (recordId: string) => diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-transition.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-transition.ts index a185695d5ea5..57c0c7d0f5e8 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-transition.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-transition.ts @@ -6,8 +6,8 @@ // all. At execution its steps are issued in the same tick, so they sit together in the journal's // write queue, and each step resolves against the fold with every earlier write landed — the // steps before it included — so what a step writes is decided by the journal, not by memory. -// Admitted whole is not executed whole: a step that fails leaves the steps before it written, and -// fails the sink, so nothing more is admitted after it. +// Admitted whole, executed as a prefix: once a step fails, the steps after it write nothing, the +// steps before it stay written, and the sink fails, so nothing more is admitted after it. import type { AgentJournalItemBody, @@ -32,16 +32,12 @@ export type StructuredAgentSessionTransitionJournal = Pick< export type StructuredAgentSessionTransitionStep = | { kind: 'item' - /** Bounds what `resolve` may write; a larger write fails the sink, unless `paced`. */ + /** Bounds what `resolve` may write; a larger write fails the sink. */ reservedBytes: number - /** The size only paces the queue: the row grows by text the fold holds (a resumed message). */ - paced?: true - /** The row and its whole body; null writes nothing. Resolved `options` replace the planned - * ones, for a writer that learns the row's turn or producer only from the fold. */ + /** The row and its whole body; null writes nothing. */ resolve: (journal: StructuredAgentSessionTransitionJournal) => { identity: AgentJournalItemIdentity body: AgentJournalItemBody - options?: StructuredAgentSessionItemAppendOptions } | null options: StructuredAgentSessionItemAppendOptions } @@ -94,35 +90,25 @@ function transitionAppend( lifecycle: transition.lifecycle, run: async (bound) => { const { journal, fence } = bound + const gate = createStepGate(journal) // Issued in one tick, so no write submitted after this transition lands between its steps. - const writes = transition.steps.map((step) => + const writes = transition.steps.map((step, index) => step.kind === 'item' ? journal .appendResolvedItem( () => { - const resolved = step.resolve(journal) + const resolved = gate.open(index) ? step.resolve(journal) : null if (!resolved) { + gate.wroteNothing() return null } if ( - !step.paced && estimateStructuredAgentSessionItemBytes(resolved.identity, resolved.body) > - step.reservedBytes + step.reservedBytes ) { throw new Error(STEP_OVERFLOW) } - return { - identity: resolved.identity, - body: resolved.body, - ...(resolved.options - ? { - options: structuredAgentSessionJournalAppendOptions( - fence, - resolved.options - ) - } - : {}) - } + return resolved }, structuredAgentSessionJournalAppendOptions(fence, step.options) ) @@ -130,15 +116,53 @@ function transitionAppend( : journal .appendResolvedLifecycleBatch({ settlementId: step.settlementId, - resolve: () => step.resolve(journal), + resolve: () => (gate.open(index) ? step.resolve(journal) : []), fence }) .then((landed) => landed !== null) ) - const wrote = await Promise.all(writes) - if (transition.publish && wrote.some(Boolean)) { + // Every step, the ones a failure turned into no-ops included, has run before this settles. + const outcomes = await Promise.allSettled(writes) + const failure = outcomes.find( + (outcome): outcome is PromiseRejectedResult => outcome.status === 'rejected' + ) + if (failure) { + throw failure.reason + } + if ( + transition.publish && + outcomes.some((outcome) => outcome.status === 'fulfilled' && outcome.value) + ) { bound.publish() } } }) } + +/** Lets a step write only when every step before it landed. Steps run in queue order, so each + * step's write learns synchronously, from the journal's count of completed writes, whether the + * write handed over just ahead of it landed; a promise would say so only after it ran. */ +function createStepGate(journal: Pick): { + /** First thing step `index` does at its turn; false once an earlier step failed. */ + open: (index: number) => boolean + /** The step writes nothing, so whether its write completes says nothing about a failure. */ + wroteNothing: () => void +} { + let failed = false + let reached = 0 + let handedAt: number | null = null + return { + open: (index) => { + // A step that never reached its resolve failed before it: a refused import, a read-only journal. + if (reached !== index || (handedAt !== null && journal.completedWrites === handedAt)) { + failed = true + } + reached = index + 1 + handedAt = failed ? null : journal.completedWrites + return !failed + }, + wroteNothing: () => { + handedAt = null + } + } +} From 2faf837f84b0fd141d43a184d828e2c740bd8cc7 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 00:36:28 -0700 Subject: [PATCH 40/62] Rebuild the timeline assembler on one admission-order state The assembler kept a second copy of its state (a forecast beside a ledger), hydrated the open turn from the journal, and recognised replays, all to recover from losing its memory while the provider child kept streaming. That never happens: one assembler lives exactly as long as one child, and a new child is a new assembler in a new generation whose events land after the dead-generation sweep. - One state, changed only when the sink admits an event (minted keys included), so a refused event takes nothing. - Every journal-dependent choice is made when the write runs, by keyed reads: a turn row is written only where none is, a stream checks its row's turn on every write, a running snapshot never lands in a settled turn or relights a settled tool, a request takes the first incarnation the journal holds no row for. - Rows are found by spelling their ids (provider-timeline-rows.ts); no join cache. - The identity scheme (legacy arm only) lives here with its first caller; requests are spelled in their acquisition generation, since JSON-RPC ids restart per process. - Saved history goes in as `input.history` plus ordinary events with the provider's ids, into an empty journal; `session.reset` and every replay rule are gone. - One terminal-body function (`terminalAgentJournalBody`) is shared with the dead-generation settlement. - The test rig's restart now sweeps and starts a new generation, as production does. --- .../codex-provider-timeline-identity.test.ts | 409 ------------------ .../journal-terminal-settlement.ts | 13 + .../provider-timeline-apply-host.ts | 35 -- ...der-timeline-assembler-attribution.test.ts | 98 ++--- ...vider-timeline-assembler-held-rows.test.ts | 160 +++++++ ...ovider-timeline-assembler-recovery.test.ts | 402 ----------------- ...rovider-timeline-assembler-refusal.test.ts | 25 +- ...provider-timeline-assembler-replay.test.ts | 295 ------------- ...rovider-timeline-assembler-restart.test.ts | 153 +++++++ .../provider-timeline-assembler-rows.test.ts | 207 +++++++++ ...rovider-timeline-assembler-session.test.ts | 11 +- ...rovider-timeline-assembler-streams.test.ts | 107 +---- ...rovider-timeline-assembler-test-support.ts | 56 ++- .../provider-timeline-assembler-turns.test.ts | 20 +- .../provider-timeline-assembler.ts | 214 +++------ .../provider-timeline-budget.ts | 39 +- .../provider-timeline-context.ts | 41 +- .../provider-timeline-decision.ts | 125 +++--- .../provider-timeline-decisions.ts | 76 ---- .../provider-timeline-event.ts | 36 +- .../provider-timeline-identity.test.ts | 54 +++ .../provider-timeline-identity.ts | 106 +++++ .../provider-timeline-item-decisions.ts | 291 +++++++------ .../provider-timeline-plan.ts | 78 +--- .../provider-timeline-rows.ts | 110 +++++ .../provider-timeline-settlement.ts | 27 +- .../provider-timeline-state.ts | 204 +++------ .../provider-timeline-stream-rows.ts | 185 -------- .../provider-timeline-text-events.ts | 100 +++-- .../provider-timeline-text-streams.ts | 211 +++------ .../provider-timeline-transition-layout.ts | 127 ++---- .../provider-timeline-turn-decisions.ts | 302 +++++++------ ...gent-session-dead-generation-settlement.ts | 19 +- .../structured-agent-session-transition.ts | 7 +- 34 files changed, 1684 insertions(+), 2659 deletions(-) delete mode 100644 src/main/codex/codex-provider-timeline-identity.test.ts delete mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-apply-host.ts create mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-assembler-held-rows.test.ts delete mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-assembler-recovery.test.ts delete mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-assembler-replay.test.ts create mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-assembler-restart.test.ts create mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-assembler-rows.test.ts delete mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-decisions.ts create mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-identity.test.ts create mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-identity.ts create mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-rows.ts delete mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-stream-rows.ts diff --git a/src/main/codex/codex-provider-timeline-identity.test.ts b/src/main/codex/codex-provider-timeline-identity.test.ts deleted file mode 100644 index 84fc68f15bf3..000000000000 --- a/src/main/codex/codex-provider-timeline-identity.test.ts +++ /dev/null @@ -1,409 +0,0 @@ -// The migration contract for moving Codex onto the shared timeline assembler: the assembler with -// the Codex identity scheme lands the same rows Codex's own translator lands today — the same keys -// (live messages, history restored on resume, a late item naming its own turn, interleaved -// subagent threads) and the same row contents. - -import { afterEach, describe, expect, it } from 'vitest' -import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' -import type { AgentJournalRenderItem } from '../../shared/agent-session-journal-types' -import type { ProviderTimelineEvent } from '../native-chat/agent-session-timeline/provider-timeline-event' -import { - closeProviderTimelineRigs, - openProviderTimelineRig, - SESSION -} from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' -import { ProviderTurnMessageOrdinals } from '../native-chat/agent-session-timeline/provider-turn-message-ordinals' -import { createCodexProviderTimelineIdentityScheme } from './codex-provider-timeline-identity' -import { - codexItemIdentity, - codexJournalItem, - type CodexThreadItem -} from './codex-structured-item-translation' -import { createCodexJournalTranslator } from './codex-structured-journal-translation' -import type { CodexStructuredSessionEvent } from './codex-structured-session-adapter' - -afterEach(closeProviderTimelineRigs) - -const THREAD = 'thread-abc' -const CHILD = 'thread-child' - -async function codexRig() { - return openProviderTimelineRig({ - scheme: createCodexProviderTimelineIdentityScheme({ - sessionId: SESSION, - primaryThreadId: () => THREAD - }), - ownThread: () => THREAD - }) -} - -function body(item: CodexThreadItem, started?: CodexThreadItem) { - const translated = codexJournalItem(item, undefined, started).body - if ( - !translated || - translated.kind === 'turn' || - translated.kind === 'approval' || - translated.kind === 'question' - ) { - throw new Error(`no item body for ${item.type}`) - } - return translated -} - -const keys = (rows: AgentJournalRenderItem[]) => - rows.filter((row) => row.body.kind !== 'turn').map((row) => row.itemId) - -/** What a client renders from a row; sequence, revision and receipt time are the journal's own. */ -const content = (rows: AgentJournalRenderItem[]) => - JSON.stringify( - rows.map( - ({ - itemId, - body, - turnScope, - agentId, - parentAgentId, - providerParentRef, - producerKind, - attempt - }) => ({ - itemId, - body, - turnScope, - agentId, - parentAgentId, - providerParentRef, - producerKind, - attempt - }) - ) - ) - -function liveTranslator(rig: Awaited>) { - return createCodexJournalTranslator({ - sink: rig.eventSink, - sessionId: SESSION, - primaryThreadId: () => THREAD, - now: () => 1_000, - schedule: (run) => { - run() - return () => {} - } - }) -} - -function notify(method: string, params: unknown): CodexStructuredSessionEvent { - return { type: 'notification', sessionId: SESSION, threadId: THREAD, method, params } -} - -describe('Codex identities through the shared assembler', () => { - it('keys messages by turn and message ordinal, and a message outside any turn by its item id', async () => { - const rig = await codexRig() - const message = ( - id: string, - text: string, - join?: { turn?: string } - ): ProviderTimelineEvent => ({ - type: 'item.close', - item: id, - body: body({ type: 'agentMessage', id, text }), - join: { thread: THREAD, ...join } - }) - rig.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_000 }) - rig.assembler.apply(message('item-0', 'one')) - rig.assembler.apply({ type: 'turn.end', turn: 'turn-1', at: 2_000, state: 'completed' }) - rig.assembler.apply(message('item-1', 'orphan')) - rig.assembler.apply({ type: 'turn.open', turn: 'turn-2', at: 3_000 }) - rig.assembler.apply(message('item-2', 'two')) - // A message naming another turn joins that turn, whatever is open. - rig.assembler.apply(message('item-3', 'late', { turn: 'turn-9' })) - - // The keys Codex's own translator writes for the same frames. - expect(keys(await rig.rows())).toEqual([ - 'codex:thread-abc:turn-1:0', - 'orca:codex-item%3Athread-abc%3Aitem-1', - 'codex:thread-abc:turn-2:0', - 'codex:thread-abc:turn-9:0' - ]) - }) - - it('keeps interleaved subagent threads apart while their rows join the open turn', async () => { - const rig = await codexRig() - rig.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_000 }) - rig.assembler.apply({ - type: 'item.close', - item: 'item-0', - body: body({ type: 'agentMessage', id: 'item-0', text: 'root' }), - join: { thread: THREAD } - }) - rig.assembler.apply({ - type: 'item.close', - item: 'item-0', - body: body({ type: 'agentMessage', id: 'item-0', text: 'child' }), - join: { thread: CHILD, turn: 'turn-child' } - }) - rig.assembler.apply({ - type: 'item.close', - item: 'item-1', - body: body({ type: 'agentMessage', id: 'item-1', text: 'still root' }), - join: { thread: THREAD } - }) - const rows = (await rig.rows()).filter((row) => row.body.kind !== 'turn') - expect(rows.map((row) => row.itemId)).toEqual([ - 'codex:thread-abc:turn-1:0', - 'codex:thread-child:turn-child:0', - 'codex:thread-abc:turn-1:1' - ]) - expect(new Set(rows.map((row) => JSON.stringify(row.turnScope))).size).toBe(1) - }) - - it('counts an echoed send in its turn’s message ordinals, as resume history does', async () => { - const rig = await codexRig() - const ordinals = new ProviderTurnMessageOrdinals() - const oracle = (item: CodexThreadItem) => - agentJournalItemKey(codexItemIdentity({ threadId: THREAD, turnId: 'turn-1', item, ordinals })) - const echo: CodexThreadItem = { type: 'userMessage', id: 'item-0', content: [] } - const reasoning: CodexThreadItem = { type: 'reasoning', id: 'item-1', summary: ['why'] } - const answer: CodexThreadItem = { type: 'agentMessage', id: 'item-2', text: 'answer' } - const expected = [oracle(echo), oracle(reasoning), oracle(answer)] - - rig.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_000 }) - rig.assembler.apply({ - type: 'input.accepted', - clientMessageId: 'send-1', - requestedAt: 900, - join: { thread: THREAD, turn: 'turn-1', item: 'item-0' } - }) - rig.assembler.apply({ - type: 'text.delta', - item: { id: 'item-1' }, - channel: 'reasoning', - text: 'why', - join: { thread: THREAD } - }) - rig.assembler.apply({ - type: 'item.close', - item: 'item-2', - body: body(answer), - join: { thread: THREAD } - }) - rig.assembler.apply({ type: 'turn.end', turn: 'turn-1', at: 2_000, state: 'completed' }) - - expect(keys(await rig.rows())).toEqual(expected.slice(1)) - expect(expected[2]).toBe('codex:thread-abc:turn-1:1') - }) -}) - -describe('Codex rows through the shared assembler', () => { - it('lands the same rows as the Codex translator for a live turn', async () => { - const codex = await openProviderTimelineRig() - let clock = 1_000 - const translator = createCodexJournalTranslator({ - sink: codex.eventSink, - sessionId: SESSION, - primaryThreadId: () => THREAD, - now: () => clock, - schedule: (run) => { - run() - return () => {} - } - }) - const notify = (method: string, params: unknown): CodexStructuredSessionEvent => ({ - type: 'notification', - sessionId: SESSION, - threadId: THREAD, - method, - params - }) - const started: CodexThreadItem = { - type: 'commandExecution', - id: 'exec-1', - command: 'ls', - status: 'inProgress' - } - const finished: CodexThreadItem = { - ...started, - status: 'completed', - exitCode: 0, - aggregatedOutput: 'ok' - } - const reply: CodexThreadItem = { type: 'agentMessage', id: 'item-1', text: 'Hello' } - translator.handle(notify('turn/started', { turn: { id: 'turn-1' } })) - translator.handle(notify('item/started', { item: { ...reply, text: '' } })) - translator.handle(notify('item/agentMessage/delta', { itemId: 'item-1', delta: 'Hello' })) - translator.handle(notify('item/completed', { item: reply })) - translator.handle(notify('item/started', { item: started })) - translator.handle(notify('item/completed', { item: finished })) - clock = 2_000 - translator.handle( - notify('turn/completed', { turn: { id: 'turn-1', status: 'completed', durationMs: 900 } }) - ) - - const shared = await codexRig() - const join = { thread: THREAD, turn: 'turn-1' } - shared.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_000 }) - shared.assembler.apply({ - type: 'text.delta', - item: { id: 'item-1' }, - channel: 'assistant', - text: 'Hello', - join - }) - shared.assembler.apply({ type: 'item.close', item: 'item-1', body: body(reply), join }) - shared.assembler.apply({ type: 'item.open', item: 'exec-1', body: body(started), join }) - shared.assembler.apply({ - type: 'item.close', - item: 'exec-1', - body: body(finished, started), - join - }) - shared.assembler.apply({ - type: 'turn.end', - turn: 'turn-1', - at: 2_000, - state: 'completed', - outcome: 'success', - durationMs: 900 - }) - - expect(content(await shared.rows())).toBe(content(await codex.rows())) - }) - - it('lands the same rows as the Codex translator for a turn restored from history', async () => { - const turn = { - id: 'turn-1', - status: 'completed', - startedAt: 1_700_000_000, - completedAt: 1_700_000_050, - items: [ - { type: 'userMessage', id: 'user-1', content: [{ type: 'text', text: 'ask' }] }, - { type: 'reasoning', id: 'think-1', summary: ['why'] }, - { type: 'agentMessage', id: 'interim-1', text: 'looking' }, - { type: 'commandExecution', id: 'exec-1', command: 'ls', status: 'completed', exitCode: 0 }, - { type: 'agentMessage', id: 'answer-1', text: 'answer' } - ] - } - const codex = await openProviderTimelineRig() - const translator = createCodexJournalTranslator({ - sink: codex.eventSink, - sessionId: SESSION, - primaryThreadId: () => THREAD - }) - expect(translator.restoreThread(THREAD, { turns: [turn] })).toEqual({ accepted: true }) - - const shared = await codexRig() - const join = { thread: THREAD, turn: 'turn-1' } - shared.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: turn.startedAt * 1000 }) - for (const item of turn.items) { - const translated = codexJournalItem(item).body - if ( - translated && - translated.kind !== 'turn' && - translated.kind !== 'approval' && - translated.kind !== 'question' - ) { - shared.assembler.apply({ type: 'item.close', item: item.id, body: translated, join }) - } - } - shared.assembler.apply({ - type: 'turn.end', - turn: 'turn-1', - at: turn.completedAt * 1000, - state: 'completed', - outcome: 'success' - }) - - expect(content(await shared.rows())).toBe(content(await codex.rows())) - }) - - it('lands the same rows across a restart in the middle of a streamed message', async () => { - const codex = await openProviderTimelineRig() - const translator = liveTranslator(codex) - const first: CodexThreadItem = { type: 'agentMessage', id: 'item-1', text: 'First' } - const second: CodexThreadItem = { type: 'agentMessage', id: 'item-2', text: 'Second' } - translator.handle(notify('turn/started', { turn: { id: 'turn-1' } })) - translator.handle(notify('item/completed', { item: first })) - translator.handle(notify('item/started', { item: { ...second, text: '' } })) - translator.handle(notify('item/agentMessage/delta', { itemId: 'item-2', delta: 'Sec' })) - translator.handle(notify('item/agentMessage/delta', { itemId: 'item-2', delta: 'ond' })) - translator.handle(notify('item/completed', { item: second })) - - const shared = await codexRig() - const join = { thread: THREAD, turn: 'turn-1' } - shared.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_000 }) - shared.assembler.apply({ type: 'item.close', item: 'item-1', body: body(first), join }) - shared.assembler.apply({ - type: 'text.delta', - item: { id: 'item-2' }, - channel: 'assistant', - text: 'Sec', - join - }) - await shared.rows() - // The host restarts while Codex is still streaming item-2. - const restarted = shared.restart({ generation: 'gen-2' }) - restarted.apply({ - type: 'text.delta', - item: { id: 'item-2' }, - channel: 'assistant', - text: 'ond', - join - }) - restarted.apply({ type: 'item.close', item: 'item-2', body: body(second), join }) - - const rows = await shared.rows() - expect(content(rows)).toBe(content(await codex.rows())) - // Codex message rows carry the item id their ordinal identity cannot spell. - expect( - rows.filter((row) => row.body.kind === 'message').map((row) => row.providerItemRef) - ).toEqual([expect.stringContaining('item-1'), expect.stringContaining('item-2')]) - }) - - it('lands the same rows, and no second copy, when a late repeat outlives the join cache', async () => { - const codex = await openProviderTimelineRig() - const translator = liveTranslator(codex) - const shared = await codexRig() - const old: CodexThreadItem = { type: 'agentMessage', id: 'old-1', text: 'Old' } - translator.handle(notify('turn/started', { turn: { id: 'turn-1' } })) - translator.handle(notify('item/completed', { item: old })) - translator.handle(notify('turn/completed', { turn: { id: 'turn-1', status: 'completed' } })) - translator.handle(notify('turn/started', { turn: { id: 'turn-2' } })) - shared.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_000 }) - shared.assembler.apply({ - type: 'item.close', - item: 'old-1', - body: body(old), - join: { thread: THREAD } - }) - shared.assembler.apply({ - type: 'turn.end', - turn: 'turn-1', - at: 1_000, - state: 'completed', - outcome: 'success' - }) - shared.assembler.apply({ type: 'turn.open', turn: 'turn-2', at: 1_000 }) - for (let index = 0; index < 1_025; index += 1) { - const item: CodexThreadItem = { type: 'agentMessage', id: `item-${index}`, text: 'small' } - translator.handle(notify('item/completed', { item })) - shared.assembler.apply({ - type: 'item.close', - item: item.id, - body: body(item), - join: { thread: THREAD } - }) - if (index % 32 === 31) { - await Promise.all([codex.rows(), shared.rows()]) - } - } - // Long after its join left memory, Codex repeats the old message with no turn of its own. - shared.assembler.apply({ - type: 'item.close', - item: 'old-1', - body: body(old), - join: { thread: THREAD } - }) - - expect(content(await shared.rows())).toBe(content(await codex.rows())) - }) -}) diff --git a/src/main/native-chat/agent-session-journal/journal-terminal-settlement.ts b/src/main/native-chat/agent-session-journal/journal-terminal-settlement.ts index 1dd1115ebd19..b09d387603ea 100644 --- a/src/main/native-chat/agent-session-journal/journal-terminal-settlement.ts +++ b/src/main/native-chat/agent-session-journal/journal-terminal-settlement.ts @@ -1,5 +1,6 @@ import type { AgentJournalItemBody } from '../../../shared/agent-session-journal-types' import { isRunningAgentJournalTurn } from '../../../shared/agent-session-turn-record' +import { cancelledJournalPromptBody } from './journal-prompt-body-bounds' /** True while an item is still awaiting the row that settles it, so a sink can * treat that row as lifecycle-critical rather than sheddable under pressure. */ @@ -12,3 +13,15 @@ export function requiresTerminalSettlement(body: AgentJournalItemBody): boolean } return isRunningAgentJournalTurn(body) } + +/** The row that settles an item no one will finish: a running tool call fails, a pending prompt + * is cancelled. Null for an item that needs none. Turn rows are each writer's own to end. */ +export function terminalAgentJournalBody(body: AgentJournalItemBody): AgentJournalItemBody | null { + if (body.kind === 'tool-call') { + return body.state === 'running' ? { ...body, state: 'failed' } : null + } + if (body.kind === 'approval' || body.kind === 'question') { + return body.resolution.state === 'pending' ? cancelledJournalPromptBody(body) : null + } + return null +} diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-apply-host.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-apply-host.ts deleted file mode 100644 index 12804f3150b3..000000000000 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-apply-host.ts +++ /dev/null @@ -1,35 +0,0 @@ -// What the event appliers share with the assembler that owns them. - -import type { StructuredAgentSessionSinkAdmission } from '../agent-session-wire/structured-agent-session-event-sink' -import type { StructuredAgentSessionTransitionJournal } from '../agent-session-wire/structured-agent-session-transition' -import type { ProviderTimelineHold } from './provider-timeline-budget' -import type { ProviderTimelineContext } from './provider-timeline-context' -import type { ProviderTimelineDropRule } from './provider-timeline-decisions' -import type { ProviderTimelinePlan } from './provider-timeline-plan' -import type { ProviderTimelineState } from './provider-timeline-state' -import type { ProviderTimelineTextStreams } from './provider-timeline-text-streams' - -export type ProviderTimelineApplyResult = { - /** The sink's answer for the event's writes; refused means nothing changed. */ - admission: StructuredAgentSessionSinkAdmission - dropped?: ProviderTimelineDropRule -} - -/** An admitted transition that has not landed: what it changes in the forecast until it does. */ -export type ProviderTimelineInFlight = { - executed: boolean - commit: ((state: ProviderTimelineState) => void) | undefined -} - -export type ProviderTimelineApplyHost = { - context: ProviderTimelineContext - streams: ProviderTimelineTextStreams - /** The forecast now; it is replaced as transitions land. */ - forecast(): ProviderTimelineState - journal(): StructuredAgentSessionTransitionJournal | null - admits(hold: ProviderTimelineHold): boolean - submit( - plan: ProviderTimelinePlan, - entry: ProviderTimelineInFlight - ): StructuredAgentSessionSinkAdmission -} diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-attribution.test.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-attribution.test.ts index 92238e697090..c49d266e3432 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-attribution.test.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-attribution.test.ts @@ -4,19 +4,15 @@ import { afterEach, describe, expect, it } from 'vitest' import { parseAgentJournalItemKey } from '../../../shared/agent-session-journal-item-key' import { agentJournalTurnBody } from '../../../shared/agent-session-turn-record' -import { createCodexProviderTimelineIdentityScheme } from '../../codex/codex-provider-timeline-identity' import { - assistantText, backgroundTask, backgroundTaskState, closeProviderTimelineRigs, - messageText, openProviderTimelineRig, openUnboundProviderTimelineAssembler, pendingApproval, providerItemId, providerTurnItemId, - SESSION, type ProviderTimelineRig } from './provider-timeline-assembler-test-support' @@ -93,43 +89,28 @@ describe('a request key reused in another turn', () => { }) }) - it('is a replay when it names the turn that is over', async () => { - const rig = await answeredInAnEarlierTurn() - expect( - rig.assembler.apply({ - type: 'request.open', - request: 'p1', - body: pendingApproval, - join: { turn: 't1' } - }).dropped - ).toBe('request-replayed') - expect(await rig.row(second)).toBeUndefined() - }) - - it('decides at execution: queued before bind, a replay of the old turn writes nothing', async () => { - const rig = await answeredInAnEarlierTurn() - const { assembler, bind } = openUnboundProviderTimelineAssembler(rig.journal) - const join = { turn: 't1' } + it('opens a new row for a request id the previous process used, after a restart', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ type: 'request.open', request: '0', body: pendingApproval }) + await rig.rows() + // JSON-RPC ids restart with the provider process: the new child asks under `0` again. + const restarted = await rig.restart() + restarted.apply({ type: 'turn.open', turn: 't2', at: 3_000 }) expect( - assembler.apply({ type: 'request.open', request: 'p1', body: pendingApproval, join }).dropped + restarted.apply({ type: 'request.open', request: '0', body: pendingApproval }).dropped ).toBeUndefined() - await bind() - expect(await rig.row(second)).toBeUndefined() - expect((await rig.row(providerItemId('request', 'p1')))?.body).toMatchObject({ - resolution: { state: 'resolved' } + // The sweep cancelled the old process's prompt; the new one is its own row, still pending. + expect((await rig.row(providerItemId('request', '0')))?.body).toMatchObject({ + resolution: { state: 'cancelled' } + }) + expect(await rig.row(providerItemId('request', '0', { generation: 'gen-2' }))).toMatchObject({ + body: { resolution: { state: 'pending' } }, + turnScope: { kind: 'turn', turnItemId: providerTurnItemId('t2') } }) }) - it('decides at execution: queued before bind, a new prompt in the live turn opens', async () => { - const rig = await answeredInAnEarlierTurn() - const { assembler, bind } = openUnboundProviderTimelineAssembler(rig.journal) - const join = { turn: 't2' } - assembler.apply({ type: 'request.open', request: 'p1', body: pendingApproval, join }) - await bind() - expect((await rig.row(second))?.body).toMatchObject({ resolution: { state: 'pending' } }) - }) - - it('decides at execution: a turn another writer ended while it was queued takes no prompt', async () => { + it('asks nothing in a turn another writer ended while the open was queued', async () => { const rig = await answeredInAnEarlierTurn() const { assembler, bind } = openUnboundProviderTimelineAssembler(rig.journal) const join = { turn: 't2' } @@ -144,7 +125,9 @@ describe('a request key reused in another turn', () => { { fence: 1, turnScope: { kind: 'thread' } } ) await bind() - expect(await rig.row(second)).toBeUndefined() + expect( + await rig.row(providerItemId('request', 'p1', { generation: 'gen-unbound' })) + ).toBeUndefined() }) }) @@ -203,23 +186,24 @@ describe('a send that names its turn', () => { }) describe('a background task', () => { - it('outlives its turn after a restart, and settles on its own update', async () => { + it('outlives its turn, and settles on its own update', async () => { const rig = await openProviderTimelineRig() rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) rig.assembler.apply({ type: 'item.open', item: 'bg', body: backgroundTask('bg', 'working') }) - await rig.rows() - const restarted = rig.restart({ generation: 'gen-2' }) - restarted.apply({ type: 'turn.end', turn: 't1', at: 2_000, state: 'completed' }) + rig.assembler.apply({ type: 'turn.end', turn: 't1', at: 2_000, state: 'completed' }) expect(await backgroundTaskState(rig, 'bg')).toBe('working') expect( - restarted.apply({ type: 'item.close', item: 'bg', body: backgroundTask('bg', 'done') }) + rig.assembler.apply({ type: 'item.close', item: 'bg', body: backgroundTask('bg', 'done') }) .dropped ).toBeUndefined() expect(await backgroundTaskState(rig, 'bg')).toBe('done') // A straggler progress report never re-lights it. expect( - restarted.apply({ type: 'item.update', item: 'bg', body: backgroundTask('bg', 'working') }) - .dropped + rig.assembler.apply({ + type: 'item.update', + item: 'bg', + body: backgroundTask('bg', 'working') + }).dropped ).toBe('item-settled') expect(await backgroundTaskState(rig, 'bg')).toBe('done') }) @@ -237,29 +221,3 @@ describe('a background task', () => { expect(await backgroundTaskState(rig, 'ran')).toBe('done') }) }) - -describe('message places after a restart', () => { - it('continues past the highest place the journal holds, not the first gap', async () => { - const rig = await openProviderTimelineRig({ - scheme: createCodexProviderTimelineIdentityScheme({ - sessionId: SESSION, - primaryThreadId: () => 'root' - }), - ownThread: () => 'root' - }) - await rig.journal.appendItem( - { provider: 'codex', threadId: 'root', turnId: 't1', ordinal: 2 }, - assistantText('old'), - { fence: 1, turnScope: { kind: 'thread' }, providerItemRef: 'item:old' } - ) - const restarted = rig.restart({ generation: 'gen-2' }) - restarted.apply({ - type: 'item.close', - item: 'new', - body: assistantText('new'), - join: { thread: 'root', turn: 't1' } - }) - const placed = (await rig.rows()).find((row) => messageText(row.body) === 'new') - expect(placed?.itemId).toBe('codex:root:t1:3') - }) -}) diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-held-rows.test.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-held-rows.test.ts new file mode 100644 index 000000000000..8d01dfbbb4d7 --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-held-rows.test.ts @@ -0,0 +1,160 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { parseAgentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import { + closeProviderTimelineRigs, + openProviderTimelineRig, + pendingApproval, + providerItemId, + providerTurnItemId, + runningTool +} from './provider-timeline-assembler-test-support' + +afterEach(closeProviderTimelineRigs) + +const answered = { + ...pendingApproval, + resolution: { + state: 'resolved' as const, + selectedOptionId: 'allow', + resolvedBy: 'phone', + resolvedAt: 1_500 + } +} + +describe('what the journal already holds stands', () => { + it('settles at the session end the work the journal holds open, from its rows', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ type: 'item.open', item: 'tool', body: runningTool('read') }) + const identity = parseAgentJournalItemKey(providerItemId('item', 'done')) + if (!identity) { + throw new Error('item key did not parse') + } + // Another writer settled one row in the turn; the session end settles only what is still open. + await rig.journal.appendItem( + identity, + { ...runningTool('grep'), state: 'completed' }, + { fence: 1, turnScope: { kind: 'turn', turnItemId: providerTurnItemId('t1') } } + ) + rig.assembler.apply({ + type: 'session.ended', + verdict: { state: 'interrupted', completedAt: 2_000 } + }) + expect(await rig.turn('t1')).toMatchObject({ state: 'interrupted', completedAt: 2_000 }) + expect((await rig.row(providerItemId('item', 'tool')))?.body).toMatchObject({ state: 'failed' }) + expect((await rig.row(providerItemId('item', 'done')))?.body).toMatchObject({ + state: 'completed' + }) + }) + + it('never resurrects a settled tool from a running snapshot', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ + type: 'item.close', + item: 'tool', + body: { ...runningTool('read'), state: 'completed' } + }) + expect( + rig.assembler.apply({ type: 'item.update', item: 'tool', body: runningTool('read') }).dropped + ).toBe('item-settled') + rig.assembler.apply({ + type: 'session.ended', + verdict: { state: 'interrupted', completedAt: 2_000 } + }) + expect((await rig.row(providerItemId('item', 'tool')))?.body).toMatchObject({ + state: 'completed' + }) + }) +}) + +describe('requests', () => { + it('opens a request reused after it settled as a new one, and withdraws each on its own', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ type: 'request.open', request: 'p1', body: pendingApproval }) + rig.assembler.apply({ type: 'request.withdrawn', request: 'p1' }) + await rig.rows() + rig.assembler.apply({ type: 'request.open', request: 'p1', body: pendingApproval }) + rig.assembler.apply({ type: 'request.withdrawn', request: 'p1' }) + + const prompts = (await rig.rows()).filter((row) => row.body.kind === 'approval') + expect(prompts.map((row) => row.itemId)).toEqual([ + providerItemId('request', 'p1'), + providerItemId('request', 'p1', { incarnation: 2 }) + ]) + expect(prompts.map((row) => row.body)).toEqual([ + expect.objectContaining({ resolution: expect.objectContaining({ state: 'cancelled' }) }), + expect.objectContaining({ resolution: expect.objectContaining({ state: 'cancelled' }) }) + ]) + }) + + it('opens a new request when the pending one under its key was answered', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ type: 'request.open', request: 'p1', body: pendingApproval }) + const identity = parseAgentJournalItemKey(providerItemId('request', 'p1')) + if (!identity) { + throw new Error('request key did not parse') + } + await rig.journal.appendItem(identity, answered, { + fence: 1, + turnScope: { kind: 'turn', turnItemId: providerTurnItemId('t1') } + }) + expect( + rig.assembler.apply({ type: 'request.open', request: 'p1', body: pendingApproval }).dropped + ).toBeUndefined() + // The answered prompt keeps its answer; withdrawing reaches only the new one. + rig.assembler.apply({ type: 'request.withdrawn', request: 'p1' }) + expect((await rig.row(providerItemId('request', 'p1')))?.body).toMatchObject({ + resolution: { state: 'resolved' } + }) + expect( + (await rig.row(providerItemId('request', 'p1', { incarnation: 2 })))?.body + ).toMatchObject({ + resolution: { state: 'cancelled' } + }) + }) + + it('leaves an answered request answered when its withdrawal lands after the answer', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ type: 'request.open', request: 'p1', body: pendingApproval }) + const identity = parseAgentJournalItemKey(providerItemId('request', 'p1')) + if (!identity) { + throw new Error('request key did not parse') + } + // Memory still holds it pending; only the journal knows a client answered. + await rig.journal.appendItem(identity, answered, { + fence: 1, + turnScope: { kind: 'turn', turnItemId: providerTurnItemId('t1') } + }) + rig.assembler.apply({ type: 'request.withdrawn', request: 'p1' }) + expect((await rig.row(providerItemId('request', 'p1')))?.body).toMatchObject({ + resolution: { state: 'resolved', selectedOptionId: 'allow' } + }) + }) + + it('never overwrites a request row already in the journal when its open runs', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ type: 'request.open', request: 'p1', body: pendingApproval }) + const identity = parseAgentJournalItemKey(providerItemId('request', 'p1')) + if (!identity) { + throw new Error('request key did not parse') + } + await rig.journal.appendItem(identity, answered, { + fence: 1, + turnScope: { kind: 'turn', turnItemId: providerTurnItemId('t1') } + }) + // Another assembler of the same generation, blind to the journal, opens `p1` again. + const blind = rig.assemble({ sink: { ...rig.sink, journalItems: () => null } }) + blind.apply({ type: 'request.open', request: 'p1', body: pendingApproval }) + expect((await rig.row(providerItemId('request', 'p1')))?.body).toMatchObject({ + resolution: { state: 'resolved', selectedOptionId: 'allow' } + }) + expect( + (await rig.row(providerItemId('request', 'p1', { incarnation: 2 })))?.body + ).toMatchObject({ resolution: { state: 'pending' } }) + }) +}) diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-recovery.test.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-recovery.test.ts deleted file mode 100644 index b63560978336..000000000000 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-recovery.test.ts +++ /dev/null @@ -1,402 +0,0 @@ -// Memory is a cache: what the assembler forgot (a restart, an evicted entry, a write the journal -// rejected, an event the sink refused) never changes which row an event lands on or what it says. - -import { afterEach, describe, expect, it } from 'vitest' -import { parseAgentJournalItemKey } from '../../../shared/agent-session-journal-item-key' -import { createCodexProviderTimelineIdentityScheme } from '../../codex/codex-provider-timeline-identity' -import { createDeferredStructuredAgentSessionEventSink } from '../agent-session-wire/structured-agent-session-event-sink' -import { testEventSinkLogging } from '../agent-session-wire/structured-agent-session-logger-test-support' -import { createProviderTimelineAssembler } from './provider-timeline-assembler' -import { - AGENT, - assistantText, - backgroundTask, - closeProviderTimelineRigs, - messageText, - NAMESPACE, - openProviderTimelineRig, - pendingApproval, - providerItemId, - providerTurnId, - providerTurnItemId, - refusingSink, - runningTool, - SESSION -} from './provider-timeline-assembler-test-support' -import { providerTimelineSink } from './provider-timeline-plan' - -afterEach(closeProviderTimelineRigs) - -const codexRig = () => - openProviderTimelineRig({ - scheme: createCodexProviderTimelineIdentityScheme({ - sessionId: SESSION, - primaryThreadId: () => 'root' - }), - ownThread: () => 'root' - }) - -const messages = async (rig: Awaited>) => - (await rig.rows()).filter((row) => row.body.kind === 'message') - -describe('a forgotten join finds its original row', () => { - it('finishes an in-flight message after a restart without overwriting an earlier one', async () => { - const rig = await codexRig() - const join = { thread: 'root', turn: 't1' } - rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) - rig.assembler.apply({ type: 'item.close', item: 'm0', body: assistantText('First'), join }) - rig.assembler.apply({ type: 'item.open', item: 'm1', body: assistantText('Partial'), join }) - await rig.rows() - - const restarted = rig.restart({ generation: 'gen-2' }) - restarted.apply({ type: 'item.close', item: 'm1', body: assistantText('Second'), join }) - restarted.apply({ type: 'item.close', item: 'm2', body: assistantText('Third'), join }) - - expect((await messages(rig)).map((row) => [row.itemId, messageText(row.body)])).toEqual([ - ['codex:root:t1:0', 'First'], - ['codex:root:t1:1', 'Second'], - ['codex:root:t1:2', 'Third'] - ]) - }) - - it('resumes an in-flight streamed message after a restart from the text it already holds', async () => { - const rig = await codexRig() - const join = { thread: 'root', turn: 't1' } - rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) - rig.assembler.apply({ - type: 'text.delta', - item: { id: 'm0' }, - channel: 'assistant', - text: 'Hel', - join - }) - await rig.rows() - - const restarted = rig.restart({ generation: 'gen-2' }) - restarted.apply({ - type: 'text.delta', - item: { id: 'm0' }, - channel: 'assistant', - text: 'lo', - join - }) - restarted.apply({ type: 'text.close', item: { id: 'm0' }, join }) - - expect((await messages(rig)).map((row) => [row.itemId, messageText(row.body)])).toEqual([ - ['codex:root:t1:0', 'Hello'] - ]) - }) - - it('reaches a message in its own turn after its join was evicted', async () => { - const rig = await codexRig() - rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) - rig.assembler.apply({ - type: 'item.close', - item: 'old-message', - body: assistantText('Old'), - join: { thread: 'root' } - }) - rig.assembler.apply({ type: 'turn.end', turn: 't1', at: 2_000, state: 'completed' }) - rig.assembler.apply({ type: 'turn.open', turn: 't2', at: 3_000 }) - for (let index = 0; index < 1_025; index += 1) { - rig.assembler.apply({ - type: 'item.close', - item: `m-${index}`, - body: assistantText('small'), - join: { thread: 'root' } - }) - if (index % 32 === 31) { - await rig.rows() - } - } - rig.assembler.apply({ - type: 'item.close', - item: 'old-message', - body: assistantText('Old'), - join: { thread: 'root' } - }) - - const old = (await rig.rows()).filter((row) => messageText(row.body) === 'Old') - expect(old.map((row) => row.itemId)).toEqual(['codex:root:t1:0']) - }) - - it('keeps a late background task update in the turn it opened in after its join was evicted', async () => { - const rig = await openProviderTimelineRig() - rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) - rig.assembler.apply({ - type: 'item.open', - item: 'background-tool', - body: backgroundTask('background-tool', 'working') - }) - rig.assembler.apply({ type: 'turn.end', turn: 't1', at: 2_000, state: 'completed' }) - rig.assembler.apply({ type: 'turn.open', turn: 't2', at: 3_000 }) - for (let index = 0; index < 1_025; index += 1) { - rig.assembler.apply({ type: 'item.close', item: `m-${index}`, body: assistantText('small') }) - if (index % 32 === 31) { - await rig.rows() - } - } - rig.assembler.apply({ - type: 'item.close', - item: 'background-tool', - body: backgroundTask('background-tool', 'done') - }) - - expect((await rig.row(providerItemId('item', 'background-tool')))?.turnScope).toEqual({ - kind: 'turn', - turnItemId: providerTurnItemId('t1') - }) - }) - - it('withdraws the request incarnation a previous run opened', async () => { - const rig = await openProviderTimelineRig() - rig.assembler.apply({ type: 'request.open', request: 'p1', body: pendingApproval }) - rig.assembler.apply({ type: 'request.withdrawn', request: 'p1' }) - rig.assembler.apply({ type: 'request.open', request: 'p1', body: pendingApproval }) - await rig.rows() - - rig.restart({ generation: 'gen-2' }).apply({ type: 'request.withdrawn', request: 'p1' }) - - expect( - (await rig.row(providerItemId('request', 'p1', { incarnation: 2 })))?.body - ).toMatchObject({ resolution: { state: 'cancelled' } }) - }) -}) - -describe('streamed text has the same lifecycle as the item it streams into', () => { - it('keeps interleaved same-id streams on separate threads apart', async () => { - const rig = await codexRig() - rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) - rig.assembler.apply({ - type: 'text.delta', - item: { id: 'm1' }, - channel: 'assistant', - text: 'Root', - join: { thread: 'root', turn: 't1' } - }) - rig.assembler.apply({ - type: 'text.delta', - item: { id: 'm1' }, - channel: 'assistant', - text: 'Child', - join: { thread: 'child', turn: 'child-turn' } - }) - rig.assembler.apply({ type: 'turn.end', turn: 't1', at: 2_000, state: 'completed' }) - - expect((await messages(rig)).map((row) => [row.itemId, messageText(row.body)])).toEqual([ - ['codex:root:t1:0', 'Root'], - ['codex:child:child-turn:0', 'Child'] - ]) - }) - - it('never lets replayed text replace a message its settled turn completed', async () => { - const rig = await openProviderTimelineRig() - rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) - rig.assembler.apply({ type: 'item.close', item: 'm1', body: assistantText('Full final text') }) - rig.assembler.apply({ type: 'turn.end', turn: 't1', at: 2_000, state: 'completed' }) - await rig.rows() - - const restarted = rig.restart({ generation: 'gen-2' }) - expect( - restarted.apply({ - type: 'text.delta', - item: { id: 'm1' }, - channel: 'assistant', - text: 'Full', - join: { turn: 't1' } - }).dropped - ).toBe('item-settled') - restarted.flush() - - expect(messageText((await rig.row(providerItemId('item', 'm1')))?.body)).toBe('Full final text') - }) - - it('settles a message on text.close, so a late delta cannot erase what it said', async () => { - const rig = await openProviderTimelineRig() - rig.assembler.apply({ - type: 'text.delta', - item: { id: 'm1' }, - channel: 'assistant', - text: 'Prefix' - }) - rig.assembler.apply({ type: 'text.close', item: { id: 'm1' } }) - expect( - rig.assembler.apply({ - type: 'text.delta', - item: { id: 'm1' }, - channel: 'assistant', - text: 'Suffix' - }).dropped - ).toBe('item-settled') - rig.assembler.flush() - - expect(messageText((await rig.row(providerItemId('item', 'm1')))?.body)).toBe('Prefix') - }) -}) - -describe('a write the journal rejects changes nothing the assembler knows', () => { - it('keeps a running tool open when a replayed supersede it planned before bind lands as nothing', async () => { - const rig = await openProviderTimelineRig() - rig.assembler.apply({ type: 'turn.open', turn: 'old', at: 1_000 }) - rig.assembler.apply({ type: 'turn.end', turn: 'old', at: 2_000, state: 'completed' }) - rig.assembler.apply({ type: 'turn.open', turn: 'live', at: 3_000 }) - await rig.rows() - - const deferred = createDeferredStructuredAgentSessionEventSink(testEventSinkLogging()) - const sink = providerTimelineSink(deferred.sink) - if (!sink) { - throw new Error('the deferred sink offers transitions') - } - const assembler = createProviderTimelineAssembler({ - sink, - sessionId: SESSION, - agent: AGENT, - generation: 'gen-2', - namespace: NAMESPACE - }) - // Planned before bind: nothing tells the planner the journal already holds both turns. - assembler.apply({ type: 'turn.open', turn: 'live', at: 3_000 }) - assembler.apply({ type: 'item.open', item: 'tool', body: runningTool('read') }) - assembler.apply({ type: 'turn.open', turn: 'old', at: 1_000 }) - deferred.bind({ journal: rig.journal, fence: 1, publish: () => {} }) - await deferred.drained() - expect(await rig.turn('live')).toMatchObject({ state: 'running' }) - expect(assembler.openTurnId).toBe(providerTurnId('live')) - - const closed = assembler.apply({ - type: 'item.close', - item: 'tool', - body: { ...runningTool('read'), state: 'completed' } - }) - expect(closed.dropped).toBeUndefined() - await deferred.drained() - expect((await rig.row(providerItemId('item', 'tool')))?.body).toMatchObject({ - state: 'completed' - }) - deferred.close() - assembler.dispose() - }) - - it('takes no message ordinal for an event the sink refused', async () => { - const rig = await codexRig() - let refusing = false - const assembler = rig.restart({ sink: refusingSink(rig.sink, () => refusing) }) - const join = { thread: 'root', turn: 't1' } - assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) - refusing = true - expect( - assembler.apply({ type: 'item.close', item: 'refused', body: assistantText('No'), join }) - .admission - ).toEqual({ accepted: false, reason: 'backpressure' }) - refusing = false - assembler.apply({ type: 'item.close', item: 'accepted', body: assistantText('Yes'), join }) - - expect((await messages(rig)).map((row) => row.itemId)).toEqual(['codex:root:t1:0']) - }) -}) - -describe('reset settles the old session whatever the assembler remembers', () => { - it('writes text a reset interrupts, though nothing else was open', async () => { - const rig = await openProviderTimelineRig({ schedule: () => () => {} }) - rig.assembler.apply({ - type: 'text.delta', - item: { stream: 'reply' }, - channel: 'assistant', - text: 'Before reset' - }) - rig.assembler.apply({ type: 'session.reset', namespace: 'provider-session-2' }) - - expect((await messages(rig)).map((row) => messageText(row.body))).toEqual(['Before reset']) - }) - - it('settles the journal work of a previous run as lost', async () => { - const rig = await openProviderTimelineRig() - rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) - rig.assembler.apply({ type: 'item.open', item: 'tool', body: runningTool('read') }) - await rig.rows() - - rig.restart({ generation: 'gen-2' }).apply({ - type: 'session.reset', - namespace: 'provider-session-2' - }) - - expect(await rig.turn('t1')).toMatchObject({ state: 'unverifiable' }) - expect((await rig.row(providerItemId('item', 'tool')))?.body).toMatchObject({ - state: 'failed' - }) - }) -}) - -describe('the budget counts the work actually open', () => { - it('frees an answered request incarnation without waiting for its turn to end', async () => { - const rig = await openProviderTimelineRig() - rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) - for (let incarnation = 1; incarnation <= 129; incarnation += 1) { - expect( - rig.assembler.apply({ type: 'request.open', request: 'approval', body: pendingApproval }) - .admission - ).toEqual({ accepted: true }) - await rig.rows() - const identity = parseAgentJournalItemKey( - providerItemId('request', 'approval', { incarnation }) - ) - if (!identity) { - throw new Error('the request has a journal key') - } - // A client answers it, through the journal. - await rig.journal.appendItem( - identity, - { - ...pendingApproval, - resolution: { - state: 'resolved', - selectedOptionId: 'allow', - resolvedBy: 'phone', - resolvedAt: 1_100 - } - }, - { fence: 1, turnScope: { kind: 'turn', turnItemId: providerTurnItemId('t1') } } - ) - } - }) - - it('keeps a streamed message counted while its stream is open, whatever its snapshots say', async () => { - const rig = await openProviderTimelineRig({ schedule: () => () => {} }) - for (let index = 0; index < 128; index += 1) { - rig.assembler.apply({ - type: 'text.delta', - item: { id: `stream-${index}` }, - channel: 'assistant', - text: 'x' - }) - rig.assembler.apply({ - type: 'item.update', - item: `stream-${index}`, - body: assistantText('x') - }) - if (index % 32 === 31) { - await rig.rows() - } - } - expect( - rig.assembler.apply({ - type: 'text.delta', - item: { id: 'overflow' }, - channel: 'assistant', - text: 'x' - }).admission - ).toEqual({ accepted: false, reason: 'failed' }) - }) - - it('refuses a provider key larger than the whole budget', () => - openProviderTimelineRig({ schedule: () => () => {} }).then((rig) => { - expect( - rig.assembler.apply({ - type: 'text.delta', - item: { id: 'x'.repeat(1024 * 1024 + 1) }, - channel: 'assistant', - text: 'tiny' - }).admission - ).toEqual({ accepted: false, reason: 'failed' }) - })) -}) diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-refusal.test.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-refusal.test.ts index 4551013a3c64..2580926b30a2 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-refusal.test.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-refusal.test.ts @@ -18,7 +18,7 @@ describe('a refused event changes nothing and its retry lands it once', () => { it('settles the tool a refused turn end owed when the end is re-applied', async () => { const rig = await openProviderTimelineRig() let refusing = false - const assembler = rig.restart({ sink: refusingSink(rig.sink, () => refusing) }) + const assembler = rig.assemble({ sink: refusingSink(rig.sink, () => refusing) }) assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) assembler.apply({ type: 'item.open', item: 'tool', body: runningTool('read') }) refusing = true @@ -36,7 +36,7 @@ describe('a refused event changes nothing and its retry lands it once', () => { it('keeps observed text when the close that would write it is refused', async () => { const rig = await openProviderTimelineRig() let refusing = false - const assembler = rig.restart({ + const assembler = rig.assemble({ sink: refusingSink(rig.sink, () => refusing), schedule: () => () => {} }) @@ -59,7 +59,7 @@ describe('a refused event changes nothing and its retry lands it once', () => { const runs: (() => void)[] = [] const rig = await openProviderTimelineRig() let refusal: 'backpressure' | 'failed' | null = null - const assembler = rig.restart({ + const assembler = rig.assemble({ sink: { ...rig.sink, tryAppendTransition: (transition) => @@ -90,7 +90,7 @@ describe('a refused event changes nothing and its retry lands it once', () => { it('does not latch a refused session end, so its retry still settles the session', async () => { const rig = await openProviderTimelineRig() let refusing = false - const assembler = rig.restart({ sink: refusingSink(rig.sink, () => refusing) }) + const assembler = rig.assemble({ sink: refusingSink(rig.sink, () => refusing) }) assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) assembler.apply({ type: 'request.open', request: 'p1', body: pendingApproval }) refusing = true @@ -115,7 +115,7 @@ describe('a refused event changes nothing and its retry lands it once', () => { it('leaves the open turn in place when a superseding open is refused', async () => { const rig = await openProviderTimelineRig() let refusing = false - const assembler = rig.restart({ sink: refusingSink(rig.sink, () => refusing) }) + const assembler = rig.assemble({ sink: refusingSink(rig.sink, () => refusing) }) assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) const first = assembler.openTurnId refusing = true @@ -128,21 +128,6 @@ describe('a refused event changes nothing and its retry lands it once', () => { expect(await rig.turn('t1')).toMatchObject({ state: 'interrupted', outcome: 'superseded' }) expect(await rig.turn('t2')).toMatchObject({ state: 'running' }) }) - - it('keeps the old session open when the reset that would settle it is refused', async () => { - const rig = await openProviderTimelineRig() - let refusing = false - const assembler = rig.restart({ sink: refusingSink(rig.sink, () => refusing) }) - assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) - refusing = true - const reset = { type: 'session.reset', namespace: 'provider-session-2' } as const - expect(assembler.apply(reset).admission).toEqual(BACKPRESSURE) - expect(assembler.openTurnId).not.toBeNull() - refusing = false - assembler.apply(reset) - expect(assembler.openTurnId).toBeNull() - expect(await rig.turn('t1')).toMatchObject({ state: 'unverifiable' }) - }) }) describe('what the assembler holds open is bounded', () => { diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-replay.test.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-replay.test.ts deleted file mode 100644 index bb0eee27f429..000000000000 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-replay.test.ts +++ /dev/null @@ -1,295 +0,0 @@ -import { afterEach, describe, expect, it } from 'vitest' -import { parseAgentJournalItemKey } from '../../../shared/agent-session-journal-item-key' -import { - closeProviderTimelineRigs, - openProviderTimelineRig, - pendingApproval, - providerItemId, - providerTurnItemId, - runningTool -} from './provider-timeline-assembler-test-support' - -afterEach(closeProviderTimelineRigs) - -const answered = { - ...pendingApproval, - resolution: { - state: 'resolved' as const, - selectedOptionId: 'allow', - resolvedBy: 'phone', - resolvedAt: 1_500 - } -} - -describe('a replay never changes what the journal already holds', () => { - it('keeps an answered prompt answered when a restarted host replays its open and its turn', async () => { - const rig = await openProviderTimelineRig() - rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) - rig.assembler.apply({ type: 'request.open', request: 'p1', body: pendingApproval }) - const itemId = providerItemId('request', 'p1') - const identity = parseAgentJournalItemKey(itemId) - if (!identity) { - throw new Error('request key did not parse') - } - // The answer path's compare-and-set lands first, with no word to the assembler. - await rig.journal.appendItem(identity, answered, { - fence: 1, - turnScope: { kind: 'turn', turnItemId: providerTurnItemId('t1') } - }) - rig.assembler.apply({ type: 'turn.end', at: 2_000, state: 'completed', outcome: 'success' }) - expect((await rig.row(itemId))?.body).toMatchObject({ resolution: { state: 'resolved' } }) - - const restarted = rig.restart({ generation: 'gen-2' }) - expect(restarted.apply({ type: 'turn.open', turn: 't1', at: 3_000 }).dropped).toBe( - 'turn-replayed' - ) - expect( - restarted.apply({ type: 'request.open', request: 'p1', body: pendingApproval }).dropped - ).toBe('request-replayed') - restarted.apply({ type: 'turn.end', at: 4_000, state: 'completed' }) - expect((await rig.row(itemId))?.body).toMatchObject({ - resolution: { state: 'resolved', selectedOptionId: 'allow' } - }) - }) - - it('does not let a replayed open of a settled turn supersede the live one', async () => { - const rig = await openProviderTimelineRig() - rig.assembler.apply({ type: 'turn.open', turn: 'old', at: 1_000 }) - rig.assembler.apply({ type: 'turn.end', at: 2_000, state: 'completed' }) - await rig.rows() - const restarted = rig.restart({ generation: 'gen-2' }) - restarted.apply({ type: 'turn.open', turn: 'live', at: 3_000 }) - restarted.apply({ type: 'item.open', item: 'live-tool', body: runningTool('read') }) - const live = restarted.openTurnId - - expect(restarted.apply({ type: 'turn.open', turn: 'old', at: 1_000 }).dropped).toBe( - 'turn-replayed' - ) - expect(restarted.openTurnId).toBe(live) - expect(await rig.turn('live')).toMatchObject({ state: 'running' }) - expect((await rig.row(providerItemId('item', 'live-tool')))?.body).toMatchObject({ - state: 'running' - }) - }) - - it('settles a turn the journal holds running even when the assembler that opened it is gone', async () => { - const rig = await openProviderTimelineRig() - rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) - rig.assembler.apply({ type: 'item.open', item: 'tool', body: runningTool('read') }) - await rig.rows() - const restarted = rig.restart({ generation: 'gen-2' }) - restarted.apply({ type: 'turn.end', turn: 't1', at: 2_000, state: 'completed' }) - expect(await rig.turn('t1')).toMatchObject({ state: 'completed', completedAt: 2_000 }) - expect((await rig.row(providerItemId('item', 'tool')))?.body).toMatchObject({ state: 'failed' }) - }) - - it('takes its open turn back from the journal when a planned open found the row already there', async () => { - const rig = await openProviderTimelineRig() - rig.assembler.apply({ type: 'turn.open', turn: 'live', at: 1_000 }) - await rig.rows() - const live = rig.assembler.openTurnId - // The plan reads no journal yet (as before it binds); the write still asks it when it runs. - let lagging = true - const stale = rig.restart({ - sink: { ...rig.sink, journalItems: () => (lagging ? null : rig.sink.journalItems()) }, - generation: 'gen-2' - }) - expect(stale.apply({ type: 'turn.open', turn: 'live', at: 1_000 }).dropped).toBeUndefined() - lagging = false - await rig.rows() - await Promise.resolve() - expect(stale.openTurnId).toBe(live) - expect(await rig.turn('live')).toMatchObject({ state: 'running', startedAt: 1_000 }) - }) - - it('never resurrects a settled tool from a running snapshot', async () => { - const rig = await openProviderTimelineRig() - rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) - rig.assembler.apply({ - type: 'item.close', - item: 'tool', - body: { ...runningTool('read'), state: 'completed' } - }) - expect( - rig.assembler.apply({ type: 'item.update', item: 'tool', body: runningTool('read') }).dropped - ).toBe('item-settled') - rig.assembler.apply({ - type: 'session.ended', - verdict: { state: 'interrupted', completedAt: 2_000 } - }) - expect((await rig.row(providerItemId('item', 'tool')))?.body).toMatchObject({ - state: 'completed' - }) - }) - - it('does not reopen a settled tool a restarted host replays', async () => { - const rig = await openProviderTimelineRig() - rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) - rig.assembler.apply({ type: 'item.open', item: 'tool', body: runningTool('read') }) - rig.assembler.apply({ - type: 'item.close', - item: 'tool', - body: { ...runningTool('read'), state: 'completed' } - }) - await rig.rows() - const restarted = rig.restart({ generation: 'gen-2' }) - expect( - restarted.apply({ type: 'item.open', item: 'tool', body: runningTool('read') }).dropped - ).toBe('item-replayed') - expect( - restarted.apply({ type: 'item.update', item: 'tool', body: runningTool('read') }).dropped - ).toBe('item-settled') - expect((await rig.row(providerItemId('item', 'tool')))?.body).toMatchObject({ - state: 'completed' - }) - }) -}) - -describe('requests', () => { - it('opens a request reused after it settled as a new one, and withdraws each on its own', async () => { - const rig = await openProviderTimelineRig() - rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) - rig.assembler.apply({ type: 'request.open', request: 'p1', body: pendingApproval }) - rig.assembler.apply({ type: 'request.withdrawn', request: 'p1' }) - await rig.rows() - rig.assembler.apply({ type: 'request.open', request: 'p1', body: pendingApproval }) - rig.assembler.apply({ type: 'request.withdrawn', request: 'p1' }) - - const prompts = (await rig.rows()).filter((row) => row.body.kind === 'approval') - expect(prompts.map((row) => row.itemId)).toEqual([ - providerItemId('request', 'p1'), - providerItemId('request', 'p1', { incarnation: 2 }) - ]) - expect(prompts.map((row) => row.body)).toEqual([ - expect.objectContaining({ resolution: expect.objectContaining({ state: 'cancelled' }) }), - expect.objectContaining({ resolution: expect.objectContaining({ state: 'cancelled' }) }) - ]) - }) - - it('opens a new request when the pending one under its key was answered', async () => { - const rig = await openProviderTimelineRig() - rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) - rig.assembler.apply({ type: 'request.open', request: 'p1', body: pendingApproval }) - const identity = parseAgentJournalItemKey(providerItemId('request', 'p1')) - if (!identity) { - throw new Error('request key did not parse') - } - await rig.journal.appendItem(identity, answered, { - fence: 1, - turnScope: { kind: 'turn', turnItemId: providerTurnItemId('t1') } - }) - expect( - rig.assembler.apply({ type: 'request.open', request: 'p1', body: pendingApproval }).dropped - ).toBeUndefined() - // The answered prompt keeps its answer; withdrawing reaches only the new one. - rig.assembler.apply({ type: 'request.withdrawn', request: 'p1' }) - expect((await rig.row(providerItemId('request', 'p1')))?.body).toMatchObject({ - resolution: { state: 'resolved' } - }) - expect( - (await rig.row(providerItemId('request', 'p1', { incarnation: 2 })))?.body - ).toMatchObject({ - resolution: { state: 'cancelled' } - }) - }) - - it('leaves an answered request answered when its withdrawal lands after the answer', async () => { - const rig = await openProviderTimelineRig() - rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) - rig.assembler.apply({ type: 'request.open', request: 'p1', body: pendingApproval }) - const identity = parseAgentJournalItemKey(providerItemId('request', 'p1')) - if (!identity) { - throw new Error('request key did not parse') - } - // Memory still holds it pending; only the journal knows a client answered. - await rig.journal.appendItem(identity, answered, { - fence: 1, - turnScope: { kind: 'turn', turnItemId: providerTurnItemId('t1') } - }) - rig.assembler.apply({ type: 'request.withdrawn', request: 'p1' }) - expect((await rig.row(providerItemId('request', 'p1')))?.body).toMatchObject({ - resolution: { state: 'resolved', selectedOptionId: 'allow' } - }) - }) - - it('never overwrites a request row it could not see when it planned the open', async () => { - const rig = await openProviderTimelineRig() - rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) - rig.assembler.apply({ type: 'request.open', request: 'p1', body: pendingApproval }) - const identity = parseAgentJournalItemKey(providerItemId('request', 'p1')) - if (!identity) { - throw new Error('request key did not parse') - } - await rig.journal.appendItem(identity, answered, { - fence: 1, - turnScope: { kind: 'turn', turnItemId: providerTurnItemId('t1') } - }) - const blind = rig.restart({ - sink: { ...rig.sink, journalItems: () => null }, - generation: 'gen-2' - }) - blind.apply({ type: 'request.open', request: 'p1', body: pendingApproval }) - expect((await rig.row(providerItemId('request', 'p1')))?.body).toMatchObject({ - resolution: { state: 'resolved', selectedOptionId: 'allow' } - }) - }) -}) - -describe('a new provider session is a new id space', () => { - it('gives a reused provider turn id its own row after a reset', async () => { - const rig = await openProviderTimelineRig() - rig.assembler.apply({ type: 'turn.open', turn: '1', at: 1_000 }) - rig.assembler.apply({ type: 'turn.end', at: 2_000, state: 'completed' }) - rig.assembler.apply({ - type: 'session.ended', - verdict: { state: 'interrupted', completedAt: 2_000 } - }) - rig.assembler.apply({ type: 'session.reset', namespace: 'provider-session-2' }) - rig.assembler.apply({ type: 'turn.open', turn: '1', at: 3_000 }) - - expect((await rig.turns()).map((turn) => turn.state)).toEqual(['completed', 'running']) - expect(await rig.turn('1', 'provider-session-2')).toMatchObject({ startedAt: 3_000 }) - }) - - it('does not overwrite an earlier session’s item when the provider reuses its id', async () => { - const rig = await openProviderTimelineRig() - rig.assembler.apply({ - type: 'item.close', - item: 'tool-1', - body: { ...runningTool('first'), state: 'completed' } - }) - rig.assembler.apply({ - type: 'session.ended', - verdict: { state: 'interrupted', completedAt: 2_000 } - }) - rig.assembler.apply({ type: 'session.reset', namespace: 'provider-session-2' }) - rig.assembler.apply({ - type: 'item.close', - item: 'tool-1', - body: { ...runningTool('second'), state: 'completed' } - }) - const tools = (await rig.rows()).filter((row) => row.body.kind === 'tool-call') - expect(tools.map((row) => row.body)).toEqual([ - expect.objectContaining({ name: 'first' }), - expect.objectContaining({ name: 'second' }) - ]) - }) - - it('settles the open work of a session reset before anything ended it, as lost', async () => { - const rig = await openProviderTimelineRig() - rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) - rig.assembler.apply({ type: 'item.open', item: 'tool', body: runningTool('read') }) - rig.assembler.apply({ - type: 'text.delta', - item: { stream: 'reply' }, - channel: 'assistant', - text: 'Half' - }) - rig.assembler.apply({ type: 'session.reset', namespace: 'provider-session-2' }) - rig.assembler.apply({ type: 'session.ended', verdict: { state: 'unverifiable' } }) - - expect((await rig.row(providerItemId('item', 'tool')))?.body).toMatchObject({ state: 'failed' }) - expect(await rig.turn('t1')).toMatchObject({ state: 'unverifiable' }) - expect((await rig.rows()).filter((row) => row.body.kind === 'message')).toHaveLength(1) - }) -}) diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-restart.test.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-restart.test.ts new file mode 100644 index 000000000000..5b4033e3ff6d --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-restart.test.ts @@ -0,0 +1,153 @@ +// A new provider child is a new assembler. Its events can be admitted before its sink binds, and +// the dead-generation sweep for the previous child lands before they are written, so every write +// decides from the journal the sweep left. + +import { afterEach, describe, expect, it } from 'vitest' +import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' +import { settleStaleStructuredAgentSessionState } from '../agent-session-wire/structured-agent-session-dead-generation-settlement' +import { + closeProviderTimelineRigs, + messageText, + openProviderTimelineRig, + openUnboundProviderTimelineAssembler, + pendingApproval, + providerItemId, + providerTurnItemId, + runningTool, + SESSION, + type ProviderTimelineRig +} from './provider-timeline-assembler-test-support' +import type { ProviderTimelineEvent } from './provider-timeline-event' + +afterEach(closeProviderTimelineRigs) + +const userText = (text: string): AgentJournalMessageItem => ({ + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text }] +}) + +/** A provider's saved history of one prompt, as an adapter adopting the session replays it. */ +const history: ProviderTimelineEvent[] = [ + { type: 'input.history', item: 'u1', body: userText('Fix it'), join: { turn: 'p1' } }, + { type: 'turn.open', turn: 'p1', at: 1_000 }, + { + type: 'text.delta', + item: { id: 'a1' }, + channel: 'assistant', + text: 'Looking', + join: { turn: 'p1' } + }, + { type: 'text.close', item: { id: 'a1' }, join: { turn: 'p1' } }, + { type: 'item.open', item: 'call-1', body: runningTool('read'), join: { turn: 'p1' } }, + { + type: 'item.close', + item: 'call-1', + body: { ...runningTool('read'), state: 'completed' }, + join: { turn: 'p1' } + }, + { type: 'turn.end', turn: 'p1', at: 2_000, state: 'completed', outcome: 'success' } +] + +async function revisions(rig: ProviderTimelineRig): Promise<[string, number][]> { + return (await rig.rows()).map((row) => [row.itemId, row.revision]) +} + +describe('an adopted session', () => { + it('lands the provider history as rows keyed by its own ids, opened by its saved message', async () => { + const rig = await openProviderTimelineRig() + history.forEach((event) => rig.assembler.apply(event)) + rig.assembler.flush() + + const user = await rig.row(providerItemId('item', 'u1')) + expect(user).toMatchObject({ + body: userText('Fix it'), + turnScope: { kind: 'turn', turnItemId: providerTurnItemId('p1') } + }) + expect(await rig.turn('p1')).toMatchObject({ + state: 'completed', + outcome: 'success', + userItemId: user?.itemId + }) + expect(messageText((await rig.row(providerItemId('item', 'a1')))?.body)).toBe('Looking') + expect((await rig.row(providerItemId('item', 'call-1')))?.body).toMatchObject({ + state: 'completed' + }) + + // Adoption run again by the next child (its first try was interrupted): nothing new lands. + const before = await revisions(rig) + const again = await rig.restart() + history.forEach((event) => again.apply(event)) + again.flush() + expect(await revisions(rig)).toEqual(before) + }) + + it('re-runs an adoption a crash cut midway without a second copy or a regressed row', async () => { + const rig = await openProviderTimelineRig() + history.slice(0, 5).forEach((event) => rig.assembler.apply(event)) + rig.assembler.flush() + await rig.rows() + // The child died with the turn and its tool running; the next acquisition sweeps them. + await rig.restart() + const swept = await rig.turn('p1') + expect(swept?.state).not.toBe('running') + const before = (await rig.rows()).length + + // The new child replays the whole history before its sink binds: only the writes can see it. + const { assembler, bind } = openUnboundProviderTimelineAssembler(rig.journal, { + generation: 'gen-3' + }) + history.forEach((event) => expect(assembler.apply(event).dropped).toBeUndefined()) + assembler.flush() + await bind() + + expect(await rig.rows()).toHaveLength(before) + expect(await rig.turn('p1')).toEqual(swept) + expect((await rig.row(providerItemId('item', 'call-1')))?.body).toMatchObject({ + state: 'failed' + }) + expect(messageText((await rig.row(providerItemId('item', 'a1')))?.body)).toBe('Looking') + }) +}) + +describe('a resumed session', () => { + it('decides events admitted before bind against the journal the sweep left', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 'old', at: 1_000 }) + rig.assembler.apply({ type: 'item.open', item: 'call-a', body: runningTool('read') }) + rig.assembler.apply({ type: 'request.open', request: '0', body: pendingApproval }) + await rig.rows() + + // The next child's events are admitted while its sink is unbound. + const { assembler, bind } = openUnboundProviderTimelineAssembler(rig.journal, { + generation: 'gen-2' + }) + const late = { type: 'item.update', item: 'call-a', body: runningTool('read') } as const + assembler.apply({ ...late, join: { turn: 'old' } }) + assembler.apply({ type: 'turn.open', turn: 'new', at: 3_000 }) + assembler.apply({ type: 'request.open', request: '0', body: pendingApproval }) + // The sweep for the dead child lands before the drain. + await settleStaleStructuredAgentSessionState({ + journal: rig.journal, + sessionId: SESSION, + fence: 1, + acquisitionGeneration: 'gen-2', + deathEvidence: null + }) + await bind() + + expect((await rig.turn('old'))?.state).not.toBe('running') + // The swept tool is not relit by a running report that was admitted before the sweep. + expect((await rig.row(providerItemId('item', 'call-a')))?.body).toMatchObject({ + state: 'failed' + }) + expect((await rig.row(providerItemId('request', '0')))?.body).toMatchObject({ + resolution: { state: 'cancelled' } + }) + expect(await rig.turn('new')).toMatchObject({ state: 'running', startedAt: 3_000 }) + expect(await rig.row(providerItemId('request', '0', { generation: 'gen-2' }))).toMatchObject({ + body: { resolution: { state: 'pending' } }, + turnScope: { kind: 'turn', turnItemId: providerTurnItemId('new') } + }) + }) +}) diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-rows.test.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-rows.test.ts new file mode 100644 index 000000000000..8e4de9e95ae9 --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-rows.test.ts @@ -0,0 +1,207 @@ +// Which row an event lands on is spelled from its keys, and an event the sink refused took +// nothing: no key, no row, no room in the budget. + +import { afterEach, describe, expect, it } from 'vitest' +import { parseAgentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import { + assistantText, + backgroundTask, + closeProviderTimelineRigs, + messageText, + openProviderTimelineRig, + pendingApproval, + providerItemId, + providerTurnItemId, + refusingSink +} from './provider-timeline-assembler-test-support' + +afterEach(closeProviderTimelineRigs) + +const messages = async (rig: Awaited>) => + (await rig.rows()).filter((row) => row.body.kind === 'message') + +describe('a refused event takes nothing', () => { + it('allocates no key and no request row, and its retry lands once', async () => { + const rig = await openProviderTimelineRig() + let refusing = false + const assembler = rig.assemble({ sink: refusingSink(rig.sink, () => refusing) }) + const refused = { accepted: false, reason: 'backpressure' } + const turnOpen = { type: 'turn.open', at: 1_000 } as const + const frame = { type: 'provider.frame', frameKind: 'mystery', payload: { a: 1 } } as const + const request = { type: 'request.open', request: 'p1', body: pendingApproval } as const + refusing = true + expect(assembler.apply(turnOpen).admission).toEqual(refused) + expect(assembler.openTurnId).toBeNull() + refusing = false + expect(assembler.apply(turnOpen).admission).toEqual({ accepted: true }) + // The minted turn takes the first serial, as if the refusal never happened. + expect(assembler.openTurnId).toBe('m:gen-1%3At1') + refusing = true + expect(assembler.apply(frame).admission).toEqual(refused) + expect(assembler.apply(request).admission).toEqual(refused) + refusing = false + expect(assembler.apply(frame).admission).toEqual({ accepted: true }) + expect(assembler.apply(request).admission).toEqual({ accepted: true }) + assembler.apply({ + type: 'text.delta', + item: { stream: 'reply' }, + channel: 'assistant', + text: 'Hi' + }) + assembler.flush() + + const rows = (await rig.rows()).filter((row) => row.body.kind !== 'turn') + expect(rows.map((row) => row.itemId)).toEqual([ + expect.stringContaining('frame%3Am%3Agen-1%253Af2'), + providerItemId('request', 'p1'), + expect.stringContaining('item%3Am%3Agen-1%253As3') + ]) + expect(messageText(rows[2]?.body)).toBe('Hi') + }) +}) + +describe('a row keeps the turn it opened in', () => { + it('takes a background task update in the turn it opened in, after that turn ended', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ + type: 'item.open', + item: 'background-tool', + body: backgroundTask('background-tool', 'working') + }) + rig.assembler.apply({ type: 'turn.end', turn: 't1', at: 2_000, state: 'completed' }) + // No turn is open: the update still joins the row's own turn, not the conversation. + rig.assembler.apply({ + type: 'item.update', + item: 'background-tool', + body: backgroundTask('background-tool', 'done') + }) + expect(await rig.row(providerItemId('item', 'background-tool'))).toMatchObject({ + turnScope: { kind: 'turn', turnItemId: providerTurnItemId('t1') }, + body: backgroundTask('background-tool', 'done') + }) + }) +}) + +describe('streamed text has the same lifecycle as the item it streams into', () => { + it('keeps interleaved same-id streams on separate threads apart', async () => { + const rig = await openProviderTimelineRig({ ownThread: () => 'root' }) + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ + type: 'text.delta', + item: { id: 'm1' }, + channel: 'assistant', + text: 'Root', + join: { thread: 'root', turn: 't1' } + }) + rig.assembler.apply({ + type: 'text.delta', + item: { id: 'm1' }, + channel: 'assistant', + text: 'Child', + join: { thread: 'child', turn: 'child-turn' } + }) + rig.assembler.apply({ type: 'turn.end', turn: 't1', at: 2_000, state: 'completed' }) + + expect((await messages(rig)).map((row) => [row.itemId, messageText(row.body)])).toEqual([ + [providerItemId('item', 'm1', { thread: 'root' }), 'Root'], + [providerItemId('item', 'm1', { thread: 'child' }), 'Child'] + ]) + }) + + it('settles a message on text.close, so a late delta cannot erase what it said', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ + type: 'text.delta', + item: { id: 'm1' }, + channel: 'assistant', + text: 'Prefix' + }) + rig.assembler.apply({ type: 'text.close', item: { id: 'm1' } }) + expect( + rig.assembler.apply({ + type: 'text.delta', + item: { id: 'm1' }, + channel: 'assistant', + text: 'Suffix' + }).dropped + ).toBe('item-settled') + rig.assembler.flush() + + expect(messageText((await rig.row(providerItemId('item', 'm1')))?.body)).toBe('Prefix') + }) +}) + +describe('the budget counts the work actually open', () => { + it('frees an answered request incarnation without waiting for its turn to end', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + for (let incarnation = 1; incarnation <= 129; incarnation += 1) { + expect( + rig.assembler.apply({ type: 'request.open', request: 'approval', body: pendingApproval }) + .admission + ).toEqual({ accepted: true }) + await rig.rows() + const identity = parseAgentJournalItemKey( + providerItemId('request', 'approval', { incarnation }) + ) + if (!identity) { + throw new Error('the request has a journal key') + } + // A client answers it, through the journal. + await rig.journal.appendItem( + identity, + { + ...pendingApproval, + resolution: { + state: 'resolved', + selectedOptionId: 'allow', + resolvedBy: 'phone', + resolvedAt: 1_100 + } + }, + { fence: 1, turnScope: { kind: 'turn', turnItemId: providerTurnItemId('t1') } } + ) + } + }) + + it('keeps a streamed message counted while its stream is open, whatever its snapshots say', async () => { + const rig = await openProviderTimelineRig({ schedule: () => () => {} }) + for (let index = 0; index < 128; index += 1) { + rig.assembler.apply({ + type: 'text.delta', + item: { id: `stream-${index}` }, + channel: 'assistant', + text: 'x' + }) + rig.assembler.apply({ + type: 'item.update', + item: `stream-${index}`, + body: assistantText('x') + }) + if (index % 32 === 31) { + await rig.rows() + } + } + expect( + rig.assembler.apply({ + type: 'text.delta', + item: { id: 'overflow' }, + channel: 'assistant', + text: 'x' + }).admission + ).toEqual({ accepted: false, reason: 'failed' }) + }) + + it('refuses a provider key larger than the whole budget', () => + openProviderTimelineRig({ schedule: () => () => {} }).then((rig) => { + expect( + rig.assembler.apply({ + type: 'text.delta', + item: { id: 'x'.repeat(1024 * 1024 + 1) }, + channel: 'assistant', + text: 'tiny' + }).admission + ).toEqual({ accepted: false, reason: 'failed' }) + })) +}) diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-session.test.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-session.test.ts index 3bc3aeb3caa5..fff993850eb1 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-session.test.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-session.test.ts @@ -67,7 +67,7 @@ describe('provider timeline session end', () => { expect(turn).not.toHaveProperty('outcome') }) - it('drops everything after the session ended until a reset starts a fresh namespace', async () => { + it('drops everything after the session ended', async () => { const rig = await openProviderTimelineRig() rig.assembler.apply({ type: 'turn.open', at: 1_000 }) const first = rig.assembler.openTurnId @@ -81,16 +81,9 @@ describe('provider timeline session end', () => { rig.assembler.apply({ type: 'item.open', item: 'call-a', body: runningTool('read') }).dropped ).toBe('session-ended') expect(rig.assembler.openTurnId).toBeNull() - - rig.assembler.apply({ type: 'session.reset', namespace: 'provider-session-2' }) - rig.assembler.apply({ type: 'turn.open', at: 3_000 }) - const second = rig.assembler.openTurnId - expect(second).not.toBeNull() - expect(second).not.toBe(first) const turns = (await rig.rows()).filter((row) => row.body.kind === 'turn') expect(turns.map((row) => row.body)).toEqual([ - expect.objectContaining({ turnId: first, state: 'interrupted' }), - expect.objectContaining({ turnId: second, state: 'running' }) + expect.objectContaining({ turnId: first, state: 'interrupted' }) ]) }) }) diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-streams.test.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-streams.test.ts index d2094d9a4399..95b28378dfb3 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-streams.test.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-streams.test.ts @@ -7,49 +7,32 @@ import { openProviderTimelineRig, openUnboundProviderTimelineAssembler, providerItemId, - providerTurnItemId, - type ProviderTimelineRig + providerTurnItemId } from './provider-timeline-assembler-test-support' afterEach(closeProviderTimelineRigs) -/** A journal that already holds a settled `old` turn and a running `live` one. */ -async function oldAndLiveTurns(): Promise { - const rig = await openProviderTimelineRig() - rig.assembler.apply({ type: 'turn.open', turn: 'old', at: 1_000 }) - rig.assembler.apply({ type: 'turn.end', turn: 'old', at: 2_000, state: 'completed' }) - rig.assembler.apply({ type: 'turn.open', turn: 'live', at: 3_000 }) - await rig.rows() - return rig -} - -async function messages(rig: ProviderTimelineRig): Promise<(string | undefined)[]> { - return (await rig.rows()) - .filter((row) => row.body.kind === 'message') - .map((row) => messageText(row.body)) -} - -describe('a stream follows the row it resolved to, not the one planning expected', () => { - it('stops at the end of the turn its row landed in, though planning expected another', async () => { - const rig = await oldAndLiveTurns() +describe("a stream writes only while its row's turn runs", () => { + it('stops at the end of its turn, though its next delta was admitted before the end was written', async () => { + const rig = await openProviderTimelineRig() const { assembler, bind, drained } = openUnboundProviderTimelineAssembler(rig.journal) - // Planned before bind, so planning expects `old` to be open; it is a replay of a settled turn. - assembler.apply({ type: 'turn.open', turn: 'old', at: 1_000 }) + assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) assembler.apply({ type: 'text.delta', item: { id: 'm' }, channel: 'assistant', text: 'Hel' }) - await bind() assembler.flush() + assembler.apply({ type: 'turn.end', turn: 't1', at: 2_000, state: 'completed' }) + // Admitted before bind: only its write can see that the turn is over. + expect( + assembler.apply({ type: 'text.delta', item: { id: 'm' }, channel: 'assistant', text: 'lo' }) + .dropped + ).toBeUndefined() + assembler.flush() + await bind() await drained() + expect(await rig.turn('t1')).toMatchObject({ state: 'completed' }) expect((await rig.row(providerItemId('item', 'm')))?.turnScope).toEqual({ kind: 'turn', - turnItemId: providerTurnItemId('live') + turnItemId: providerTurnItemId('t1') }) - - assembler.apply({ type: 'turn.end', turn: 'live', at: 4_000, state: 'completed' }) - await drained() - expect(await rig.turn('live')).toMatchObject({ state: 'completed' }) - assembler.apply({ type: 'text.delta', item: { id: 'm' }, channel: 'assistant', text: 'lo' }) - assembler.flush() - await drained() expect(messageText((await rig.row(providerItemId('item', 'm')))?.body)).toBe('Hel') }) @@ -100,66 +83,6 @@ describe('a stream follows the row it resolved to, not the one planning expected }) }) -describe('a message boundary is the journal decision', () => { - it('keeps an anonymous message whole across a queued open the journal held as a replay', async () => { - const rig = await oldAndLiveTurns() - const { assembler, bind } = openUnboundProviderTimelineAssembler(rig.journal) - assembler.apply({ type: 'turn.open', turn: 'live', at: 3_000 }) - assembler.apply({ - type: 'text.delta', - item: { stream: 'reply' }, - channel: 'assistant', - text: 'Hel' - }) - assembler.apply({ type: 'turn.open', turn: 'old', at: 1_000 }) - assembler.apply({ - type: 'text.delta', - item: { stream: 'reply' }, - channel: 'assistant', - text: 'lo' - }) - assembler.apply({ type: 'text.close', item: { stream: 'reply' } }) - await bind() - expect(await messages(rig)).toEqual(['Hello']) - }) - - it('still splits an anonymous message at a turn the journal really opened', async () => { - const rig = await oldAndLiveTurns() - const { assembler, bind } = openUnboundProviderTimelineAssembler(rig.journal) - assembler.apply({ - type: 'text.delta', - item: { stream: 'reply' }, - channel: 'assistant', - text: 'One' - }) - assembler.apply({ type: 'turn.open', turn: 'next', at: 4_000 }) - assembler.apply({ - type: 'text.delta', - item: { stream: 'reply' }, - channel: 'assistant', - text: 'Two' - }) - assembler.apply({ type: 'text.close', item: { stream: 'reply' } }) - await bind() - expect(await messages(rig)).toEqual(['One', 'Two']) - }) - - it('resumes a named message after a queued open the journal held as a replay', async () => { - const rig = await oldAndLiveTurns() - const { assembler, bind } = openUnboundProviderTimelineAssembler(rig.journal) - assembler.apply({ type: 'text.delta', item: { id: 'm' }, channel: 'assistant', text: 'Hel' }) - assembler.apply({ type: 'turn.open', turn: 'old', at: 1_000 }) - assembler.apply({ type: 'text.delta', item: { id: 'm' }, channel: 'assistant', text: 'lo' }) - assembler.apply({ type: 'text.close', item: { id: 'm' } }) - await bind() - expect(await messages(rig)).toEqual(['Hello']) - expect((await rig.row(providerItemId('item', 'm')))?.turnScope).toEqual({ - kind: 'turn', - turnItemId: providerTurnItemId('live') - }) - }) -}) - describe('the open budget counts every provider string a stream keeps', () => { it('refuses a stream whose thread alone is past the budget', async () => { const rig = await openProviderTimelineRig({ schedule: () => () => {} }) diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-test-support.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-test-support.ts index c7e66edb98bb..15b5963db5ea 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-test-support.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-test-support.ts @@ -31,6 +31,7 @@ import { type ProviderTimelineAssembler, type ProviderTimelineAssemblerDeps } from './provider-timeline-assembler' +import { settleStaleStructuredAgentSessionState } from '../agent-session-wire/structured-agent-session-dead-generation-settlement' import { createLegacyProviderTimelineIdentityScheme, type ProviderTimelineItemFamily @@ -44,23 +45,25 @@ export const NAMESPACE = 'provider-session-1' const scheme = createLegacyProviderTimelineIdentityScheme({ agent: AGENT, sessionId: SESSION }) -/** The journal key the assembler gives a provider-keyed item. */ +/** The journal key the assembler gives a provider-keyed item, or a request of `generation`. */ export function providerItemId( - family: ProviderTimelineItemFamily, + family: ProviderTimelineItemFamily | 'request', key: string, - options: { namespace?: string; incarnation?: number } = {} + options: { namespace?: string; thread?: string; generation?: string; incarnation?: number } = {} ): string { return agentJournalItemKey( - scheme.item({ - namespace: options.namespace ?? NAMESPACE, - family, - key: { source: 'provider', value: key }, - thread: null, - turn: null, - itemClass: 'message', - messageOrdinal: null, - incarnation: options.incarnation ?? 1 - }) + family === 'request' + ? scheme.request({ + generation: options.generation ?? GENERATION, + key, + incarnation: options.incarnation ?? 1 + }) + : scheme.item({ + namespace: options.namespace ?? NAMESPACE, + family, + key: { source: 'provider', value: key }, + thread: options.thread ?? null + }) ) } @@ -139,7 +142,7 @@ export async function closeProviderTimelineRigs(): Promise { } /** An assembler whose sink binds to the rig's journal only when `bind` is called, as a lane that - * starts before its journal opens: it plans without the journal's view. */ + * starts before its journal opens (a resume): it admits events without the journal's view. */ export type UnboundProviderTimelineAssembler = { assembler: ProviderTimelineAssembler bind(): Promise @@ -187,8 +190,12 @@ export type ProviderTimelineRig = { sink: ProviderTimelineSink /** The same journal's event sink, for a lane that writes it directly. */ eventSink: StructuredAgentSessionEventSink - /** A fresh assembler on the same journal, as a restarted host builds. */ - restart(overrides?: Partial): ProviderTimelineAssembler + /** What a restarted host does: the dead-generation sweep settles what the old child left, then + * a new child gets a new assembler in a new generation. */ + restart(overrides?: Partial): Promise + /** Another assembler of the same generation on the same journal, for a test that needs its own + * sink; the rig's own assembler must then stay unused. */ + assemble(overrides?: Partial): ProviderTimelineAssembler rows(): Promise row(itemId: string): Promise /** The turn row of provider turn `turnKey`, or the row whose turn id is `turnKey`. */ @@ -258,12 +265,27 @@ export async function openProviderTimelineRig( const turn = readAgentJournalTurn(item.body) return turn ? [turn] : [] }) + let generations = 1 + const restart = async (more: Partial = {}) => { + generations += 1 + const generation = more.generation ?? `gen-${generations}` + await deferred.drained() + await settleStaleStructuredAgentSessionState({ + journal, + sessionId: SESSION, + fence: 1, + acquisitionGeneration: generation, + deathEvidence: null + }) + return build({ ...more, generation }) + } return { journal, assembler: build(), sink, eventSink: deferred.sink, - restart: build, + restart, + assemble: build, rows, row: async (itemId) => (await rows()).find((item) => item.itemId === itemId), turns, diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-turns.test.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-turns.test.ts index 69fa01b3f9d1..19a7750b089c 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-turns.test.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-turns.test.ts @@ -3,6 +3,7 @@ import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal import { closeProviderTimelineRigs, openProviderTimelineRig, + openUnboundProviderTimelineAssembler, providerTurnId, providerTurnItemId } from './provider-timeline-assembler-test-support' @@ -149,9 +150,9 @@ describe('provider timeline turns', () => { expect(rig.assembler.apply({ type: 'turn.end', at: 3_000, state: 'completed' }).dropped).toBe( 'no-turn' ) - // A replayed open of a settled turn neither reopens nor rewrites it. + // An open of a turn the journal holds settled neither reopens nor rewrites it. expect(rig.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 4_000 }).dropped).toBe( - 'turn-replayed' + 'turn-settled' ) expect(await rig.turn('turn-1')).toMatchObject({ state: 'completed', @@ -161,16 +162,18 @@ describe('provider timeline turns', () => { }) }) - it('never rewrites a settled turn when a restarted host replays its history', async () => { + it('writes nothing for an open admitted before bind whose row the journal already holds settled', async () => { const rig = await openProviderTimelineRig() rig.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_000 }) rig.assembler.apply({ type: 'turn.end', at: 2_000, state: 'completed', outcome: 'success' }) await rig.rows() - // A new host process has no memory of the turn; only the journal knows it ended. - const restarted = rig.restart({ generation: 'gen-2' }) - restarted.apply({ type: 'turn.open', turn: 'turn-1', at: 5_000 }) - restarted.apply({ type: 'turn.end', at: 6_000, state: 'interrupted', outcome: 'cancellation' }) + // Admitted without the journal's view, so only its write can see the row is there. + const { assembler, bind } = openUnboundProviderTimelineAssembler(rig.journal) + expect( + assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 5_000 }).dropped + ).toBeUndefined() + await bind() expect(await rig.turn('turn-1')).toEqual({ turnId: providerTurnId('turn-1'), state: 'completed', @@ -179,5 +182,8 @@ describe('provider timeline turns', () => { startedAt: 1_000, completedAt: 2_000 }) + // The journal's settlement reaches the state at the next event. + assembler.apply({ type: 'activity', text: 'reading' }) + expect(assembler.openTurnId).toBeNull() }) }) diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler.ts index af1918c24b1c..581c9703be00 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler.ts @@ -4,56 +4,47 @@ // Division of lifecycle work: // - The adapter translates its dialect, decides when a turn opens, and re-applies an event the sink // refused (the lane runner holds it and pauses reading under backpressure). -// - The assembler plans each event as ONE sink transition. Which row each write lands on, whether -// a replay writes at all, and every change to what the assembler knows (its ledger) are decided -// in the transition's resolvers, at the event's turn in the journal's write queue, against the -// journal as it stands then. So there is one clock: a refused event changed nothing, a write the -// journal rejects changes nothing, and a restart or an evicted cache finds the same rows again. -// - Planning reads a forecast — the ledger plus what admitted events still in the queue will -// change — only to answer `apply` at once: dropped or not, over budget or not, the open turn. -// - The journal keeps what it owns: a person's Stop, a dead generation after a restart, and the -// answer compare-and-set. +// - The assembler admits each event as ONE sink transition. What it knows (`ProviderTimelineState`) +// changes only when the sink admits the event, in admission order, which is the order its own +// rows are written in. Each write decides, when it runs, from what the journal holds then: a +// resume admits events before the sink binds, and the dead-generation sweep lands in between. +// - The journal keeps what other writers own: a person's Stop, a client's answer, the sweep. The +// assembler reads those by key and never copies them. +// - One assembler lives exactly as long as one provider child, so nothing it knows ever has to be +// recovered: a new child is a new assembler, in a new acquisition generation. import type { AgentType } from '../../../shared/agent-session-journal-types' import type { AgentSessionDeltaCoalescerDeps } from '../agent-session-wire/agent-session-delta-coalescer' import type { StructuredAgentSessionSinkAdmission } from '../agent-session-wire/structured-agent-session-event-sink' -import type { StructuredAgentSessionTransitionJournal } from '../agent-session-wire/structured-agent-session-transition' -import type { - ProviderTimelineApplyHost, - ProviderTimelineApplyResult, - ProviderTimelineInFlight -} from './provider-timeline-apply-host' import { PROVIDER_TIMELINE_OVER_BUDGET, providerTimelineBudgetAdmits, type ProviderTimelineHold } from './provider-timeline-budget' -import { providerTimelineLedger, type ProviderTimelineContext } from './provider-timeline-context' +import type { ProviderTimelineContext } from './provider-timeline-context' import { decideProviderTimelineEvent, - type ProviderTimelineDecidedEvent, - type ProviderTimelineDecision -} from './provider-timeline-decisions' + type ProviderTimelineDecidedEvent +} from './provider-timeline-decision' import type { ProviderTimelineEvent } from './provider-timeline-event' -import { - createLegacyProviderTimelineIdentityScheme, - type ProviderTimelineIdentityScheme -} from './provider-timeline-identity' -import { ProviderTimelineJoins } from './provider-timeline-joins' +import { createLegacyProviderTimelineIdentityScheme } from './provider-timeline-identity' import { ProviderTimelinePlan, type ProviderTimelineSink } from './provider-timeline-plan' +import { ProviderTimelineRows } from './provider-timeline-rows' import { ProviderTimelineState } from './provider-timeline-state' import { applyProviderTimelineTextClose, - applyProviderTimelineTextDelta + applyProviderTimelineTextDelta, + type ProviderTimelineApplyResult, + type ProviderTimelineTextHost } from './provider-timeline-text-events' import { ProviderTimelineTextStreams } from './provider-timeline-text-streams' import { planProviderTimelineBarrier, - planProviderTimelineSlots + planProviderTimelineWrites } from './provider-timeline-transition-layout' -export type { ProviderTimelineApplyResult } from './provider-timeline-apply-host' -export type { ProviderTimelineDropRule } from './provider-timeline-decisions' +export type { ProviderTimelineDropRule } from './provider-timeline-decision' +export type { ProviderTimelineApplyResult } from './provider-timeline-text-events' export type ProviderTimelineAssembler = { apply(event: ProviderTimelineEvent): ProviderTimelineApplyResult @@ -68,15 +59,12 @@ export type ProviderTimelineAssemblerDeps = { sink: ProviderTimelineSink sessionId: string agent: AgentType - /** The acquisition: minted keys are unique per generation. */ + /** The acquisition: minted keys and request ids are unique per generation. */ generation: string - /** The provider session whose ids the adapter forwards; the same one again after a restart that - * re-attaches it, so its replays are recognised. */ + /** The provider session whose ids the adapter forwards. */ namespace: string /** The session's own provider thread, for providers that run subagents on threads of their own. */ ownThread?: () => string | null - /** Defaults to the shared `legacy` identity arm. */ - scheme?: ProviderTimelineIdentityScheme coalesceMs?: number schedule?: AgentSessionDeltaCoalescerDeps['schedule'] } @@ -86,174 +74,96 @@ const ADMITTED: StructuredAgentSessionSinkAdmission = { accepted: true } export function createProviderTimelineAssembler( deps: ProviderTimelineAssemblerDeps ): ProviderTimelineAssembler { - const scheme = - deps.scheme ?? - createLegacyProviderTimelineIdentityScheme({ agent: deps.agent, sessionId: deps.sessionId }) - let settlements = 0 const context: ProviderTimelineContext = { sessionId: deps.sessionId, agent: deps.agent, - joins: new ProviderTimelineJoins({ - scheme, + generation: deps.generation, + rows: new ProviderTimelineRows({ + scheme: createLegacyProviderTimelineIdentityScheme({ + agent: deps.agent, + sessionId: deps.sessionId + }), generation: deps.generation, namespace: deps.namespace }), - ledger: new ProviderTimelineState(deps.namespace), - ...(deps.ownThread ? { ownThread: deps.ownThread } : {}), - settlementId: (what) => { - settlements += 1 - return `provider-timeline:${deps.sessionId}:${deps.generation}:${settlements}:${what}` - } + ...(deps.ownThread ? { ownThread: deps.ownThread } : {}) } + const state = new ProviderTimelineState() const streams = new ProviderTimelineTextStreams({ sink: deps.sink, context, - generation: deps.generation, ...(deps.coalesceMs === undefined ? {} : { coalesceMs: deps.coalesceMs }), ...(deps.schedule ? { schedule: deps.schedule } : {}) }) - let forecast = context.ledger.clone() - const inFlight: ProviderTimelineInFlight[] = [] - - /** The forecast again: the ledger, plus what admitted events not yet run will change. */ - const resync = () => { - forecast = context.ledger.clone() - for (const entry of inFlight) { - if (!entry.executed) { - entry.commit?.(forecast) - } - } - } - - const submit = ( - plan: ProviderTimelinePlan, - entry: ProviderTimelineInFlight - ): StructuredAgentSessionSinkAdmission => { - if (!plan.writes) { - return plan.submit(deps.sink) - } - inFlight.push(entry) - const settle = () => { - const at = inFlight.indexOf(entry) - if (at !== -1) { - inFlight.splice(at, 1) - resync() - } - } - const admission = plan.submit(deps.sink, settle) - if (!admission.accepted) { - inFlight.splice(inFlight.indexOf(entry), 1) - return admission - } - entry.commit?.(forecast) - return admission - } - const admits = (hold: ProviderTimelineHold): boolean => - providerTimelineBudgetAdmits({ - hold, - forecast, - ledger: context.ledger, - streams: streams.open, - journal: deps.sink.journalItems() - }) - - const host: ProviderTimelineApplyHost = { - context, + const textHost = (journal: ProviderTimelineTextHost['journal']): ProviderTimelineTextHost => ({ + sink: deps.sink, + state, streams, - forecast: () => forecast, - journal: () => deps.sink.journalItems(), - admits, - submit - } + journal, + admits: (hold) => admits(hold, journal) + }) - const applyDecided = (event: ProviderTimelineDecidedEvent): ProviderTimelineApplyResult => { - const journal = deps.sink.journalItems() + const admits = (hold: ProviderTimelineHold, journal: ProviderTimelineTextHost['journal']) => + providerTimelineBudgetAdmits({ hold, state, streams: streams.open, journal }) + + const applyDecided = ( + event: ProviderTimelineDecidedEvent, + journal: ProviderTimelineTextHost['journal'] + ): ProviderTimelineApplyResult => { + const serials = state.serials() const decision = decideProviderTimelineEvent( - { context, state: forecast, journal, execute: false }, + { context, state, journal, serial: serials.next }, event ) if (decision.dropped) { return { admission: ADMITTED, dropped: decision.dropped } } - if (decision.hold && !admits(decision.hold)) { + if (decision.hold && !admits(decision.hold, journal)) { return { admission: PROVIDER_TIMELINE_OVER_BUDGET } } const plan = new ProviderTimelinePlan() - const entry: ProviderTimelineInFlight = { executed: false, commit: decision.commit } - let executed: ProviderTimelineDecision | null = null - // A message boundary is the event's only when its own decision, at execution, wrote. - const landed = () => executed !== null && !executed.dropped && executed.write !== null - planProviderTimelineBarrier(host, plan, event, decision, landed) - // The event's decision on the ledger, taken once, by its first slot to run. - const decide = (at: StructuredAgentSessionTransitionJournal) => { - if (!executed) { - const ledger = providerTimelineLedger(context, at) - executed = decideProviderTimelineEvent( - { context, state: ledger, journal: at, execute: true }, - event - ) - entry.executed = true - executed.commit?.(ledger) - } - return executed.dropped ? null : executed - } - planProviderTimelineSlots(host, plan, event, decide) - if ( - event.type === 'turn.open' || - event.type === 'turn.end' || - event.type === 'session.ended' || - event.type === 'session.reset' - ) { + planProviderTimelineBarrier({ streams, state, context }, plan, event, decision) + planProviderTimelineWrites(context, plan, decision, serials.next) + plan.onAdmitted(() => { + decision.commit?.(state) + serials.commit() + }) + if (event.type === 'turn.open' || event.type === 'turn.end' || event.type === 'session.ended') { plan.onAdmitted(() => deps.sink.setActivity?.(null)) } - return { admission: submit(plan, entry) } + return { admission: plan.submit(deps.sink) } } const apply = (event: ProviderTimelineEvent): ProviderTimelineApplyResult => { const journal = deps.sink.journalItems() if (journal) { - forecast.reconcile(journal) + state.reconcile(journal) } switch (event.type) { case 'text.delta': - return applyProviderTimelineTextDelta(host, event) + return applyProviderTimelineTextDelta(textHost(journal), event) case 'text.close': - return applyProviderTimelineTextClose(host, event) + return applyProviderTimelineTextClose(textHost(journal), event) case 'activity': { - const open = forecast.open - if (forecast.ended || !open) { - return { admission: ADMITTED, dropped: forecast.ended ? 'session-ended' : 'no-turn' } + const open = state.open + if (state.ended || !open) { + return { admission: ADMITTED, dropped: state.ended ? 'session-ended' : 'no-turn' } } deps.sink.setActivity?.( event.text === null ? null : { turnId: open.turnId, text: event.text } ) return { admission: ADMITTED } } - case 'turn.open': - return applyDecided( - event.turn === undefined ? { ...event, minted: context.joins.mint('t') } : event - ) - case 'provider.frame': - return applyDecided({ ...event, minted: context.joins.mint('f') }) - case 'input.accepted': - case 'turn.end': - case 'item.open': - case 'item.update': - case 'item.close': - case 'request.open': - case 'request.withdrawn': - case 'context.usage': - case 'session.ended': - case 'session.reset': - return applyDecided(event) + default: + return applyDecided(event, journal) } } return { apply, get openTurnId() { - return forecast.open?.turnId ?? null + return state.open?.turnId ?? null }, flush: () => streams.flush(), dispose: () => streams.dispose() diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-budget.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-budget.ts index 864a193818fa..a097bf689d69 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-budget.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-budget.ts @@ -1,12 +1,11 @@ // One admission budget for everything the assembler holds open: running items, pending requests -// and open text streams. It is derived, not booked: the open set is the forecast's obligations -// plus the open streams, one entry per item (an item and its stream share its join reference) and -// one per request key whatever its incarnation. Before refusing, the set is re-derived from the -// journal, so an answer a client gave or a row another writer settled frees its room. Past the -// budget an event is refused as `failed`, which ends the session the way a failed journal write -// does, and the journal-derived dead-generation settlement closes whatever it left open. +// and open text streams. The open set is the state's open items plus the open streams, one entry +// per item (an item and its stream share its row id) and one per request key whatever its +// incarnation. Before refusing, each entry is checked against the journal by key, so an answer a +// client gave or a row another writer settled frees its room. Past the budget an event is refused +// as `failed`, which ends the session the way a failed journal write does, and the journal-derived +// dead-generation settlement closes whatever it left open. -import { requiresTerminalSettlement } from '../agent-session-journal/journal-terminal-settlement' import type { StructuredAgentSessionSinkAdmission } from '../agent-session-wire/structured-agent-session-event-sink' import type { StructuredAgentSessionTransitionJournal } from '../agent-session-wire/structured-agent-session-transition' import type { ProviderTimelineState } from './provider-timeline-state' @@ -23,21 +22,21 @@ export type ProviderTimelineHold = { key: string; bytes: number } export function providerTimelineBudgetAdmits(input: { hold: ProviderTimelineHold - forecast: ProviderTimelineState - ledger: ProviderTimelineState + state: ProviderTimelineState streams: readonly ProviderTimelineHold[] journal: StructuredAgentSessionTransitionJournal | null }): boolean { if (fits(input)) { return true } - // The fold is never behind the ledger, so what it shows settled is settled in both. - for (const state of [input.forecast, input.ledger]) { - for (const [key, obligation] of state.obligations) { - const body = obligation.itemId ? input.journal?.itemBody(obligation.itemId) : null - if (body && !requiresTerminalSettlement(body)) { - state.obligations.delete(key) - } + const { journal, state } = input + if (!journal) { + return false + } + // Another writer's fact, read by key: what it settled is settled. + for (const key of state.items.keys()) { + if (state.settledInJournal(key, journal)) { + state.items.delete(key) } } return fits(input) @@ -45,12 +44,14 @@ export function providerTimelineBudgetAdmits(input: { function fits(input: { hold: ProviderTimelineHold - forecast: ProviderTimelineState + state: ProviderTimelineState streams: readonly ProviderTimelineHold[] }): boolean { const open = new Map() - for (const [key, obligation] of input.forecast.obligations) { - open.set(key, obligation.bytes) + for (const [key, entry] of input.state.items) { + if (!entry.closed) { + open.set(key, entry.bytes) + } } for (const stream of input.streams) { open.set(stream.key, Math.max(open.get(stream.key) ?? 0, stream.bytes)) diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-context.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-context.ts index 16cdee07da9e..e65c134ad888 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-context.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-context.ts @@ -1,34 +1,30 @@ -// What every planner and resolver of one assembler shares, and where a joined row goes. +// What every decision of one assembler shares, and where a joined row goes. import type { AgentJournalProducerLinkage, + AgentJournalTurnScope, AgentType } from '../../../shared/agent-session-journal-types' -import type { StructuredAgentSessionTransitionJournal } from '../agent-session-wire/structured-agent-session-transition' import type { ProviderTimelineJoin } from './provider-timeline-event' -import type { ProviderTimelineJoins, ProviderTimelinePlacement } from './provider-timeline-joins' +import { providerKey, type ProviderTimelineRows } from './provider-timeline-rows' import type { ProviderTimelineState } from './provider-timeline-state' export type ProviderTimelineContext = { sessionId: string agent: AgentType - joins: ProviderTimelineJoins - /** The truth decisions run on; resolvers only. */ - ledger: ProviderTimelineState + generation: string + rows: ProviderTimelineRows /** The session's own provider thread; a join naming another thread is subagent work. */ ownThread?: () => string | null - /** A settlement id no other settlement of this journal shares. */ - settlementId(what: string): string } -/** The ledger, with its open turn taken from the journal before its first decision runs, and - * ended when another writer of the journal ended it. */ -export function providerTimelineLedger( +/** A settlement id no other settlement of this journal shares. */ +export function providerTimelineSettlementId( context: ProviderTimelineContext, - journal: StructuredAgentSessionTransitionJournal -): ProviderTimelineState { - context.ledger.reconcile(journal) - return context.ledger + serial: number, + what: string +): string { + return `provider-timeline:${context.sessionId}:${context.generation}:${serial}:${what}` } /** The turn a new row joins: the one the provider names (subagent work joins the open turn while @@ -37,21 +33,16 @@ export function providerTimelinePlacement( context: ProviderTimelineContext, state: ProviderTimelineState, join: ProviderTimelineJoin | undefined -): ProviderTimelinePlacement { +): AgentJournalTurnScope { const thread = join?.thread ?? null if (join?.turn === undefined) { - return { thread, turn: state.open?.address.key ?? null, scope: state.scope } + return state.scope } - const key = { source: 'provider', value: join.turn } as const const own = context.ownThread?.() ?? null - const subagent = thread !== null && own !== null && thread !== own - return { - thread, - turn: key, - scope: subagent - ? state.scope - : { kind: 'turn', turnItemId: context.joins.turn(key, state.namespace).itemId } + if (thread !== null && own !== null && thread !== own) { + return state.scope } + return { kind: 'turn', turnItemId: context.rows.turn(providerKey(join.turn)).itemId } } /** What an entry the assembler holds open costs: its body, its producer, and every provider diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-decision.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-decision.ts index ee13faf44a36..bd1274934676 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-decision.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-decision.ts @@ -1,4 +1,10 @@ -// The shape of a decision on one non-text event, and the reads every rule shares. +// What a non-text event does, in two parts that never share a clock. +// +// Admission decides from the state (and the journal's rows by key, when bound) whether the event +// is dropped, what it holds open, and how it changes the state; it writes and allocates nothing. +// Each write's resolver decides, when the write runs, what the journal holds then: a resume admits +// events before the sink binds, and the dead-generation sweep lands between admission and the +// write, so every journal-dependent choice is made there. import type { AgentJournalItemBody, @@ -10,92 +16,107 @@ import type { StructuredAgentSessionTransitionJournal } from '../agent-session-w import type { ProviderTimelineHold } from './provider-timeline-budget' import type { ProviderTimelineContext } from './provider-timeline-context' import type { ProviderTimelineEvent } from './provider-timeline-event' -import type { ProviderTimelineKey } from './provider-timeline-identity' -import type { ProviderTimelineTurnRef } from './provider-timeline-joins' import { - providerTimelineSettlement, - type ProviderTimelineTurnEnd -} from './provider-timeline-settlement' + decideFrame, + decideItem, + decideRequest, + decideWithdrawal +} from './provider-timeline-item-decisions' import type { ProviderTimelineState } from './provider-timeline-state' +import { + decideContextUsage, + decideHistory, + decideInput, + decideSessionEnd, + decideTurnEnd, + decideTurnOpen +} from './provider-timeline-turn-decisions' -/** Why an event wrote nothing. Each is a grammar rule the adapter broke or a replay it repeated. */ +/** Why an event wrote nothing. Each is a grammar rule the adapter broke or a fact already held. */ export type ProviderTimelineDropRule = | 'session-ended' | 'turn-duplicate' - | 'turn-replayed' + | 'turn-settled' | 'turn-unknown' | 'no-turn' | 'item-settled' - | 'item-replayed' | 'request-duplicate' - | 'request-replayed' | 'request-unknown' | 'stream-unknown' | 'stream-mismatch' -/** A non-text event with the keys planning minted for it, so execution names the same ones. */ - -/** A non-text event with the keys planning minted for it, so execution names the same ones. */ export type ProviderTimelineDecidedEvent = Exclude< ProviderTimelineEvent, { type: 'text.delta' | 'text.close' | 'activity' } -> & { minted?: ProviderTimelineKey } +> + +type Journal = StructuredAgentSessionTransitionJournal export type ProviderTimelineResolvedWrite = { identity: AgentJournalItemIdentity body: AgentJournalItemBody - options?: StructuredAgentSessionItemAppendOptions +} + +/** One row write: where it goes is fixed at admission, what it says is resolved when it runs. */ +export type ProviderTimelineItemWrite = { + reservedBytes: number + lifecycle: boolean + /** A new row's turn is admission's placement; an existing row keeps its own. */ + options: StructuredAgentSessionItemAppendOptions + resolve: (journal: Journal) => ProviderTimelineResolvedWrite | null } export type ProviderTimelineDecision = { dropped?: ProviderTimelineDropRule - /** What the event would hold open, for the budget. */ + /** What the event holds open, for the budget. */ hold?: ProviderTimelineHold - /** The event's change to what is known: the forecast's at admission, the ledger's at execution. */ + /** The event's change to the state, made when the sink admits it. */ commit?: (state: ProviderTimelineState) => void - /** Execution only: the settlement step's mutations and the item step's write. */ - settle?: readonly JournalLifecycleMutationInput[] - write?: ProviderTimelineResolvedWrite | null - /** The item the event closes, whose stream it releases. */ + /** The settlement the event owes, read from the journal when it runs. */ + settle?: { what: string; resolve: (journal: Journal) => readonly JournalLifecycleMutationInput[] } + writes?: readonly ProviderTimelineItemWrite[] + /** The streamed item whose text this event's full snapshot replaces. */ closes?: string } export type ProviderTimelineDecisionInput = { context: ProviderTimelineContext state: ProviderTimelineState - journal: StructuredAgentSessionTransitionJournal | null - /** Execution: place rows and compute writes. Planning never does. */ - execute: boolean -} - -export const providerKey = (value: string): ProviderTimelineKey => ({ source: 'provider', value }) - -export function settledTool(body: AgentJournalItemBody | null): boolean { - return body?.kind === 'tool-call' && body.state !== 'running' + /** The journal at admission; null before the sink binds. */ + journal: Journal | null + /** Serials this event takes, committed with it. */ + serial: () => number } -export function runningTool(body: AgentJournalItemBody): boolean { - return body.kind === 'tool-call' && body.state === 'running' -} - -export function pendingPrompt(body: AgentJournalItemBody | null): boolean { - return ( - (body?.kind === 'approval' || body?.kind === 'question') && body.resolution.state === 'pending' - ) -} - -export function turnOf(scope: { kind: string; turnItemId?: string }): string | null { - return scope.kind === 'turn' ? (scope.turnItemId ?? null) : null -} - -/** A turn's journal-derived settlement, at execution. */ -export function settlementOf( +export function decideProviderTimelineEvent( input: ProviderTimelineDecisionInput, - turn: ProviderTimelineTurnRef, - end: ProviderTimelineTurnEnd -): readonly JournalLifecycleMutationInput[] | undefined { - if (!input.execute || !input.journal) { - return undefined + event: ProviderTimelineDecidedEvent +): ProviderTimelineDecision { + if (input.state.ended) { + return { dropped: 'session-ended' } + } + switch (event.type) { + case 'input.accepted': + return decideInput(input, event) + case 'input.history': + return decideHistory(input, event) + case 'turn.open': + return decideTurnOpen(input, event) + case 'turn.end': + return decideTurnEnd(input, event) + case 'item.open': + case 'item.update': + case 'item.close': + return decideItem(input, event) + case 'request.open': + return decideRequest(input, event) + case 'request.withdrawn': + return decideWithdrawal(input, event) + case 'context.usage': + return decideContextUsage(input, event) + case 'provider.frame': + return decideFrame(input, event) + case 'session.ended': + return decideSessionEnd(input, event) } - return providerTimelineSettlement(input.journal, { turnItemId: turn.itemId }, [turn], end) } diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-decisions.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-decisions.ts deleted file mode 100644 index 7f03e7267c00..000000000000 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-decisions.ts +++ /dev/null @@ -1,76 +0,0 @@ -// What each non-text event does: one rule set, run twice. -// -// Planning runs it on the forecast and the journal as it stands, to answer `apply` (dropped or -// not, and what it would hold open) without writing or allocating anything. Execution runs it -// again in the event's first resolver, on the ledger and the fold at that point in the journal's -// write queue: only then are rows placed, ordinals and incarnations taken, and the ledger changed. -// So when the two disagree — before bind, after a restart, behind a replay the journal rejects — -// the journal's answer is the one that lands, and the ledger follows it. - -import { - decideFrame, - decideItem, - decideRequest, - decideWithdrawal -} from './provider-timeline-item-decisions' -import type { - ProviderTimelineDecidedEvent, - ProviderTimelineDecision, - ProviderTimelineDecisionInput -} from './provider-timeline-decision' -import { - decideContextUsage, - decideInput, - decideSessionEnd, - decideTurnEnd, - decideTurnOpen -} from './provider-timeline-turn-decisions' - -export type { - ProviderTimelineDecidedEvent, - ProviderTimelineDecision, - ProviderTimelineDropRule -} from './provider-timeline-decision' - -export function decideProviderTimelineEvent( - input: ProviderTimelineDecisionInput, - event: ProviderTimelineDecidedEvent -): ProviderTimelineDecision { - if (input.state.ended && event.type !== 'session.reset') { - return { dropped: 'session-ended' } - } - switch (event.type) { - case 'input.accepted': - return decideInput(input, event) - case 'turn.open': - return decideTurnOpen(input, event) - case 'turn.end': - return decideTurnEnd(input, event) - case 'item.open': - case 'item.update': - case 'item.close': - return decideItem(input, event) - case 'request.open': - return decideRequest(input, event) - case 'request.withdrawn': - return decideWithdrawal(input, event) - case 'context.usage': - return decideContextUsage(input, event) - case 'provider.frame': - return decideFrame(input, event) - case 'session.ended': - return decideSessionEnd(input, event.verdict, (state) => { - state.ended = true - state.endSession() - }) - case 'session.reset': - // Nothing ended the old session's open work, so it is lost, not interrupted; the resolver - // finds nothing when an end already settled it. - return decideSessionEnd(input, { state: 'unverifiable' }, (state) => { - state.reset(event.namespace) - if (input.execute) { - input.context.joins.reset(event.namespace) - } - }) - } -} diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-event.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-event.ts index b3f01ff9f7e4..a745709210cd 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-event.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-event.ts @@ -4,30 +4,34 @@ // provider traffic into these semantic events and never writes a journal row or mints a journal // identity: every key here is the provider's own id (a turn, a tool call, a message, a thread), // or a name the adapter keeps stable for something the provider does not name. The assembler -// resolves those joins to rows, scopes rows to turns, and owns the turn and item rules in -// `agent-session-journal-types.ts`; the journal decides, at each write, what a replay changes. +// spells those keys as row ids, scopes rows to turns, and owns the turn and item rules in +// `agent-session-journal-types.ts`. // // Rules an adapter can rely on: +// - One assembler lives exactly as long as one provider child: a new process is a new assembler, +// with a new acquisition generation. // - Only `turn.open` opens a turn. Items, text and requests never do: one that arrives with no // turn open and names none is written as a thread row. An adapter whose provider starts work on // its own (a background wake, auto-compaction) decides that a turn began and says `turn.open` // before that work's items. // - `join.turn` attaches a row to the turn the provider says it belongs to, open or not. -// - A turn settles once, with only the provider's verdict; the journal refuses a second settlement -// and a replayed open, even after a restart. +// - A turn settles once, with only the provider's verdict. A row the journal already holds is +// never written back to an earlier state: a settled turn stays settled, a settled tool never +// runs again, an answered request stays answered. +// - Saved history the provider replays goes only into an empty journal (an adopted session), as +// ordinary events with the provider's own ids, so re-running an interrupted adoption writes the +// same rows again. // - An event the sink refused changed nothing. Re-apply the same event to retry it (after // `backpressure`); `failed` and `closed` are final. // - A provider item is (thread, id): the same id on another thread is another item. Text and // full snapshots of one id are one row, and its close settles it for both. -// - `dropped` is the verdict when the event was applied. The journal has the last word when the -// event's writes run: a replay the journal already holds writes nothing even if it was taken. -// - After `session.ended` every event is dropped until `session.reset`. Reset whenever the -// provider's ids may repeat (a new process): its ids then name new rows. +// - After `session.ended` every event is dropped. import type { AgentSessionContextUsage } from '../../../shared/agent-session-context-usage' import type { AgentJournalApprovalItem, AgentJournalItemBody, + AgentJournalMessageItem, AgentJournalProducerLinkage, AgentJournalQuestionItem, AgentJournalTurnOutcome @@ -66,13 +70,20 @@ type Joined = { join?: ProviderTimelineJoin } export type ProviderTimelineEvent = /** Orca's send reached the provider. It names the open turn when nothing opened that one, else - * the next. `join.item`: the provider's id for its echo of the send, which takes that message's - * place among its turn's messages and writes no row (the send's own row is the bubble). */ + * the next; `join.turn` names the turn it opens. The send's own row is the bubble. */ | { type: 'input.accepted' clientMessageId: string requestedAt: number - join?: ProviderTimelineJoin & { item?: string } + join?: ProviderTimelineJoin + } + /** A user message from the provider's saved history (an adopted session has no Orca send): its + * own row, keyed by `item`, and the message that opened `join.turn`. */ + | { + type: 'input.history' + item: string + body: AgentJournalMessageItem + join: ProviderTimelineJoin & { turn: string } } /** A turn began. `turn` is the provider's turn id when it has one; the assembler mints one otherwise. */ | { type: 'turn.open'; turn?: string; at: number } @@ -120,6 +131,3 @@ export type ProviderTimelineEvent = | ({ type: 'provider.frame'; frameKind: string; payload: unknown } & Joined) /** The provider child is gone. The verdict is what the host can prove about its end. */ | { type: 'session.ended'; verdict: StructuredAgentSessionTurnVerdict } - /** A new provider session began; its ids live in `namespace`. Work still open is settled first, - * as lost (`unverifiable`), since nothing ended it. */ - | { type: 'session.reset'; namespace: string } diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-identity.test.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-identity.test.ts new file mode 100644 index 000000000000..6242af3998ff --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-identity.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest' +import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import { + createLegacyProviderTimelineIdentityScheme, + providerTimelineKeyPart, + spellProviderTimelineKey +} from './provider-timeline-identity' + +const scheme = createLegacyProviderTimelineIdentityScheme({ agent: 'grok', sessionId: 's1' }) +const provider = (value: string) => ({ source: 'provider', value }) as const + +describe('provider timeline identity spelling', () => { + it('escapes a key so it cannot forge another part', () => { + expect(providerTimelineKeyPart('a:b/c')).toBe('a%3Ab%2Fc') + expect(spellProviderTimelineKey('ns', provider('x:y'))).not.toBe( + spellProviderTimelineKey('ns:x', provider('y')) + ) + }) + + it('bounds a long key by a digest, so two long keys with one prefix stay apart', () => { + const head = 'k'.repeat(400) + const one = providerTimelineKeyPart(`${head}1`) + const two = providerTimelineKeyPart(`${head}2`) + expect(Buffer.byteLength(one, 'utf8')).toBeLessThanOrEqual(256) + expect(one).not.toBe(two) + }) + + it('spells an item per thread, and a minted key apart from any provider one', () => { + const item = (thread: string | null) => + agentJournalItemKey( + scheme.item({ namespace: 'ns', family: 'item', key: provider('m1'), thread }) + ) + expect(new Set([item(null), item('root'), item('child')]).size).toBe(3) + expect(spellProviderTimelineKey('ns', { source: 'minted', value: 'gen-1:s1' })).toBe( + 'm:gen-1%3As1' + ) + }) + + it('spells a request in the acquisition that asked it, with its incarnation', () => { + const request = (generation: string, incarnation: number) => + scheme.request({ generation, key: '0', incarnation }) + expect(request('gen-1', 1)).toMatchObject({ provider: 'legacy', recordId: 'request:g:gen-1:0' }) + expect(request('gen-1', 2)).toMatchObject({ recordId: 'request:g:gen-1:0#2' }) + expect(agentJournalItemKey(request('gen-2', 1))).not.toBe( + agentJournalItemKey(request('gen-1', 1)) + ) + }) + + it('keeps turn rows on the record prefix every lane writes', () => { + const turn = { namespace: 'ns', key: provider('t1') } + expect(scheme.turn(turn)).toMatchObject({ recordId: 'turn-lifecycle:p:ns:t1' }) + expect(scheme.turnId(turn)).toBe('p:ns:t1') + }) +}) diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-identity.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-identity.ts new file mode 100644 index 000000000000..6dd47f03ec53 --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-identity.ts @@ -0,0 +1,106 @@ +// How the assembler's keys become persisted journal identities. +// +// The assembler decides WHICH rows are the same row; a scheme only spells them. It must be pure, +// and it is the one place a provider's persisted identity shape lives. Every provider on the +// assembler spells its rows in the existing `legacy` arm, so no row shape is new. + +import type { + AgentJournalItemIdentity, + AgentType +} from '../../../shared/agent-session-journal-types' +import { boundPayload, digestPayload } from '../agent-session-journal/journal-payload-bounds' + +/** A key the provider vouched for, or one the assembler minted because it named nothing. + * A minted value is unique per acquisition, so it never needs a namespace. */ +export type ProviderTimelineKey = { source: 'provider' | 'minted'; value: string } + +/** Streamed text and full item snapshots share `item`, so a provider-named message is one row + * however it arrives; fallback frames never share its key space. */ +export type ProviderTimelineItemFamily = 'item' | 'frame' + +export type ProviderTimelineTurnAddress = { + /** The provider session whose ids the key belongs to. */ + namespace: string + key: ProviderTimelineKey +} + +export type ProviderTimelineItemAddress = ProviderTimelineTurnAddress & { + family: ProviderTimelineItemFamily + /** The provider thread it came from; null when the provider named none. */ + thread: string | null +} + +/** A question the provider asked under its request id. JSON-RPC ids restart with each provider + * process, so a request is spelled in the acquisition that asked it. */ +export type ProviderTimelineRequestAddress = { + generation: string + key: string + /** 1 for the first request under the key in this acquisition; a reused key takes the next. */ + incarnation: number +} + +export type ProviderTimelineIdentityScheme = { + turn(address: ProviderTimelineTurnAddress): AgentJournalItemIdentity + /** The id the turn row carries and a client's Stop names. */ + turnId(address: ProviderTimelineTurnAddress): string + item(address: ProviderTimelineItemAddress): AgentJournalItemIdentity + request(address: ProviderTimelineRequestAddress): AgentJournalItemIdentity +} + +const MAX_KEY_PART_BYTES = 256 + +/** A provider id as a bounded identity part: escaped so `:` cannot forge another part, and + * digest-suffixed when long so two long ids never share a prefix-only spelling. */ +export function providerTimelineKeyPart(value: string): string { + const encoded = encodeURIComponent(value) + if (Buffer.byteLength(encoded, 'utf8') <= MAX_KEY_PART_BYTES) { + return encoded + } + const suffix = `#${digestPayload(value).slice(0, 24)}` + const head = boundPayload(encoded, { + inlineHeadBytes: MAX_KEY_PART_BYTES - Buffer.byteLength(suffix, 'utf8') + }).head + return `${head}${suffix}` +} + +/** A key as one bounded string inside its namespace (and thread, for a per-thread key). */ +export function spellProviderTimelineKey( + namespace: string, + key: ProviderTimelineKey, + thread: string | null = null +): string { + // A provider's item ids are its own per thread, so a subagent thread's ids are spelled apart. + const scope = thread === null ? '' : `${providerTimelineKeyPart(thread)}/` + return key.source === 'provider' + ? `p:${providerTimelineKeyPart(namespace)}:${scope}${providerTimelineKeyPart(key.value)}` + : `m:${providerTimelineKeyPart(key.value)}` +} + +/** The existing `legacy` arm. Turn rows keep the `turn-lifecycle:` record prefix the other lanes + * write; provider keys are spelled inside their namespace, and apart from minted ones. */ +export function createLegacyProviderTimelineIdentityScheme(input: { + agent: AgentType + sessionId: string +}): ProviderTimelineIdentityScheme { + const identity = (recordId: string): AgentJournalItemIdentity => ({ + provider: 'legacy', + agent: input.agent, + sessionId: input.sessionId, + recordId + }) + return { + turn: (address) => + identity(`turn-lifecycle:${spellProviderTimelineKey(address.namespace, address.key)}`), + turnId: (address) => spellProviderTimelineKey(address.namespace, address.key), + item: (address) => + identity( + `${address.family}:${spellProviderTimelineKey(address.namespace, address.key, address.thread)}` + ), + request: (address) => + identity( + `request:g:${providerTimelineKeyPart(address.generation)}:${providerTimelineKeyPart(address.key)}${ + address.incarnation > 1 ? `#${address.incarnation}` : '' + }` + ) + } +} diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-item-decisions.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-item-decisions.ts index d5eb0ffb62ca..e3d9ff8ec461 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-item-decisions.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-item-decisions.ts @@ -1,22 +1,67 @@ -// What item, request and frame events do, decided on the forecast and again on the ledger. +// What item, request and frame events do: admitted on the state, written against the journal. -import { requiresTerminalSettlement } from '../agent-session-journal/journal-terminal-settlement' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalItemBody +} from '../../../shared/agent-session-journal-types' import { cancelledJournalPromptBody } from '../agent-session-journal/journal-prompt-body-bounds' +import { requiresTerminalSettlement } from '../agent-session-journal/journal-terminal-settlement' +import { estimateStructuredAgentSessionItemBytes } from '../agent-session-wire/structured-agent-session-event-sink-estimate' +import type { StructuredAgentSessionTransitionJournal } from '../agent-session-wire/structured-agent-session-transition' import { unhandledProviderFrameJournalItem } from '../agent-session-wire/unhandled-provider-frame' import { relightsProviderTimelineBackgroundTask } from './provider-timeline-background-tasks' import { providerTimelineEntryBytes, providerTimelinePlacement } from './provider-timeline-context' +import type { + ProviderTimelineDecidedEvent, + ProviderTimelineDecision, + ProviderTimelineDecisionInput +} from './provider-timeline-decision' import { - pendingPrompt, providerKey, - runningTool, - settledTool, + providerTimelineTurnRowState, turnOf, - type ProviderTimelineDecidedEvent, - type ProviderTimelineDecision, - type ProviderTimelineDecisionInput -} from './provider-timeline-decision' -import { providerTimelineItemClass, type ProviderTimelineKey } from './provider-timeline-identity' -import type { ProviderTimelineItemJoin } from './provider-timeline-joins' + type ProviderTimelineRowId +} from './provider-timeline-rows' +import type { ProviderTimelineRequestBody } from './provider-timeline-event' +import type { ProviderTimelineOpenItem } from './provider-timeline-state' + +type Journal = StructuredAgentSessionTransitionJournal +type ItemChange = 'open' | 'update' | 'close' + +function settledTool(body: AgentJournalItemBody | null): boolean { + return body?.kind === 'tool-call' && body.state !== 'running' +} + +function pendingPrompt(body: AgentJournalItemBody | null): body is ProviderTimelineRequestBody { + return ( + (body?.kind === 'approval' || body?.kind === 'question') && body.resolution.state === 'pending' + ) +} + +/** Whether the row the journal holds refuses this write: a settled tool never runs again and keeps + * its first terminal body, a settled background task is never relit, and a turn that is over + * takes no work that waits on a settlement it already ran. */ +function refusesItemWrite( + journal: Journal, + row: ProviderTimelineRowId, + change: ItemChange, + body: AgentJournalItemBody, + placed: string | null +): boolean { + const held = journal.item(row.itemId) + const turn = held ? turnOf(held.turnScope ?? AGENT_JOURNAL_THREAD_SCOPE) : placed + if (settledTool(held?.body ?? null) && (change === 'close' || requiresTerminalSettlement(body))) { + return true + } + if (relightsProviderTimelineBackgroundTask(held?.body ?? null, body)) { + return true + } + return ( + requiresTerminalSettlement(body) && + turn !== null && + providerTimelineTurnRowState(journal, turn) === 'settled' + ) +} export function decideItem( input: ProviderTimelineDecisionInput, @@ -25,37 +70,19 @@ export function decideItem( const { state, journal, context } = input const change = event.type === 'item.open' ? 'open' : event.type === 'item.update' ? 'update' : 'close' - const join: ProviderTimelineItemJoin = { - family: 'item', - key: providerKey(event.item), - thread: event.join?.thread ?? null - } - const ref = context.joins.reference(join, state.namespace) - const found = context.joins.find(join, journal, state.namespace) - const held = found ? (journal?.itemBody(found.itemId) ?? null) : null - // This run closed it: an open reopens the same row, a second close is a repeat. - const closed = state.closed.has(ref) - if (change === 'open' && !closed && held) { - return { dropped: 'item-replayed' } - } - if (change === 'update' && runningTool(event.body) && (closed || settledTool(held))) { - return { dropped: 'item-settled' } - } - if (change === 'close' && (closed || settledTool(held))) { - return { dropped: 'item-settled' } - } - if (change !== 'open' && relightsProviderTimelineBackgroundTask(held, event.body)) { + const row = context.rows.item('item', providerKey(event.item), event.join?.thread ?? null) + const key = row.itemId + const closed = state.items.get(key)?.closed === true + const scope = providerTimelinePlacement(context, state, event.join) + const placed = turnOf(scope) + if ( + (closed && + (change === 'close' || (change === 'update' && requiresTerminalSettlement(event.body)))) || + (journal && refusesItemWrite(journal, row, change, event.body, placed)) + ) { return { dropped: 'item-settled' } } const obligation = change !== 'close' && requiresTerminalSettlement(event.body) - const placement = providerTimelinePlacement(context, state, event.join) - const row = - found ?? - (input.execute && journal - ? context.joins.place(join, providerTimelineItemClass(event.body), placement, journal) - : null) - const scope = row?.scope ?? placement.scope - const turnItemId = turnOf(scope) const bytes = providerTimelineEntryBytes({ key: event.item, join: event.join, @@ -63,94 +90,81 @@ export function decideItem( producer: event.producer }) return { - ...(obligation ? { hold: { key: ref, bytes } } : {}), - write: row && { - identity: row.identity, - body: event.body, - options: { - ...event.producer, - turnScope: row.scope, - ...(row.ref === undefined ? {} : { providerItemRef: row.ref }) + ...(obligation ? { hold: { key, bytes } } : {}), + writes: [ + { + reservedBytes: estimateStructuredAgentSessionItemBytes(row.identity, event.body), + lifecycle: change !== 'update', + options: { ...event.producer, turnScope: scope }, + resolve: (at) => + refusesItemWrite(at, row, change, event.body, placed) + ? null + : { identity: row.identity, body: event.body } } - }, - ...(change === 'close' ? { closes: ref } : {}), + ], + ...(change === 'close' ? { closes: key } : {}), commit: (next) => { - if (obligation) { - next.obligations.set(ref, { itemId: row?.itemId ?? null, turnItemId, bytes }) - } else { - next.obligations.delete(ref) - } if (change === 'close') { - next.closed.set(ref, turnItemId) - } else if (change === 'open') { - next.closed.delete(ref) + next.close(key, placed) + } else if (obligation) { + next.items.set(key, { kind: 'item', row, turnItemId: placed, bytes, closed: false }) + } else if (change === 'open' || next.items.get(key)?.closed !== true) { + next.items.delete(key) } } } } -export function requestRef(input: ProviderTimelineDecisionInput, key: ProviderTimelineKey): string { - return input.context.joins.reference( - { family: 'request', key, thread: null }, - input.state.namespace - ) -} +const requestKey = (request: string) => `request:${request}` export function decideRequest( input: ProviderTimelineDecisionInput, event: Extract ): ProviderTimelineDecision { const { state, journal, context } = input - const key = providerKey(event.request) - const ref = requestRef(input, key) - const current = context.joins.request(key, journal, state.namespace) - const held = current ? (journal?.itemBody(current.itemId) ?? null) : null - // Pending in the journal, or opened here and not landed yet: the same request again. - if (pendingPrompt(held) || (state.obligations.has(ref) && held === null)) { + const key = requestKey(event.request) + // Pending here, unless a client's answer already settled it in the journal. + if (state.items.has(key) && !(journal && state.settledInJournal(key, journal))) { return { dropped: 'request-duplicate' } } - // The turn this open lands in decides whether it repeats its predecessor: the same key again - // in a turn that is over is that turn's replay; in another, live turn it is a new request. - const placement = providerTimelinePlacement(context, state, event.join) - const target = turnOf(placement.scope) - const previous = current ? turnOf(current.scope) : null - if ( - held && - previous !== null && - state.status({ itemId: previous }, journal) === 'settled' && - (target === null || - target === previous || - state.status({ itemId: target }, journal) === 'settled') - ) { - return { dropped: 'request-replayed' } - } - const row = - input.execute && journal - ? context.joins.nextRequest(key, event.body.kind, placement, journal) - : null + const scope = providerTimelinePlacement(context, state, event.join) + const turn = turnOf(scope) const bytes = providerTimelineEntryBytes({ key: event.request, join: event.join, body: event.body, producer: event.producer }) + const opened: ProviderTimelineOpenItem = { + kind: 'request', + row: null, + turnItemId: turn, + bytes, + closed: false + } return { - hold: { key: ref, bytes }, - // A row already there is a client's answer or a replay; neither is overwritten. - write: - row && journal?.itemBody(row.itemId) === null - ? { - identity: row.identity, - body: event.body, - options: { ...event.producer, turnScope: row.scope, lifecycle: true } + hold: { key, bytes }, + writes: [ + { + reservedBytes: estimateStructuredAgentSessionItemBytes( + context.rows.widestRequest(event.request).identity, + event.body + ), + lifecycle: true, + options: { ...event.producer, turnScope: scope, lifecycle: true }, + resolve: (at) => { + // A reused id takes the next incarnation; a row already there is never overwritten. + const row = context.rows.nextRequest(event.request, at) + opened.row = row + // A turn another writer ended while the open was queued asks nothing more. + if (turn !== null && providerTimelineTurnRowState(at, turn) === 'settled') { + return null } - : null, - commit: (next) => - next.obligations.set(ref, { - itemId: row?.itemId ?? null, - turnItemId: turnOf(row?.scope ?? placement.scope), - bytes - }) + return { identity: row.identity, body: event.body } + } + } + ], + commit: (next) => next.items.set(key, opened) } } @@ -158,34 +172,34 @@ export function decideWithdrawal( input: ProviderTimelineDecisionInput, event: Extract ): ProviderTimelineDecision { - const { state, journal } = input - const key = providerKey(event.request) - const ref = requestRef(input, key) - // The latest incarnation the journal holds, however long ago this run (or another) opened it. - const current = input.context.joins.request(key, journal, state.namespace) - const held = current ? (journal?.itemBody(current.itemId) ?? null) : null - if (!pendingPrompt(held) && !(state.obligations.has(ref) && held === null)) { + const key = requestKey(event.request) + const opened = input.state.items.get(key) + if (opened?.kind !== 'request') { return { dropped: 'request-unknown' } } - // Only while the journal holds it pending: a client's answer that landed first stands. - const cancelled = current && held && pendingPrompt(held) ? cancelledJournalPromptBody(held) : null return { - ...(input.execute - ? { - settle: - current && cancelled - ? [ - { - kind: 'item' as const, - identity: current.identity, - body: cancelled, - turnScope: current.scope - } - ] - : [] + settle: { + what: 'request-withdrawn', + resolve: (journal) => { + // The open ran first and named its row. Only while it is pending: a client's answer that + // landed first stands. + const held = opened.row ? journal.item(opened.row.itemId) : null + const cancelled = + held && pendingPrompt(held.body) ? cancelledJournalPromptBody(held.body) : null + if (!opened.row || !held || !cancelled) { + return [] } - : {}), - commit: (next) => next.obligations.delete(ref) + return [ + { + kind: 'item', + identity: opened.row.identity, + body: cancelled, + turnScope: held.turnScope ?? AGENT_JOURNAL_THREAD_SCOPE + } + ] + } + }, + commit: (next) => next.items.delete(key) } } @@ -193,16 +207,25 @@ export function decideFrame( input: ProviderTimelineDecisionInput, event: Extract ): ProviderTimelineDecision { - const { state, journal, context } = input + const { state, context } = input const frame = unhandledProviderFrameJournalItem(context.agent, event.frameKind, event.payload) - if (!frame || !event.minted || !input.execute || !journal) { + if (!frame) { return {} } - const row = context.joins.place( - { family: 'frame', key: event.minted, thread: event.join?.thread ?? null }, + const row = context.rows.item( 'frame', - providerTimelinePlacement(context, state, event.join), - journal + context.rows.minted('f', input.serial()), + event.join?.thread ?? null ) - return { write: { identity: row.identity, body: frame.body, options: { turnScope: row.scope } } } + const write = { identity: row.identity, body: frame.body } + return { + writes: [ + { + reservedBytes: estimateStructuredAgentSessionItemBytes(row.identity, frame.body), + lifecycle: false, + options: { turnScope: providerTimelinePlacement(context, state, event.join) }, + resolve: () => write + } + ] + } } diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-plan.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-plan.ts index 44dec71dc576..f9e9e0e33e38 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-plan.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-plan.ts @@ -1,21 +1,16 @@ // One grammar event's journal writes, admitted as one sink transition. // -// Planning lays out the writes the event may need and changes nothing. What each write says, and -// which row it lands on, is decided by its resolver when the transition reaches its turn in the -// journal's write queue; those resolvers are the only code that changes the assembler's ledger. -// `onAdmitted` work (the forecast, text marked written) runs only when the sink takes the -// transition, so a refused event leaves the assembler exactly as it was and re-applying it is the -// retry. +// What each write says, and whether it writes at all, is decided by its resolver when the +// transition reaches its turn in the journal's write queue. `onAdmitted` work (the state, text +// marked written) runs only when the sink takes the transition, so a refused event leaves the +// assembler exactly as it was and re-applying it is the retry. import type { AgentSessionTurnActivity } from '../../../shared/agent-session-wire' import type { StructuredAgentSessionEventSink, StructuredAgentSessionSinkAdmission } from '../agent-session-wire/structured-agent-session-event-sink' -import type { - StructuredAgentSessionTransitionJournal, - StructuredAgentSessionTransitionStep -} from '../agent-session-wire/structured-agent-session-transition' +import type { StructuredAgentSessionTransitionStep } from '../agent-session-wire/structured-agent-session-transition' /** The sink calls the assembler needs: one-operation transitions, and the journal as it stands. */ export type ProviderTimelineSink = Required< @@ -31,9 +26,7 @@ const ADMITTED: StructuredAgentSessionSinkAdmission = { accepted: true } export class ProviderTimelinePlan { private readonly steps: StructuredAgentSessionTransitionStep[] = [] private readonly admitted: (() => void)[] = [] - private readonly executions: ((journal: StructuredAgentSessionTransitionJournal) => void)[] = [] private lifecycle = false - private refusal: StructuredAgentSessionSinkAdmission | null = null item(step: Omit, lifecycle = false): void { this.steps.push({ kind: 'item', ...step }) @@ -50,38 +43,12 @@ export class ProviderTimelinePlan { this.admitted.push(change) } - /** Ledger work that rides the transition's place in the queue, after its last step resolved. */ - atExecution(change: (journal: StructuredAgentSessionTransitionJournal) => void): void { - this.executions.push(change) - } - - /** The event cannot be taken at all (over budget); nothing is submitted. */ - refuse(admission: StructuredAgentSessionSinkAdmission): void { - this.refusal ??= admission - } - - /** Whether submitting sends a transition (and so `landed` will be heard). */ - get writes(): boolean { - return this.steps.length > 0 || this.executions.length > 0 - } - - submit(sink: ProviderTimelineSink, landed?: () => void): StructuredAgentSessionSinkAdmission { - if (this.refusal) { - return this.refusal - } - if (this.executions.length > 0 && this.steps.length === 0) { - this.item({ - reservedBytes: 0, - resolve: () => null, - options: { turnScope: { kind: 'thread' } } - }) - } + submit(sink: ProviderTimelineSink): StructuredAgentSessionSinkAdmission { if (this.steps.length > 0) { const admission = sink.tryAppendTransition({ - steps: this.withExecutions(), + steps: this.steps, lifecycle: this.lifecycle, - publish: true, - ...(landed ? { landed: () => landed() } : {}) + publish: true }) if (!admission.accepted) { return admission @@ -92,35 +59,6 @@ export class ProviderTimelinePlan { } return ADMITTED } - - private withExecutions(): StructuredAgentSessionTransitionStep[] { - const last = this.steps.at(-1) - if (!last || this.executions.length === 0) { - return this.steps - } - const executions = this.executions - const finish = (journal: StructuredAgentSessionTransitionJournal) => - executions.forEach((change) => change(journal)) - const wrapped: StructuredAgentSessionTransitionStep = - last.kind === 'item' - ? { - ...last, - resolve: (journal) => { - const resolved = last.resolve(journal) - finish(journal) - return resolved - } - } - : { - ...last, - resolve: (journal) => { - const resolved = last.resolve(journal) - finish(journal) - return resolved - } - } - return [...this.steps.slice(0, -1), wrapped] - } } /** The sink as the assembler needs it; null for a sink without transitions or a journal view. */ diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-rows.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-rows.ts new file mode 100644 index 000000000000..72c892cfbe9f --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-rows.ts @@ -0,0 +1,110 @@ +// Provider keys → journal rows, with no index to keep. +// +// Every row the assembler writes has an identity spelled from its key, so finding a row is +// spelling its id and reading that row: nothing to cache, evict or rebuild after a restart. The +// turn a row belongs to is the one the journal holds for it (its first write fixes it). Request +// incarnations are probed: the next is the first spelling the journal holds no row for. + +import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import type { + AgentJournalItemIdentity, + AgentJournalTurnScope +} from '../../../shared/agent-session-journal-types' +import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' +import type { StructuredAgentSessionTransitionJournal } from '../agent-session-wire/structured-agent-session-transition' +import type { + ProviderTimelineIdentityScheme, + ProviderTimelineItemFamily, + ProviderTimelineKey +} from './provider-timeline-identity' + +export type ProviderTimelineRowId = { identity: AgentJournalItemIdentity; itemId: string } + +export type ProviderTimelineTurnRef = ProviderTimelineRowId & { + key: ProviderTimelineKey + /** The id the turn row carries and a client's Stop names. */ + turnId: string +} + +export type ProviderTimelineTurnRowState = 'absent' | 'running' | 'settled' + +type Journal = StructuredAgentSessionTransitionJournal + +/** Room an item step reserves for a request id: the longest incarnation suffix it could take. */ +const REQUEST_INCARNATION_BOUND = 1_000_000_000 + +export function providerKey(value: string): ProviderTimelineKey { + return { source: 'provider', value } +} + +export function turnOf(scope: AgentJournalTurnScope): string | null { + return scope.kind === 'turn' ? scope.turnItemId : null +} + +/** What the journal holds for a turn row: any writer's settlement (a person's Stop included). */ +export function providerTimelineTurnRowState( + journal: Journal, + turnItemId: string +): ProviderTimelineTurnRowState { + const row = readAgentJournalTurn(journal.itemBody(turnItemId) ?? undefined) + return !row ? 'absent' : row.state === 'running' ? 'running' : 'settled' +} + +export class ProviderTimelineRows { + constructor( + private readonly deps: { + scheme: ProviderTimelineIdentityScheme + generation: string + namespace: string + } + ) {} + + /** A key unique to this acquisition; `serial` is taken from the state when the event is admitted. */ + minted(kind: string, serial: number): ProviderTimelineKey { + return { source: 'minted', value: `${this.deps.generation}:${kind}${serial}` } + } + + turn(key: ProviderTimelineKey): ProviderTimelineTurnRef { + const address = { namespace: this.deps.namespace, key } + const identity = this.deps.scheme.turn(address) + return { + key, + identity, + itemId: agentJournalItemKey(identity), + turnId: this.deps.scheme.turnId(address) + } + } + + item( + family: ProviderTimelineItemFamily, + key: ProviderTimelineKey, + thread: string | null + ): ProviderTimelineRowId { + return this.row(this.deps.scheme.item({ namespace: this.deps.namespace, family, key, thread })) + } + + /** The widest id a request under `key` could take, for the room its write reserves. */ + widestRequest(key: string): ProviderTimelineRowId { + return this.request(key, REQUEST_INCARNATION_BOUND) + } + + /** The first incarnation under `key` the journal holds no row for: never one already written. */ + nextRequest(key: string, journal: Journal): ProviderTimelineRowId { + for (let incarnation = 1; ; incarnation += 1) { + const row = this.request(key, incarnation) + if (journal.itemBody(row.itemId) === null) { + return row + } + } + } + + private request(key: string, incarnation: number): ProviderTimelineRowId { + return this.row( + this.deps.scheme.request({ generation: this.deps.generation, key, incarnation }) + ) + } + + private row(identity: AgentJournalItemIdentity): ProviderTimelineRowId { + return { identity, itemId: agentJournalItemKey(identity) } + } +} diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-settlement.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-settlement.ts index dd4e92166551..b8fb5f532233 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-settlement.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-settlement.ts @@ -1,10 +1,10 @@ // What a turn's end or the session's end settles, read from the journal when the settlement runs. // -// The same mechanism the dead-generation settlement uses after a restart: every row still waiting -// on the row that settles it — a running tool call, a pending prompt — is settled from its body as -// the journal holds it then. Nothing about open work is trusted from memory, so a prompt a client -// answered a moment earlier stays answered, a row a previous assembler opened is settled too, and -// a second settlement finds nothing left to do. +// The same mechanism, and the same terminal bodies, as the dead-generation settlement after a +// restart: every row still waiting on the row that settles it — a running tool call, a pending +// prompt — is settled from its body as the journal holds it then. Nothing about open work is +// trusted from memory, so a prompt a client answered a moment earlier stays answered, and a +// second settlement finds nothing left to do. import { parseAgentJournalItemKey } from '../../../shared/agent-session-journal-item-key' import { @@ -17,8 +17,8 @@ import { agentJournalTurnBody, readAgentJournalTurn } from '../../../shared/agent-session-turn-record' -import { cancelledJournalPromptBody } from '../agent-session-journal/journal-prompt-body-bounds' import type { JournalLifecycleMutationInput } from '../agent-session-journal/journal-row-builders' +import { terminalAgentJournalBody } from '../agent-session-journal/journal-terminal-settlement' import type { StructuredAgentSessionTransitionJournal } from '../agent-session-wire/structured-agent-session-transition' import { lostProviderTimelineBackgroundTasks } from './provider-timeline-background-tasks' import { @@ -36,19 +36,6 @@ export type ProviderTimelineTurnEnd = } | { state: 'unverifiable' } -/** What a row still open when its turn or the session ends becomes; null when it already stands. */ -export function settledProviderTimelineBody( - body: AgentJournalItemBody -): AgentJournalItemBody | null { - if (body.kind === 'tool-call') { - return body.state === 'running' ? { ...body, state: 'failed' } : null - } - if (body.kind === 'approval' || body.kind === 'question') { - return body.resolution.state === 'pending' ? cancelledJournalPromptBody(body) : null - } - return null -} - /** The end owns the turn's terminal fields; `unverifiable` carries no end and no verdict. Fields * keep the order every lane writes them in, so the row is the same bytes whoever ends it. */ export function endedProviderTimelineTurn( @@ -93,7 +80,7 @@ export function providerTimelineSettlement( // Background tasks outlive turns; only the session's end leaves them past seeing. const settled = !covered ? null - : (settledProviderTimelineBody(body) ?? + : (terminalAgentJournalBody(body) ?? (scope === 'session' ? lostProviderTimelineBackgroundTasks(body) : null)) const identity = settled ? parseAgentJournalItemKey(itemId) : null if (settled && identity) { diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-state.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-state.ts index a74fd943bcdd..d236f2536fab 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-state.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-state.ts @@ -1,28 +1,25 @@ -// What the assembler knows about the session, held twice. +// What the assembler knows about the session: one state, in admission order. // -// The ledger is the truth the decisions run on: it changes only inside transition resolvers, at -// each event's turn in the journal's write queue, and everything in it is either a cache of the -// journal (re-derived from the fold when missing) or this run's own memory (items it closed, the -// sends waiting for a turn). The forecast is a copy plus what admitted events still in the queue -// are expected to change; it only answers `apply` before those events land, and is rebuilt from -// the ledger as they do. - -import { parseAgentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +// An event changes it only when the sink admits the event, so a refused event changed nothing and +// re-applying it is the retry. For this producer's own rows admission order is write order (the +// sink is a FIFO queue), so the state never has to be re-read from the journal. Facts other +// writers own (a person's Stop, a client's answer) are read from the journal by key, never copied. + import { AGENT_JOURNAL_THREAD_SCOPE, type AgentJournalTurnLifecycle, type AgentJournalTurnScope } from '../../../shared/agent-session-journal-types' -import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' -import { BoundedMap } from '../../../shared/bounded-map' +import { requiresTerminalSettlement } from '../agent-session-journal/journal-terminal-settlement' import type { StructuredAgentSessionTransitionJournal } from '../agent-session-wire/structured-agent-session-transition' -import type { ProviderTimelineTurnRef } from './provider-timeline-joins' +import { + providerTimelineTurnRowState, + type ProviderTimelineRowId, + type ProviderTimelineTurnRef +} from './provider-timeline-rows' -/** Closed items remembered so a repeat close is dropped and an open reopens; past this, the - * journal decides from the row alone. */ +/** Closed items remembered so a repeat close is dropped; past this the journal decides alone. */ const MAX_CLOSED_ITEMS = 512 -/** Turns remembered as settled without asking the journal. */ -const MAX_SETTLED_TURNS = 256 /** Sends waiting for a turn; a provider that never opens one cannot grow this without bound. */ const MAX_PENDING_INPUTS = 64 const MAX_PENDING_INPUT_BYTES = 64 * 1024 @@ -35,101 +32,77 @@ export type ProviderTimelineOpenTurn = ProviderTimelineTurnRef & { running: AgentJournalTurnLifecycle } +/** A user message waiting for the turn it opens: Orca's send, or the provider's saved one. */ export type ProviderTimelinePendingInput = { - clientMessageId: string - requestedAt: number - /** The turn row the provider said the send opens; absent: the next turn to open. */ + userItemId: string + requestedAt?: number + /** The turn row the provider said it opens; absent: the next turn to open. */ turnItemId?: string } -/** Work still waiting on a row that settles it: a running tool or a pending request. Keyed by - * the item's join reference (a request's by its key, whatever its incarnation). */ -export type ProviderTimelineObligation = { - /** The row, once a resolver placed it. */ - itemId: string | null +/** Work this acquisition holds open (a running tool, a pending request), or an item it closed. */ +export type ProviderTimelineOpenItem = { + kind: 'item' | 'request' + /** The row; a request's is chosen by its write, which records it here. */ + row: ProviderTimelineRowId | null /** The turn whose end settles it; null for a row in no turn. */ turnItemId: string | null bytes: number + /** Closed here: holds no budget; a repeat close is dropped and an open reopens it. */ + closed: boolean } -export type ProviderTimelineTurnStatus = 'absent' | 'running' | 'settled' - export class ProviderTimelineState { ended = false open: ProviderTimelineOpenTurn | null = null + /** The turn that ended last, for context facts that arrive after it. */ latest: ProviderTimelineTurnRef | null = null inputs: ProviderTimelinePendingInput[] = [] - /** Items this run closed, with the turn they closed in. */ - closed = new BoundedMap({ - maxEntries: MAX_CLOSED_ITEMS, - maxBytes: MAX_CLOSED_ITEMS * 1024, - sizeOf: (turnItemId, ref) => bytes(ref) + bytes(turnItemId ?? '') + 16 - }) - settled = new BoundedMap({ - maxEntries: MAX_SETTLED_TURNS, - maxBytes: MAX_SETTLED_TURNS * 1024, - sizeOf: (_settled, turnItemId) => bytes(turnItemId) + 8 - }) - obligations = new Map() - /** Whether the open turn was taken from the journal yet (ledger only). */ - hydrated = false - - constructor(public namespace: string) {} + /** Keyed by row id (an item) or `request:` (a request, whatever its incarnation). */ + items = new Map() + /** Minted keys and settlement ids; taken only by admitted events. */ + serial = 0 get scope(): AgentJournalTurnScope { return this.open ? { kind: 'turn', turnItemId: this.open.itemId } : AGENT_JOURNAL_THREAD_SCOPE } - clone(): ProviderTimelineState { - const copy = new ProviderTimelineState(this.namespace) - copy.ended = this.ended - copy.open = this.open && { ...this.open } - copy.latest = this.latest - copy.inputs = [...this.inputs] - for (const [key, value] of this.closed.entries()) { - copy.closed.set(key, value) - } - for (const [key, value] of this.settled.entries()) { - copy.settled.set(key, value) + /** Serials for one event: committed with it, so a refused event takes none. */ + serials(): { next(): number; commit(): void } { + let taken = this.serial + return { + next: () => (taken += 1), + commit: () => { + this.serial = taken + } } - copy.obligations = new Map(this.obligations) - copy.hydrated = this.hydrated - return copy } - /** Settled once anything settled it: this run, or any writer of the journal (a person's Stop). - * Memory says only what the journal cannot yet: a turn opened by a write still queued. */ - status( - turn: { itemId: string }, - journal: StructuredAgentSessionTransitionJournal | null - ): ProviderTimelineTurnStatus { - if (this.settled.has(turn.itemId)) { - return 'settled' - } - const row = readAgentJournalTurn(journal?.itemBody(turn.itemId) ?? undefined) - if (row) { - return row.state === 'running' ? 'running' : 'settled' + /** Ends the open turn when another writer of the journal settled its row. */ + reconcile(journal: StructuredAgentSessionTransitionJournal): void { + if (this.open && providerTimelineTurnRowState(journal, this.open.itemId) === 'settled') { + this.endTurn(this.open) } - return this.open?.itemId === turn.itemId ? 'running' : 'absent' } - /** Ends the open turn when the journal holds it settled: another writer ended it. */ - reconcile(journal: StructuredAgentSessionTransitionJournal): void { - if (!this.hydrated) { - this.hydrate(journal) - } - const row = this.open && readAgentJournalTurn(journal.itemBody(this.open.itemId) ?? undefined) - if (this.open && row && row.state !== 'running') { - this.endTurn(this.open) + /** Whether the journal shows the work under `key` settled by any writer (a client's answer). + * Work whose write is still queued stays open. */ + settledInJournal(key: string, journal: StructuredAgentSessionTransitionJournal): boolean { + const entry = this.items.get(key) + if (!entry || entry.closed || !entry.row) { + return false } + const body = journal.itemBody(entry.row.itemId) + // A request write that found its turn over left no row: nothing is pending. + return body === null ? entry.kind === 'request' : !requiresTerminalSettlement(body) } - /** A send waiting for its turn; past the bounds the oldest is forgotten (its turn opens as the - * provider's own). */ + /** A user message waiting for its turn; past the bounds the oldest is forgotten (its turn opens + * as the provider's own). */ wait(pending: ProviderTimelinePendingInput): void { this.inputs.push(pending) const size = (input: ProviderTimelinePendingInput) => - bytes(input.clientMessageId) + bytes(input.turnItemId ?? '') + 16 + bytes(input.userItemId) + bytes(input.turnItemId ?? '') + 16 let held = this.inputs.reduce((total, input) => total + size(input), 0) while (this.inputs.length > MAX_PENDING_INPUTS || held > MAX_PENDING_INPUT_BYTES) { const forgotten = this.inputs.shift() @@ -140,7 +113,7 @@ export class ProviderTimelineState { } } - /** The send that opens `turnItemId`: the one that named it, else the oldest that named none. */ + /** The message that opens `turnItemId`: the one that named it, else the oldest that named none. */ opener(turnItemId: string): ProviderTimelinePendingInput | undefined { return ( this.inputs.find((input) => input.turnItemId === turnItemId) ?? @@ -148,69 +121,32 @@ export class ProviderTimelineState { ) } - /** The turn is over, and so is everything its settlement covered. */ + /** Closed here, in the turn it joined; past the bound the oldest closed items are forgotten. */ + close(key: string, turnItemId: string | null): void { + this.items.delete(key) + this.items.set(key, { kind: 'item', row: null, turnItemId, bytes: 0, closed: true }) + const closed = [...this.items].filter(([, entry]) => entry.closed) + for (const [oldest] of closed.slice(0, Math.max(0, closed.length - MAX_CLOSED_ITEMS))) { + this.items.delete(oldest) + } + } + + /** The turn is over, and so is everything in it: its end settled that work. */ endTurn(turn: ProviderTimelineTurnRef): void { if (this.open?.itemId === turn.itemId) { this.open = null } this.latest = turn - this.settled.set(turn.itemId, true) - for (const [key, obligation] of this.obligations) { - if (obligation.turnItemId === turn.itemId) { - this.obligations.delete(key) - } - } - // A settled turn's rows are judged by the turn now. - for (const [key, turnItemId] of this.closed.entries()) { - if (turnItemId === turn.itemId) { - this.closed.delete(key) + for (const [key, entry] of this.items) { + if (entry.turnItemId === turn.itemId) { + this.items.delete(key) } } } - /** The session is over: nothing it held is open any more. */ endSession(): void { + this.ended = true this.open = null - this.obligations.clear() - this.closed.clear() - } - - reset(namespace: string): void { - this.namespace = namespace - this.ended = false - this.open = null - this.latest = null - this.inputs = [] - this.obligations.clear() - this.closed.clear() - this.settled.clear() - } - - /** Takes the open turn from the journal: the newest turn row it holds running. Its key is the - * turn id the row carries, which is the provider's own turn id for a scheme that spells it. */ - hydrate(journal: StructuredAgentSessionTransitionJournal): void { - this.hydrated = true - if (this.open) { - return - } - let newest = -1 - journal.visitItems((itemId, sequence, body) => { - const running = readAgentJournalTurn(body) - const identity = running?.state === 'running' ? parseAgentJournalItemKey(itemId) : null - if (!running || !identity || sequence < newest) { - return - } - newest = sequence - this.open = { - address: { - namespace: this.namespace, - key: { source: 'provider', value: running.turnId } - }, - identity, - itemId, - turnId: running.turnId, - running - } - }) + this.items.clear() } } diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-stream-rows.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-stream-rows.ts deleted file mode 100644 index b362062b25ca..000000000000 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-stream-rows.ts +++ /dev/null @@ -1,185 +0,0 @@ -// The ledger of which row each text stream writes, changed only at its writes' turn in the queue. -// -// A stream's row is decided by its first write, like every other row: a provider-named message -// the journal already holds is one this run resumes (its turn still running: the stream adopts the -// row's text as its prefix) or a replay (its turn settled, or this run closed it: nothing is -// written). An anonymous stream continues the row of the one before it when the event that ended -// that one turned out a replay. Every later write checks again that the row is not over, against -// the journal, so a turn another writer ended stops the stream there too. - -import type { AgentJournalItemBody } from '../../../shared/agent-session-journal-types' -import { BoundedMap } from '../../../shared/bounded-map' -import type { StructuredAgentSessionTransitionJournal } from '../agent-session-wire/structured-agent-session-transition' -import { - providerTimelineLedger, - providerTimelinePlacement, - type ProviderTimelineContext -} from './provider-timeline-context' -import { turnOf } from './provider-timeline-decision' -import type { ProviderTimelineRow } from './provider-timeline-joins' -import type { ProviderTimelineState } from './provider-timeline-state' -import type { ProviderTimelineStream } from './provider-timeline-text-streams' - -/** The row a stream writes, and the text it held before this run streamed into it. */ -export type ProviderTimelineStreamRow = { row: ProviderTimelineRow; prefix: string } - -type Owned = ProviderTimelineStreamRow | 'replayed' - -/** Rows kept for the anonymous stream that may continue them, and streams stopped mid-flight - * whose queued writes must still write nothing. */ -const MAX_CARRIED_ROWS = 64 -const MAX_STOPPED_STREAMS = 512 - -function providerTimelineMessageText(body: AgentJournalItemBody | null): string { - return body?.kind === 'message' && body.blocks[0]?.type === 'text' ? body.blocks[0].text : '' -} - -export class ProviderTimelineStreamRows { - /** The streams planning still holds open. */ - private readonly owned = new Map() - private readonly carried = new BoundedMap({ - maxEntries: MAX_CARRIED_ROWS - }) - private readonly stopped = new BoundedMap({ maxEntries: MAX_STOPPED_STREAMS }) - - constructor( - private readonly context: ProviderTimelineContext, - /** Drops a stream planning still holds open, so its next delta starts another. */ - private readonly forget: (stream: ProviderTimelineStream) => void - ) {} - - /** At a write: the row it lands on, or null when it writes nothing. */ - resolve( - stream: ProviderTimelineStream, - journal: StructuredAgentSessionTransitionJournal - ): ProviderTimelineStreamRow | null { - if (this.stopped.has(stream.id)) { - return null - } - const ledger = providerTimelineLedger(this.context, journal) - let owned = this.owned.get(stream.id) - if (!owned) { - owned = this.own(stream, ledger, journal) - this.owned.set(stream.id, owned) - } - if (owned === 'replayed' || ledger.ended) { - return null - } - // Every write, not only the first: whoever ended the row's turn since, this run or another - // writer of the journal, the stream never writes into it again. - if (this.over(stream, owned.row, ledger, journal)) { - this.stop(stream) - return null - } - return owned - } - - /** The event that released `stream` ran: `held` when it really ended the stream's message. */ - release(stream: ProviderTimelineStream, held: boolean): void { - stream.boundary = held ? 'held' : 'void' - const owned = this.owned.get(stream.id) - this.owned.delete(stream.id) - // A named stream's next delta finds its row again through the journal; an anonymous one's row - // is kept for the stream that follows it. - if (!held && !stream.named && owned && owned !== 'replayed') { - this.carried.set(stream.id, owned) - } - } - - /** Stops every stream whose row is over now. */ - retire( - streams: readonly ProviderTimelineStream[], - journal: StructuredAgentSessionTransitionJournal - ): void { - const ledger = providerTimelineLedger(this.context, journal) - for (const stream of streams) { - const owned = this.owned.get(stream.id) - if (owned && owned !== 'replayed' && this.over(stream, owned.row, ledger, journal)) { - this.stop(stream) - } - } - } - - /** The turn the stream's row is in, once a write placed it; else the one planning expects. */ - turn(stream: ProviderTimelineStream): string | null { - const owned = this.owned.get(stream.id) - return owned && owned !== 'replayed' ? turnOf(owned.row.scope) : stream.turnItemId - } - - clear(): void { - this.owned.clear() - this.carried.clear() - this.stopped.clear() - } - - private own( - stream: ProviderTimelineStream, - ledger: ProviderTimelineState, - journal: StructuredAgentSessionTransitionJournal - ): Owned { - const { joins } = this.context - if (stream.named && ledger.closed.has(stream.key)) { - return 'replayed' - } - const carried = this.continued(stream, journal) - if (carried) { - return carried - } - const found = stream.named ? joins.find(stream.join, journal) : null - if (found) { - const turnItemId = turnOf(found.scope) - if (turnItemId !== null && ledger.status({ itemId: turnItemId }, journal) === 'settled') { - return 'replayed' - } - return { row: found, prefix: providerTimelineMessageText(journal.itemBody(found.itemId)) } - } - const placement = providerTimelinePlacement(this.context, ledger, { - ...(stream.join.thread === null ? {} : { thread: stream.join.thread }), - ...(stream.turn === undefined ? {} : { turn: stream.turn }) - }) - const itemClass = stream.channel === 'assistant' ? 'message' : 'reasoning' - return { row: joins.place(stream.join, itemClass, placement, journal), prefix: '' } - } - - /** The row of the message this one continues: an earlier anonymous stream whose release the - * journal did not take (its event was a replay). */ - private continued( - stream: ProviderTimelineStream, - journal: StructuredAgentSessionTransitionJournal - ): Owned | null { - let previous = stream.follows - delete stream.follows - for (; previous?.boundary === 'void'; previous = previous.follows) { - const kept = this.carried.get(previous.id) - if (kept) { - this.carried.delete(previous.id) - return { - row: kept.row, - prefix: providerTimelineMessageText(journal.itemBody(kept.row.itemId)) - } - } - } - return null - } - - /** Whether the row is past streaming into: its item closed, or its turn settled. */ - private over( - stream: ProviderTimelineStream, - row: ProviderTimelineRow, - ledger: ProviderTimelineState, - journal: StructuredAgentSessionTransitionJournal - ): boolean { - const turn = turnOf(row.scope) - return ( - (stream.named && ledger.closed.has(stream.key)) || - (turn !== null && ledger.status({ itemId: turn }, journal) === 'settled') - ) - } - - /** The stream writes nothing more; a later delta under its key starts another. */ - private stop(stream: ProviderTimelineStream): void { - this.owned.delete(stream.id) - this.stopped.set(stream.id, true) - this.forget(stream) - } -} diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-text-events.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-text-events.ts index 8b793ef2139f..0cf3452440bb 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-text-events.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-text-events.ts @@ -1,30 +1,45 @@ -// Text events: planning-side streams, admitted like every other event. +// Text events: streams admitted like every other event. -import type { StructuredAgentSessionTransitionJournal } from '../agent-session-wire/structured-agent-session-transition' +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import type { StructuredAgentSessionSinkAdmission } from '../agent-session-wire/structured-agent-session-event-sink' -import type { - ProviderTimelineApplyHost, - ProviderTimelineApplyResult, - ProviderTimelineInFlight -} from './provider-timeline-apply-host' +import type { StructuredAgentSessionTransitionJournal } from '../agent-session-wire/structured-agent-session-transition' +import type { ProviderTimelineHold } from './provider-timeline-budget' import { PROVIDER_TIMELINE_OVER_BUDGET } from './provider-timeline-budget' +import type { ProviderTimelineDropRule } from './provider-timeline-decision' import type { ProviderTimelineEvent } from './provider-timeline-event' -import { ProviderTimelinePlan } from './provider-timeline-plan' +import { ProviderTimelinePlan, type ProviderTimelineSink } from './provider-timeline-plan' +import { providerTimelineTurnRowState, turnOf } from './provider-timeline-rows' +import type { ProviderTimelineState } from './provider-timeline-state' +import type { ProviderTimelineTextStreams } from './provider-timeline-text-streams' + +export type ProviderTimelineApplyResult = { + /** The sink's answer for the event's writes; refused means nothing changed. */ + admission: StructuredAgentSessionSinkAdmission + dropped?: ProviderTimelineDropRule +} + +/** What the text appliers share with the assembler that owns them. */ +export type ProviderTimelineTextHost = { + sink: ProviderTimelineSink + state: ProviderTimelineState + streams: ProviderTimelineTextStreams + journal: StructuredAgentSessionTransitionJournal | null + admits(hold: ProviderTimelineHold): boolean +} const ADMITTED: StructuredAgentSessionSinkAdmission = { accepted: true } export function applyProviderTimelineTextDelta( - host: ProviderTimelineApplyHost, + host: ProviderTimelineTextHost, event: Extract ): ProviderTimelineApplyResult { - const { streams } = host - const forecast = host.forecast() - if (forecast.ended) { + const { streams, state } = host + if (state.ended) { return { admission: ADMITTED, dropped: 'session-ended' } } - const journal = host.journal() const plan = new ProviderTimelinePlan() - let stream = streams.get(streams.key(event.item, event.join, forecast)) + const key = streams.key(event.item, event.join) + let stream = streams.get(key) if (stream && !streams.continues(stream, event.channel, event.producer)) { if (stream.named) { return { admission: ADMITTED, dropped: 'stream-mismatch' } @@ -36,70 +51,59 @@ export function applyProviderTimelineTextDelta( stream = undefined } if (!stream) { - if ('id' in event.item && settledNamedItem(host, event, journal)) { + if ('id' in event.item && settledNamedItem(host, key)) { return { admission: ADMITTED, dropped: 'item-settled' } } + const serials = state.serials() stream = streams.start({ item: event.item, join: event.join, channel: event.channel, producer: event.producer, - state: forecast + state, + serial: serials.next() }) if (!host.admits({ key: stream.key, bytes: stream.bytes })) { return { admission: PROVIDER_TIMELINE_OVER_BUDGET } } + plan.onAdmitted(serials.commit) } streams.planAppend(plan, stream, event.text) - return { admission: host.submit(plan, { executed: false, commit: undefined }) } + return { admission: plan.submit(host.sink) } } /** A named delta for an item this run closed, or one the journal holds in a settled turn. */ -function settledNamedItem( - host: ProviderTimelineApplyHost, - event: Extract, - journal: StructuredAgentSessionTransitionJournal | null -): boolean { - const { context } = host - const forecast = host.forecast() - if (!('id' in event.item)) { - return false - } - const join = { - family: 'item' as const, - key: { source: 'provider' as const, value: event.item.id }, - thread: event.join?.thread ?? null - } - if (forecast.closed.has(context.joins.reference(join, forecast.namespace))) { +function settledNamedItem(host: ProviderTimelineTextHost, key: string): boolean { + if (host.state.items.get(key)?.closed) { return true } - const row = context.joins.find(join, journal, forecast.namespace) - const turnItemId = row?.scope.kind === 'turn' ? row.scope.turnItemId : null - return turnItemId !== null && forecast.status({ itemId: turnItemId }, journal) === 'settled' + const held = host.journal?.item(key) + const turn = held ? turnOf(held.turnScope ?? AGENT_JOURNAL_THREAD_SCOPE) : null + return ( + host.journal !== null && + turn !== null && + providerTimelineTurnRowState(host.journal, turn) === 'settled' + ) } export function applyProviderTimelineTextClose( - host: ProviderTimelineApplyHost, + host: ProviderTimelineTextHost, event: Extract ): ProviderTimelineApplyResult { - const { streams } = host - const forecast = host.forecast() - if (forecast.ended) { + const { streams, state } = host + if (state.ended) { return { admission: ADMITTED, dropped: 'session-ended' } } - const stream = streams.get(streams.key(event.item, event.join, forecast)) + const stream = streams.get(streams.key(event.item, event.join)) if (!stream) { return { admission: ADMITTED, dropped: 'stream-unknown' } } const plan = new ProviderTimelinePlan() streams.planFlush(plan, stream) streams.planClose(plan, stream, event.text) - const entry: ProviderTimelineInFlight = { - executed: false, - commit: stream.named ? (state) => state.closed.set(stream.key, stream.turnItemId) : undefined + if (stream.named) { + // The close settles the item for full snapshots too. + plan.onAdmitted(() => state.close(stream.key, turnOf(stream.scope))) } - plan.atExecution(() => { - entry.executed = true - }) - return { admission: host.submit(plan, entry) } + return { admission: plan.submit(host.sink) } } diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-text-streams.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-text-streams.ts index 1557873426b6..da869954907d 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-text-streams.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-text-streams.ts @@ -6,11 +6,14 @@ // message. Deltas accumulate in the shared coalescer, and a row is a snapshot of the text so far. // Text owed to the journal is written inside the next event's transition — every other event is // an ordering barrier — or by the coalescer's window, and is marked written once the sink takes it. -// Which row each write lands on is the journal's call, at the write (`provider-timeline-stream-rows.ts`). +// Every write first reads the turn its row is in: once any writer settled that turn (a person's +// Stop, the turn's own end), the stream writes nothing more. -import type { - AgentJournalItemBody, - AgentJournalProducerLinkage +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalItemBody, + type AgentJournalProducerLinkage, + type AgentJournalTurnScope } from '../../../shared/agent-session-journal-types' import { boundInlineText, @@ -21,77 +24,60 @@ import { type AgentSessionDeltaCoalescerDeps } from '../agent-session-wire/agent-session-delta-coalescer' import { estimateStructuredAgentSessionItemBytes } from '../agent-session-wire/structured-agent-session-event-sink-estimate' -import type { - StructuredAgentSessionItemAppendOptions, - StructuredAgentSessionSinkAdmission -} from '../agent-session-wire/structured-agent-session-event-sink' import type { StructuredAgentSessionTransitionJournal } from '../agent-session-wire/structured-agent-session-transition' import { providerTimelineEntryBytes, providerTimelinePlacement, type ProviderTimelineContext } from './provider-timeline-context' +import type { ProviderTimelineResolvedWrite } from './provider-timeline-decision' import type { ProviderTimelineJoin, ProviderTimelineTextChannel, ProviderTimelineTextItem } from './provider-timeline-event' -import { BoundedMap } from '../../../shared/bounded-map' import { providerTimelineKeyPart } from './provider-timeline-identity' -import type { ProviderTimelineItemJoin, ProviderTimelineRow } from './provider-timeline-joins' import { ProviderTimelinePlan, type ProviderTimelineSink } from './provider-timeline-plan' -import { ProviderTimelineStreamRows } from './provider-timeline-stream-rows' +import { + providerKey, + providerTimelineTurnRowState, + turnOf, + type ProviderTimelineRowId +} from './provider-timeline-rows' import type { ProviderTimelineState } from './provider-timeline-state' -/** One open text stream, as planning knows it. */ +/** One open text stream. */ export type ProviderTimelineStream = { - /** Unique per stream: the coalescer's key, and the ledger's for the row it owns. */ + /** The coalescer's key, unique per stream. */ id: string - /** The stream's slot: a named stream's is its item's join reference, which the budget shares. */ + /** The stream's slot: a named stream's is its item's row id, which the budget shares. */ key: string - join: ProviderTimelineItemJoin - /** The turn the provider named, if any. */ - turn: string | undefined - /** The turn planning expects its row in, so a turn's end releases it. */ - turnItemId: string | null + row: ProviderTimelineRowId + /** The turn its row joins when it is new; an existing row keeps its own. */ + scope: AgentJournalTurnScope named: boolean channel: ProviderTimelineTextChannel producer: AgentJournalProducerLinkage | undefined /** Whether any text was written; a whitespace-only stream completes nothing. */ written: boolean bytes: number - /** The anonymous stream an event released just before this one began: this one continues its - * row unless that event's boundary landed. */ - follows?: ProviderTimelineStream - /** Execution: whether the event that released this stream ended its message. */ - boundary?: 'held' | 'void' -} - -type Resolved = { - identity: ProviderTimelineRow['identity'] - body: AgentJournalItemBody - options: StructuredAgentSessionItemAppendOptions + /** A write found its row's turn settled: it writes nothing more. */ + stopped: boolean } export class ProviderTimelineTextStreams { private readonly streams = new Map() private readonly byId = new Map() - private readonly rows: ProviderTimelineStreamRows - /** Per anonymous slot, the stream a boundary not yet decided released last. */ - private readonly released = new BoundedMap({ maxEntries: 64 }) private readonly coalescer - private serial = 0 constructor( private readonly deps: { sink: ProviderTimelineSink context: ProviderTimelineContext - generation: string coalesceMs?: number schedule?: AgentSessionDeltaCoalescerDeps['schedule'] } ) { - this.rows = new ProviderTimelineStreamRows(deps.context, (stream) => this.forget(stream)) this.coalescer = createAgentSessionDeltaCoalescer({ ...(deps.coalesceMs === undefined ? {} : { windowMs: deps.coalesceMs }), ...(deps.schedule ? { schedule: deps.schedule } : {}), @@ -103,47 +89,45 @@ export class ProviderTimelineTextStreams { /** What the budget counts: one entry per open stream. */ get open(): readonly ProviderTimelineStream[] { - return [...this.streams.values()] + return [...this.streams.values()].filter((stream) => !stream.stopped) } /** The stream slot `item` names on its thread. */ - key( - item: ProviderTimelineTextItem, - join: ProviderTimelineJoin | undefined, - state: ProviderTimelineState - ): string { + key(item: ProviderTimelineTextItem, join: ProviderTimelineJoin | undefined): string { + const thread = join?.thread ?? null return 'id' in item - ? this.deps.context.joins.reference(this.itemJoin(item, join), state.namespace) - : `stream:${providerTimelineKeyPart(join?.thread ?? '')}:${providerTimelineKeyPart(item.stream)}` + ? this.deps.context.rows.item('item', providerKey(item.id), thread).itemId + : `stream:${providerTimelineKeyPart(thread ?? '')}:${providerTimelineKeyPart(item.stream)}` } + /** The open stream in `key`; a stopped one is let go, so the next delta starts another. */ get(key: string): ProviderTimelineStream | undefined { - return this.streams.get(key) + const stream = this.streams.get(key) + if (stream?.stopped) { + this.forget(stream) + return undefined + } + return stream } - /** A new stream; the caller has checked the budget for its `bytes`. */ + /** A new stream, not yet open; `serial` names it (and an anonymous one's row). */ start(input: { item: ProviderTimelineTextItem join: ProviderTimelineJoin | undefined channel: ProviderTimelineTextChannel producer: AgentJournalProducerLinkage | undefined state: ProviderTimelineState + serial: number }): ProviderTimelineStream { - this.serial += 1 + const { rows } = this.deps.context + const thread = input.join?.thread ?? null const named = 'id' in input.item - const join = - 'id' in input.item ? this.itemJoin(input.item, input.join) : this.anonymousJoin(input.join) - const placement = providerTimelinePlacement(this.deps.context, input.state, input.join) - const key = this.key(input.item, input.join, input.state) - const follows = named ? undefined : this.released.get(key) - this.released.delete(key) + const key = 'id' in input.item ? providerKey(input.item.id) : rows.minted('s', input.serial) return { - id: `${this.deps.generation}:s${this.serial}`, - key, - ...(follows ? { follows } : {}), - join, - turn: input.join?.turn, - turnItemId: placement.scope.kind === 'turn' ? placement.scope.turnItemId : null, + id: `s${input.serial}`, + key: this.key(input.item, input.join), + row: rows.item('item', key, thread), + scope: providerTimelinePlacement(this.deps.context, input.state, input.join), named, channel: input.channel, producer: input.producer, @@ -152,7 +136,8 @@ export class ProviderTimelineTextStreams { key: 'id' in input.item ? input.item.id : input.item.stream, join: input.join, producer: input.producer - }) + }), + stopped: false } } @@ -186,43 +171,18 @@ export class ProviderTimelineTextStreams { } } - /** Ends the streams `which` selects; their text was flushed by the same event. Planning starts - * the next message at once; `landed`, asked at execution, says whether the event really ended - * them: when it did not (a replay the journal held), the next anonymous message continues - * this one's row, and a named stream's next delta resumes its row from the journal. */ + /** Ends the streams `which` selects once the event is admitted; their text was flushed by it. */ planRelease( plan: ProviderTimelinePlan, - which: (stream: ProviderTimelineStream) => boolean, - landed?: () => boolean + which: (stream: ProviderTimelineStream) => boolean ): void { const released = [...this.streams.values()].filter(which) - if (released.length === 0) { - return + if (released.length > 0) { + plan.onAdmitted(() => released.forEach((stream) => this.forget(stream))) } - plan.onAdmitted(() => - released.forEach((stream) => { - this.forget(stream) - if (landed && !stream.named) { - this.released.set(stream.key, stream) - } - }) - ) - plan.atExecution(() => - released.forEach((stream) => this.rows.release(stream, !landed || landed())) - ) - } - - /** At a turn's or the session's end: every stream whose row is over now stops. */ - planRetire(plan: ProviderTimelinePlan): void { - plan.atExecution((journal) => this.rows.retire(this.open, journal)) - } - - /** The turn a stream's row is in, once a write placed it; else the one planning expects. */ - rowTurn(stream: ProviderTimelineStream): string | null { - return this.rows.turn(stream) } - /** Ends one stream with the provider's final text, else what streamed; it settles the item. */ + /** Ends one stream with the provider's final text, else what streamed. */ planClose(plan: ProviderTimelinePlan, stream: ProviderTimelineStream, finalText?: string): void { if (finalText === undefined) { const owed = this.coalescer.dirty().find(({ key }) => key === stream.id) @@ -230,18 +190,8 @@ export class ProviderTimelineTextStreams { this.planText(plan, stream, owed.snapshot.text) } } else { - this.planText( - plan, - stream, - boundInlineText(finalText, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text, - true - ) - } - if (stream.named) { - // The close settles the item for full snapshots too, in the turn its row is in. - plan.atExecution(() => - this.deps.context.ledger.closed.set(stream.key, this.rows.turn(stream)) - ) + const text = boundInlineText(finalText, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text + this.planText(plan, stream, text, true) } this.planRelease(plan, (each) => each === stream) } @@ -252,8 +202,6 @@ export class ProviderTimelineTextStreams { dispose(): void { this.open.forEach((stream) => this.forget(stream)) - this.rows.clear() - this.released.clear() this.coalescer.dispose() } @@ -265,7 +213,7 @@ export class ProviderTimelineTextStreams { } const plan = new ProviderTimelinePlan() this.planText(plan, stream, text) - const admission: StructuredAgentSessionSinkAdmission = plan.submit(this.deps.sink) + const admission = plan.submit(this.deps.sink) // A failed or closed sink can never take it; the session's end owns what it leaves. return admission.accepted || admission.reason !== 'backpressure' } @@ -281,16 +229,11 @@ export class ProviderTimelineTextStreams { plan.onAdmitted(() => this.coalescer.markFlushed(stream.id)) return } - const placeholder = this.message(stream, text) + const body = this.message(stream, text) plan.item({ - // Room for the text and the longest identity a scheme spells; a resumed prefix only paces. - reservedBytes: estimateStructuredAgentSessionItemBytes( - { provider: 'orca', clientMessageId: 'x'.repeat(1024) }, - placeholder - ), - paced: true, - resolve: (journal) => this.resolveText(stream, text, providerFinal, journal), - options: { ...stream.producer, turnScope: { kind: 'thread' } } + reservedBytes: estimateStructuredAgentSessionItemBytes(stream.row.identity, body), + resolve: (journal) => this.resolveText(stream, body, journal), + options: { ...stream.producer, turnScope: stream.scope } }) plan.onAdmitted(() => { stream.written = true @@ -298,27 +241,23 @@ export class ProviderTimelineTextStreams { }) } - /** At the write's turn in the queue: the row the stream owns, and its text so far. */ + /** Every write, not only the first: whoever ended the row's turn since, this run or another + * writer of the journal, the stream never writes into it again. */ private resolveText( stream: ProviderTimelineStream, - text: string, - providerFinal: boolean, + body: AgentJournalItemBody, journal: StructuredAgentSessionTransitionJournal - ): Resolved | null { - const owned = this.rows.resolve(stream, journal) - if (!owned) { + ): ProviderTimelineResolvedWrite | null { + const held = journal.item(stream.row.itemId) + const turn = turnOf(held ? (held.turnScope ?? AGENT_JOURNAL_THREAD_SCOPE) : stream.scope) + if ( + stream.stopped || + (turn !== null && providerTimelineTurnRowState(journal, turn) === 'settled') + ) { + stream.stopped = true return null } - const { row, prefix } = owned - return { - identity: row.identity, - body: this.message(stream, providerFinal ? text : `${prefix}${text}`), - options: { - ...stream.producer, - turnScope: row.scope, - ...(row.ref === undefined ? {} : { providerItemRef: row.ref }) - } - } + return { identity: stream.row.identity, body } } private message(stream: ProviderTimelineStream, text: string): AgentJournalItemBody { @@ -329,18 +268,6 @@ export class ProviderTimelineTextStreams { } } - private itemJoin(item: { id: string }, join?: ProviderTimelineJoin): ProviderTimelineItemJoin { - return { - family: 'item', - key: { source: 'provider', value: item.id }, - thread: join?.thread ?? null - } - } - - private anonymousJoin(join?: ProviderTimelineJoin): ProviderTimelineItemJoin { - return { family: 'item', key: this.deps.context.joins.mint('s'), thread: join?.thread ?? null } - } - private forget(stream: ProviderTimelineStream): void { this.coalescer.forget(stream.id) if (this.streams.get(stream.key) === stream) { diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-transition-layout.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-transition-layout.ts index 35fd7abc14b5..a51cc9064dac 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-transition-layout.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-transition-layout.ts @@ -1,130 +1,87 @@ -// The writes one non-text event may need, laid out before anything is decided: the text owed -// ahead of it, then its fixed settlement and item slots, each resolved when it runs. +// The writes one non-text event's transition carries, in order: the text owed ahead of it, its +// settlement, then its rows. -import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' -import { estimateStructuredAgentSessionItemBytes } from '../agent-session-wire/structured-agent-session-event-sink-estimate' -import type { StructuredAgentSessionTransitionJournal } from '../agent-session-wire/structured-agent-session-transition' -import { unhandledProviderFrameJournalItem } from '../agent-session-wire/unhandled-provider-frame' -import type { ProviderTimelineApplyHost } from './provider-timeline-apply-host' +import { + providerTimelineSettlementId, + type ProviderTimelineContext +} from './provider-timeline-context' import type { ProviderTimelineDecidedEvent, ProviderTimelineDecision -} from './provider-timeline-decisions' +} from './provider-timeline-decision' import type { ProviderTimelinePlan } from './provider-timeline-plan' +import { providerKey, turnOf } from './provider-timeline-rows' +import type { ProviderTimelineState } from './provider-timeline-state' +import type { ProviderTimelineTextStreams } from './provider-timeline-text-streams' -/** Room for a settled turn row and its context facts. */ -const TURN_ROW_RESERVED_BYTES = 64 * 1024 -/** Identities a scheme spells stay well under this; item rows reserve it for theirs. */ -const IDENTITY_PLACEHOLDER = { provider: 'orca', clientMessageId: 'x'.repeat(1024) } as const +/** Paces the queue only: a settlement's mutations are the journal's to choose. */ +const SETTLEMENT_RESERVED_BYTES = 64 * 1024 /** Text owed ahead of the event lands first; a row-writing event also ends the messages it * separates: anonymous ones of its producer, every stream of a turn it ends, all on a session end. */ export function planProviderTimelineBarrier( - host: ProviderTimelineApplyHost, + input: { + streams: ProviderTimelineTextStreams + state: ProviderTimelineState + context: ProviderTimelineContext + }, plan: ProviderTimelinePlan, event: ProviderTimelineDecidedEvent, - decision: ProviderTimelineDecision, - /** Execution: whether the event's own decision wrote, so the boundary it draws is real. */ - landed: () => boolean + decision: ProviderTimelineDecision ): void { - const { streams, context } = host - const forecast = host.forecast() + const { streams, state, context } = input // A full snapshot of a streamed message replaces what streamed, so that text is not flushed. const replaced = decision.closes ? streams.get(decision.closes) : undefined streams.planFlush(plan, replaced) if (replaced) { - streams.planRelease(plan, (stream) => stream === replaced, landed) + streams.planRelease(plan, (stream) => stream === replaced) } - if (event.type === 'session.ended' || event.type === 'session.reset') { - streams.planRelease(plan, () => true, landed) - streams.planRetire(plan) + if (event.type === 'session.ended') { + streams.planRelease(plan, () => true) return } if (event.type === 'turn.end' || event.type === 'turn.open') { const ending = event.type === 'turn.end' && event.turn !== undefined - ? context.joins.turn({ source: 'provider', value: event.turn }, forecast.namespace).itemId - : forecast.open?.itemId + ? context.rows.turn(providerKey(event.turn)).itemId + : state.open?.itemId streams.planRelease( plan, (stream) => (!stream.named && stream.producer?.agentId === undefined) || - (ending !== undefined && streams.rowTurn(stream) === ending), - landed + (ending !== undefined && turnOf(stream.scope) === ending) ) - // The turn that really ended is the journal's: its streams stop wherever planning placed them. - streams.planRetire(plan) return } if (event.type !== 'input.accepted') { const agentId = 'producer' in event ? event.producer?.agentId : undefined - streams.planRelease( - plan, - (stream) => !stream.named && stream.producer?.agentId === agentId, - landed - ) + streams.planRelease(plan, (stream) => !stream.named && stream.producer?.agentId === agentId) } } -export function planProviderTimelineSlots( - host: ProviderTimelineApplyHost, +/** The event's own settlement and rows, after the text it flushed. */ +export function planProviderTimelineWrites( + context: ProviderTimelineContext, plan: ProviderTimelinePlan, - event: ProviderTimelineDecidedEvent, - decide: (journal: StructuredAgentSessionTransitionJournal) => ProviderTimelineDecision | null + decision: ProviderTimelineDecision, + serial: () => number ): void { - const { context } = host - const settlement = (what: string) => + const { settle } = decision + if (settle) { plan.settlement({ - settlementId: context.settlementId(what), - reservedBytes: TURN_ROW_RESERVED_BYTES, - resolve: (journal) => decide(journal)?.settle ?? [] + settlementId: providerTimelineSettlementId(context, serial(), settle.what), + reservedBytes: SETTLEMENT_RESERVED_BYTES, + resolve: settle.resolve }) - const item = (reservedBytes: number, lifecycle: boolean) => + } + for (const write of decision.writes ?? []) { plan.item( { - reservedBytes, - resolve: (journal) => decide(journal)?.write ?? null, - options: { turnScope: AGENT_JOURNAL_THREAD_SCOPE } + reservedBytes: write.reservedBytes, + resolve: write.resolve, + options: write.options }, - lifecycle + write.lifecycle ) - switch (event.type) { - case 'turn.open': - settlement('turn-superseded') - item(TURN_ROW_RESERVED_BYTES, true) - return - case 'turn.end': - settlement('turn-end') - return - case 'request.withdrawn': - settlement('request-withdrawn') - return - case 'session.ended': - settlement('session-end') - return - case 'session.reset': - settlement('session-reset') - return - case 'input.accepted': - case 'context.usage': - item(TURN_ROW_RESERVED_BYTES, true) - return - case 'item.open': - case 'item.update': - case 'item.close': - item( - estimateStructuredAgentSessionItemBytes(IDENTITY_PLACEHOLDER, event.body), - event.type !== 'item.update' - ) - return - case 'request.open': - item(estimateStructuredAgentSessionItemBytes(IDENTITY_PLACEHOLDER, event.body), true) - return - case 'provider.frame': { - const frame = unhandledProviderFrameJournalItem(context.agent, event.frameKind, event.payload) - if (frame) { - item(estimateStructuredAgentSessionItemBytes(IDENTITY_PLACEHOLDER, frame.body), false) - } - } } } diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-turn-decisions.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-turn-decisions.ts index a6ee99b15bf1..f242a7573e7b 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-turn-decisions.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-turn-decisions.ts @@ -1,4 +1,5 @@ -// What turn, send, context and session events do, decided on the forecast and again on the ledger. +// What turn, user-message, context and session events do: admitted on the state, written +// against the journal. import { AGENT_JOURNAL_THREAD_SCOPE, @@ -9,81 +10,100 @@ import { agentJournalTurnBody, readAgentJournalTurn } from '../../../shared/agent-session-turn-record' +import { estimateStructuredAgentSessionItemBytes } from '../agent-session-wire/structured-agent-session-event-sink-estimate' import type { StructuredAgentSessionTransitionJournal } from '../agent-session-wire/structured-agent-session-transition' import { agentJournalTurnRowReservedBytes, resolveAgentJournalTurnRowWrite } from './agent-journal-turn-row-revision' +import { providerTimelinePlacement } from './provider-timeline-context' +import type { + ProviderTimelineDecidedEvent, + ProviderTimelineDecision, + ProviderTimelineDecisionInput, + ProviderTimelineItemWrite, + ProviderTimelineResolvedWrite +} from './provider-timeline-decision' import { providerKey, - settlementOf, - type ProviderTimelineDecidedEvent, - type ProviderTimelineDecision, - type ProviderTimelineDecisionInput, - type ProviderTimelineResolvedWrite -} from './provider-timeline-decision' -import { providerTimelinePlacement } from './provider-timeline-context' -import type { ProviderTimelineTurnRef } from './provider-timeline-joins' + providerTimelineTurnRowState, + type ProviderTimelineTurnRef +} from './provider-timeline-rows' import { providerTimelineSettlement, - runningProviderTimelineTurns, - type ProviderTimelineTurnEnd + runningProviderTimelineTurns } from './provider-timeline-settlement' -import type { ProviderTimelineState } from './provider-timeline-state' +import type { + ProviderTimelineOpenTurn, + ProviderTimelinePendingInput +} from './provider-timeline-state' + +/** Room for a settled turn row and its context facts. */ +const TURN_ROW_RESERVED_BYTES = 64 * 1024 export function decideTurnOpen( input: ProviderTimelineDecisionInput, event: Extract ): ProviderTimelineDecision { - const { state, context } = input + const { state, journal, context } = input const open = state.open - const key = event.turn === undefined ? event.minted : providerKey(event.turn) - if (!key || (event.turn === undefined && open)) { + // An open naming no turn while one is open is the same turn, not a new one. + if (event.turn === undefined && open) { return { dropped: 'turn-duplicate' } } - const turn = context.joins.turn(key, state.namespace) - if (open?.itemId === turn.itemId) { + const turn = context.rows.turn( + event.turn === undefined ? context.rows.minted('t', input.serial()) : providerKey(event.turn) + ) + const held = journal ? providerTimelineTurnRowState(journal, turn.itemId) : 'absent' + if (open?.itemId === turn.itemId || held === 'running') { return { dropped: 'turn-duplicate' } } - if (state.status(turn, input.journal) !== 'absent') { - return { dropped: 'turn-replayed' } + if (held === 'settled') { + return { dropped: 'turn-settled' } } const pending = state.opener(turn.itemId) const running: AgentJournalTurnLifecycle = { turnId: turn.turnId, state: 'running', - userItemId: pending - ? agentJournalSubmissionKey(pending.clientMessageId) - : context.joins.turnOpener(turn), + userItemId: pending?.userItemId ?? turn.itemId, startedAt: event.at, - ...(pending ? { requestedAt: pending.requestedAt } : {}) - } - // A newer turn ended this one, whoever asked for it. - const superseded = open - ? settlementOf(input, open, { - state: 'interrupted', - completedAt: event.at, - outcome: 'superseded' - }) - : [] + ...(pending?.requestedAt === undefined ? {} : { requestedAt: pending.requestedAt }) + } + const write: ProviderTimelineResolvedWrite = { + identity: turn.identity, + body: agentJournalTurnBody(running) + } return { - ...(superseded ? { settle: superseded } : {}), - write: input.execute + // A newer turn ended this one, whoever asked for it. + ...(open ? { - identity: turn.identity, - body: agentJournalTurnBody(running), - // The running row's ts is the turn start itself, so clients read no append lag. - options: { turnScope: AGENT_JOURNAL_THREAD_SCOPE, lifecycle: true, observedAt: event.at } + settle: { + what: 'turn-superseded', + resolve: (journal) => + providerTimelineSettlement(journal, { turnItemId: open.itemId }, [open], { + state: 'interrupted', + completedAt: event.at, + outcome: 'superseded' + }) + } } - : null, + : {}), + writes: [ + { + reservedBytes: TURN_ROW_RESERVED_BYTES, + lifecycle: true, + // The running row's ts is the turn start itself, so clients read no append lag. + options: { turnScope: AGENT_JOURNAL_THREAD_SCOPE, lifecycle: true, observedAt: event.at }, + // Only where no row is: a turn the journal already holds is never written back to running. + resolve: (at) => (at.itemBody(turn.itemId) === null ? write : null) + } + ], commit: (next) => { if (next.open) { next.endTurn(next.open) } if (pending) { - next.inputs = next.inputs.filter( - (input) => input.clientMessageId !== pending.clientMessageId - ) + next.inputs = next.inputs.filter((each) => each !== pending) } next.open = { ...turn, running } } @@ -96,69 +116,103 @@ export function decideTurnEnd( ): ProviderTimelineDecision { const { state } = input const turn = - event.turn === undefined - ? state.open - : input.context.joins.turn(providerKey(event.turn), state.namespace) + event.turn === undefined ? state.open : input.context.rows.turn(providerKey(event.turn)) if (!turn) { return { dropped: 'no-turn' } } - if (state.status(turn, input.journal) !== 'running') { + // Only the open turn ends here; any other is already over (superseded, or ended by its writer). + if (state.open?.itemId !== turn.itemId) { return { dropped: 'turn-unknown' } } - const settle = settlementOf(input, turn, { - state: event.state, - completedAt: event.at, - ...(event.outcome !== undefined ? { outcome: event.outcome } : {}), - ...(event.durationMs !== undefined ? { durationMs: event.durationMs } : {}) - }) - return { ...(settle ? { settle } : {}), commit: (next) => next.endTurn(turn) } + return { + settle: { + what: 'turn-end', + resolve: (journal) => + providerTimelineSettlement(journal, { turnItemId: turn.itemId }, [turn], { + state: event.state, + completedAt: event.at, + ...(event.outcome !== undefined ? { outcome: event.outcome } : {}), + ...(event.durationMs !== undefined ? { durationMs: event.durationMs } : {}) + }) + }, + commit: (next) => next.endTurn(turn) + } } export function decideInput( input: ProviderTimelineDecisionInput, event: Extract ): ProviderTimelineDecision { - const { state, journal, context } = input - const named = event.join?.turn - const placement = providerTimelinePlacement(context, state, event.join) - if (input.execute && journal && placement.turn && event.join?.item !== undefined) { - context.joins.reserveEcho( - { family: 'item', key: providerKey(event.join.item), thread: event.join.thread ?? null }, - placement, - journal - ) + return decideOpener( + input, + { + userItemId: agentJournalSubmissionKey(event.clientMessageId), + requestedAt: event.requestedAt + }, + event.join?.turn + ) +} + +/** A saved user message of an adopted session: its own row, only where none is, and the opener of + * the turn it names. */ +export function decideHistory( + input: ProviderTimelineDecisionInput, + event: Extract +): ProviderTimelineDecision { + const { context, state } = input + const row = context.rows.item('item', providerKey(event.item), event.join.thread ?? null) + const write: ProviderTimelineResolvedWrite = { identity: row.identity, body: event.body } + const opener = decideOpener(input, { userItemId: row.itemId }, event.join.turn) + return { + ...opener, + writes: [ + { + reservedBytes: estimateStructuredAgentSessionItemBytes(row.identity, event.body), + lifecycle: false, + options: { turnScope: providerTimelinePlacement(context, state, event.join) }, + resolve: (at) => (at.itemBody(row.itemId) === null ? write : null) + }, + ...(opener.writes ?? []) + ] } +} + +/** A user message names the turn it opened: the one it names once that one opens, else the open + * turn while that still names no message of its own, else the next to open. */ +function decideOpener( + input: ProviderTimelineDecisionInput, + message: Omit, + named: string | undefined +): ProviderTimelineDecision { + const { state, journal, context } = input const open = state.open - const namedTurn = - named === undefined ? null : context.joins.turn(providerKey(named), state.namespace) - const status = namedTurn ? state.status(namedTurn, journal) : null - // A late echo of a turn already over names nothing. - if (status === 'settled' || (status === 'running' && namedTurn?.itemId !== open?.itemId)) { - return {} - } - const pending = { - clientMessageId: event.clientMessageId, - requestedAt: event.requestedAt, - ...(namedTurn && status === 'absent' ? { turnItemId: namedTurn.itemId } : {}) - } - // No turn open, or the send names one still to open: it waits for that turn. - if (!open || pending.turnItemId !== undefined) { - return { - commit: (next) => next.wait(pending) + const turn = named === undefined ? null : context.rows.turn(providerKey(named)) + if (turn && turn.itemId !== open?.itemId) { + // A late echo of a turn already over names nothing. + if (journal && providerTimelineTurnRowState(journal, turn.itemId) !== 'absent') { + return {} } + return { commit: (next) => next.wait({ ...message, turnItemId: turn.itemId }) } + } + if (!open) { + return { commit: (next) => next.wait(message) } } - // Only a turn still naming its fallback opener takes the send; the journal's row says, once there. const opener = - readAgentJournalTurn(journal?.itemBody(open.itemId) ?? undefined)?.userItemId ?? + (journal && readAgentJournalTurn(journal.itemBody(open.itemId) ?? undefined)?.userItemId) ?? open.running.userItemId - if (opener !== context.joins.turnOpener(open)) { + if (opener !== open.itemId) { return {} } - const userItemId = agentJournalSubmissionKey(event.clientMessageId) - const running = { ...open.running, userItemId, requestedAt: event.requestedAt } + const running = { ...open.running, ...message } return { - write: - input.execute && journal ? reviseOpener(journal, open, userItemId, event.requestedAt) : null, + writes: [ + { + reservedBytes: TURN_ROW_RESERVED_BYTES, + lifecycle: true, + options: { turnScope: AGENT_JOURNAL_THREAD_SCOPE, lifecycle: true }, + resolve: (at) => reviseOpener(at, open, message) + } + ], commit: (next) => { if (next.open?.itemId === open.itemId) { next.open = { ...next.open, running } @@ -167,51 +221,46 @@ export function decideInput( } } -/** Only the opener fields change; the rest are the row's as the journal holds it. */ -export function reviseOpener( +/** Only while the row runs and still names the turn itself as its opener: an opener another writer + * gave it stands. Every other field is the row's as the journal holds it. */ +function reviseOpener( journal: StructuredAgentSessionTransitionJournal, - open: ProviderTimelineTurnRef, - userItemId: string, - requestedAt: number + open: ProviderTimelineOpenTurn, + message: Omit ): ProviderTimelineResolvedWrite | null { const row = readAgentJournalTurn(journal.itemBody(open.itemId) ?? undefined) - if (row?.state !== 'running') { + if (row?.state !== 'running' || row.userItemId !== open.itemId) { return null } const target = { identity: open.identity } const write = { - lifecycle: agentJournalTurnBody({ ...row, userItemId, requestedAt }), + lifecycle: agentJournalTurnBody({ ...row, ...message }), onlyWhileRunning: true as const } - const resolved = resolveAgentJournalTurnRowWrite( + return resolveAgentJournalTurnRowWrite( journal, target, write, agentJournalTurnRowReservedBytes(target, write) ) - return ( - resolved && { ...resolved, options: { turnScope: AGENT_JOURNAL_THREAD_SCOPE, lifecycle: true } } - ) } export function decideSessionEnd( - input: ProviderTimelineDecisionInput, - end: ProviderTimelineTurnEnd, - commit: (state: ProviderTimelineState) => void + _input: ProviderTimelineDecisionInput, + event: Extract ): ProviderTimelineDecision { - const { journal } = input return { - ...(input.execute && journal - ? { - settle: providerTimelineSettlement( - journal, - 'session', - runningProviderTimelineTurns(journal), - end - ) - } - : {}), - commit + settle: { + what: 'session-end', + resolve: (journal) => + providerTimelineSettlement( + journal, + 'session', + runningProviderTimelineTurns(journal), + event.verdict + ) + }, + commit: (next) => next.endSession() } } @@ -219,24 +268,23 @@ export function decideContextUsage( input: ProviderTimelineDecisionInput, event: Extract ): ProviderTimelineDecision { - const { state, journal } = input + const { state } = input const named = event.join?.turn - const turn = - named === undefined - ? (state.open ?? state.latest) - : input.context.joins.turn(providerKey(named), state.namespace) - if (!input.execute || !journal) { - return {} - } + const turn: ProviderTimelineTurnRef | null = + named === undefined ? (state.open ?? state.latest) : input.context.rows.turn(providerKey(named)) const target = turn ? { identity: turn.identity } : ({ newest: true } as const) const write = { contextUsage: event.usage } - const resolved = resolveAgentJournalTurnRowWrite( - journal, - target, - write, - agentJournalTurnRowReservedBytes(target, write) - ) - return { - write: resolved && { ...resolved, options: { turnScope: AGENT_JOURNAL_THREAD_SCOPE } } + const usage: ProviderTimelineItemWrite = { + reservedBytes: agentJournalTurnRowReservedBytes(target, write), + lifecycle: true, + options: { turnScope: AGENT_JOURNAL_THREAD_SCOPE }, + resolve: (journal) => + resolveAgentJournalTurnRowWrite( + journal, + target, + write, + agentJournalTurnRowReservedBytes(target, write) + ) } + return { writes: [usage] } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts index f0372c8e58de..833cb89424e9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts @@ -8,13 +8,12 @@ import { STALE_SESSION_ROW_PREFIX } from '../../../shared/agent-session-stop-row import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' import { AGENT_JOURNAL_THREAD_SCOPE, - type AgentJournalItemBody, type AgentJournalRenderItem } from '../../../shared/agent-session-journal-types' import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' import { partitionJournalLifecycleMutations } from '../agent-session-journal/journal-lifecycle-batch-partition' import type { JournalLifecycleMutationInput } from '../agent-session-journal/journal-row-builders' -import { cancelledJournalPromptBody } from '../agent-session-journal/journal-prompt-body-bounds' +import { terminalAgentJournalBody } from '../agent-session-journal/journal-terminal-settlement' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { agentSessionFailureWords, @@ -176,7 +175,7 @@ export async function settleStructuredAgentSessionDeadGeneration(input: { } for (const item of items) { const identity = parseAgentJournalItemKey(item.itemId) - const body = terminalDeadGenerationBody(item) + const body = terminalAgentJournalBody(item.body) if (identity && body) { mutations.push({ kind: 'item', @@ -234,7 +233,7 @@ export async function settleStaleStructuredAgentSessionState(input: { const mutations: JournalLifecycleMutationInput[] = [] for (const item of items) { const identity = parseAgentJournalItemKey(item.itemId) - const body = terminalDeadGenerationBody(item) + const body = terminalAgentJournalBody(item.body) if (identity && body) { mutations.push({ kind: 'item', @@ -288,20 +287,10 @@ export async function settleStaleStructuredAgentSessionState(input: { return mutations.length } -function terminalDeadGenerationBody(item: AgentJournalRenderItem): AgentJournalItemBody | null { - if (item.body.kind === 'tool-call' && item.body.state === 'running') { - return { ...item.body, state: 'failed' } - } - if (item.body.kind === 'approval' || item.body.kind === 'question') { - return item.body.resolution.state === 'pending' ? cancelledJournalPromptBody(item.body) : null - } - return null -} - function isUnfinishedItem(item: AgentJournalRenderItem): boolean { return ( readAgentJournalTurn(item.body)?.state === 'running' || - terminalDeadGenerationBody(item) !== null + terminalAgentJournalBody(item.body) !== null ) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-transition.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-transition.ts index a185695d5ea5..3e2f8449e0d4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-transition.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-transition.ts @@ -32,10 +32,8 @@ export type StructuredAgentSessionTransitionJournal = Pick< export type StructuredAgentSessionTransitionStep = | { kind: 'item' - /** Bounds what `resolve` may write; a larger write fails the sink, unless `paced`. */ + /** Bounds what `resolve` may write; a larger write fails the sink. */ reservedBytes: number - /** The size only paces the queue: the row grows by text the fold holds (a resumed message). */ - paced?: true /** The row and its whole body; null writes nothing. Resolved `options` replace the planned * ones, for a writer that learns the row's turn or producer only from the fold. */ resolve: (journal: StructuredAgentSessionTransitionJournal) => { @@ -105,9 +103,8 @@ function transitionAppend( return null } if ( - !step.paced && estimateStructuredAgentSessionItemBytes(resolved.identity, resolved.body) > - step.reservedBytes + step.reservedBytes ) { throw new Error(STEP_OVERFLOW) } From 9c58983e39922358fca2565c180f6866b4991495 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 00:37:14 -0700 Subject: [PATCH 41/62] Answer every agent request after an ACP cancel A cancel that lands before a permission handler starts now still runs the permission path, so the agent gets the `cancelled` outcome rather than a request-cancelled error. A handler that ignores the abort no longer leaves the agent waiting: once the abort has run through, any request still unanswered gets request-cancelled. Handlers that answer on abort keep their own reply. Also renames a lint-rejected helper parameter, replaces a Reflect.apply in a test, and stops the permission diagnostic from firing with an empty list. --- src/main/acp/acp-incoming-requests.ts | 27 +++++++--- src/main/acp/acp-json-rpc-peer.ts | 3 +- src/main/acp/acp-permission-requests.test.ts | 54 +++++++++++++++++--- src/main/acp/acp-permission-requests.ts | 11 ++-- src/main/acp/acp-session-lifecycle.test.ts | 23 ++++++--- src/main/acp/acp-session-runtime.ts | 2 + 6 files changed, 94 insertions(+), 26 deletions(-) diff --git a/src/main/acp/acp-incoming-requests.ts b/src/main/acp/acp-incoming-requests.ts index 92e50219e515..6b8d21124eb2 100644 --- a/src/main/acp/acp-incoming-requests.ts +++ b/src/main/acp/acp-incoming-requests.ts @@ -28,13 +28,25 @@ export class AcpIncomingRequests { this.open.clear() } - // The handler still answers (a permission answers `cancelled`); -32800 only if it rejects. + // Each handler answers on abort (a permission answers `cancelled`); -32800 if it rejects, or if + // it is still silent once the abort has run through (the next event-loop turn). cancel(): void { - for (const request of this.open.values()) { - if (!request.cancelled) { - request.cancelled = true - request.controller.abort(new AcpRpcError(-32800, 'Request cancelled')) - } + const cancelled = [...this.open].filter(([, request]) => !request.cancelled) + for (const [, request] of cancelled) { + request.cancelled = true + request.controller.abort(new AcpRpcError(-32800, 'Request cancelled')) + } + if (cancelled.length > 0) { + setImmediate(() => { + for (const [id, request] of cancelled) { + if (this.open.get(id) === request) { + this.open.delete(id) + request.closed = true + request.abandon() + void this.sendError(id, new AcpRpcError(-32800, 'Request cancelled')) + } + } + }) } } @@ -62,7 +74,8 @@ export class AcpIncomingRequests { void Promise.race([ abandoned, Promise.resolve().then(() => { - if (controller.signal.aborted) { + // A cancelled request still reaches its handler, so a permission can answer `cancelled`. + if (request.closed) { throw controller.signal.reason } if (!this.handler) { diff --git a/src/main/acp/acp-json-rpc-peer.ts b/src/main/acp/acp-json-rpc-peer.ts index 2a2cb867b08c..0cf0b1fab377 100644 --- a/src/main/acp/acp-json-rpc-peer.ts +++ b/src/main/acp/acp-json-rpc-peer.ts @@ -33,7 +33,8 @@ const envelopeSchema = z.looseObject({ export type AcpJsonRpcMessage = z.infer export type AcpRequestContext = { id: string | number | null; signal: AbortSignal } export type AcpPeerHandlers = { - // Void means handled; unsupported methods must throw AcpRpcError(-32601). + // Void means handled; unsupported methods must throw AcpRpcError(-32601). On a cancel abort, + // answer (e.g. the protocol's own cancelled reply) or throw at once: silence is answered -32800. onRequest?: (method: string, params: unknown, context: AcpRequestContext) => unknown onNotification?: (method: string, params: unknown) => void onDiagnostic?: (message: string) => void diff --git a/src/main/acp/acp-permission-requests.test.ts b/src/main/acp/acp-permission-requests.test.ts index f51a7429853c..d2f15611725b 100644 --- a/src/main/acp/acp-permission-requests.test.ts +++ b/src/main/acp/acp-permission-requests.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { AcpSessionRuntime, type AcpSessionRuntimeOptions } from './acp-session-runtime' import { AcpScriptedAgent, deferred, tick } from './acp-scripted-agent.test-support' +import type { AcpPermissionHandler } from './acp-permission-requests' import type { RequestPermissionRequest } from './generated/acp-protocol.generated' const opened: { close: () => void }[] = [] @@ -66,11 +67,11 @@ describe('ACP permission requests', () => { options: [{ optionId: 'allow', name: 'allow', kind: 'allow_once' }] }) expect(diagnostics).toEqual([ - 'Delivered ACP permission request without unreadable fields: options.1, toolCall.content' + 'Delivered ACP permission request without unreadable fields: options.0.name, options.1, toolCall.content' ]) }) - it.each([ + it.each<[string, AcpPermissionHandler, string]>([ [ 'the handler throws', () => { @@ -78,12 +79,14 @@ describe('ACP permission requests', () => { }, 'handler failed: Error: renderer gone' ], - ['the handler answers nonsense', () => ({ outcome: 'yes' }), 'invalid handler response'] + // JSON.parse yields an untyped value, so a deliberately malformed reply needs no assertion. + [ + 'the handler answers nonsense', + () => JSON.parse('{"outcome":"yes"}'), + 'invalid handler response' + ] ])('answers cancelled with a diagnostic when %s', async (_label, onPermission, problem) => { - // Reflect.apply returns any, so a deliberately ill-typed answer needs no type assertion. - const handler: AcpSessionRuntimeOptions['onPermission'] = (request, context) => - Reflect.apply(onPermission, undefined, [request, context]) - const { agent, runtime, diagnostics } = fixture({ onPermission: handler }) + const { agent, runtime, diagnostics } = fixture({ onPermission }) await runtime.start(startOptions) void runtime.prompt([{ type: 'text', text: 'hi' }]).catch(() => {}) expect( @@ -96,6 +99,43 @@ describe('ACP permission requests', () => { expect(diagnostics).toEqual([`Answered ACP permission request cancelled: ${problem}`]) }) + it('answers cancelled when the cancel lands before the permission handler starts', async () => { + const asked = vi.fn((_request: RequestPermissionRequest) => new Promise(() => {})) + const { agent, runtime } = fixture({ onPermission: asked }) + await runtime.start(startOptions) + runtime.subscribe(() => void runtime.cancel()) + const answer = new Promise((resolve) => { + agent.stdin.on('data', (chunk: string) => { + if (chunk.includes('"id":5')) { + resolve(JSON.parse(chunk)) + } + }) + }) + // One chunk: the permission, then an update whose listener cancels before the handler runs. + agent.stdout.write( + [ + { + jsonrpc: '2.0', + id: 5, + method: 'session/request_permission', + params: { sessionId: 'session-1', toolCall, options: [allow] } + }, + { + jsonrpc: '2.0', + method: 'session/update', + params: { + sessionId: 'session-1', + update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'x' } } + } + } + ] + .map((frame) => `${JSON.stringify(frame)}\n`) + .join('') + ) + expect(await answer).toMatchObject({ id: 5, result: { outcome: { outcome: 'cancelled' } } }) + expect(asked).not.toHaveBeenCalled() + }) + it('lets a turn the agent began itself ask for permission and cancels it with session/cancel', async () => { const asked = deferred() const { agent, runtime } = fixture({ diff --git a/src/main/acp/acp-permission-requests.ts b/src/main/acp/acp-permission-requests.ts index c39f4a4c9022..4c4aa0b8f025 100644 --- a/src/main/acp/acp-permission-requests.ts +++ b/src/main/acp/acp-permission-requests.ts @@ -24,14 +24,14 @@ const optionRoutingSchema = z.looseObject({ optionId: z.string() }) // Keeps every field the schema does not know or can read; drops (and names) the unreadable ones. function readableFields( - shape: Record, + knownFields: Record, value: Record, path: string, dropped: string[] ): Record { const kept: Record = {} for (const [key, field] of Object.entries(value)) { - if (!Object.hasOwn(shape, key) || shape[key].safeParse(field).success) { + if (!Object.hasOwn(knownFields, key) || knownFields[key].safeParse(field).success) { kept[key] = field } else { dropped.push(`${path}${key}`) @@ -61,6 +61,9 @@ export function readAcpPermissionRequest( dropped.push(`options.${index}`) return [] } + if (typeof ids.data.name !== 'string') { + dropped.push(`options.${index}.name`) + } const named = { ...ids.data, name: typeof ids.data.name === 'string' ? ids.data.name : ids.data.optionId @@ -83,7 +86,9 @@ export function readAcpPermissionRequest( if (!request.success) { return null } - diagnose(`Delivered ACP permission request without unreadable fields: ${dropped.join(', ')}`) + if (dropped.length > 0) { + diagnose(`Delivered ACP permission request without unreadable fields: ${dropped.join(', ')}`) + } return request.data } diff --git a/src/main/acp/acp-session-lifecycle.test.ts b/src/main/acp/acp-session-lifecycle.test.ts index 092ed11e186c..ec36afe471d5 100644 --- a/src/main/acp/acp-session-lifecycle.test.ts +++ b/src/main/acp/acp-session-lifecycle.test.ts @@ -247,16 +247,19 @@ describe('ACP caller-owned waits', () => { await expect(authenticated).resolves.toEqual({}) }) - it('lets cancel abort vendor hooks so each sends its own answer, or request-cancelled if it rejects', async () => { + it('lets cancel abort vendor hooks so each sends its own answer, else request-cancelled', async () => { const { agent, runtime } = fixture({ onRequest: (method, _params, context) => - new Promise((resolve, reject) => - context.signal.addEventListener('abort', () => - method === '_vendor/plan' - ? resolve({ outcome: 'abandoned' }) - : reject(new Error('stop')) - ) - ) + new Promise((resolve, reject) => { + // '_vendor/silent' ignores the abort: the runtime still answers the agent. + if (method !== '_vendor/silent') { + context.signal.addEventListener('abort', () => + method === '_vendor/plan' + ? resolve({ outcome: 'abandoned' }) + : reject(new Error('stop')) + ) + } + }) }) agent.on('session/prompt', (frame) => agent.on('session/cancel', () => agent.reply(frame, { stopReason: 'cancelled' })) @@ -265,11 +268,15 @@ describe('ACP caller-owned waits', () => { const pending = runtime.prompt([...prompt]) const question = agent.request('question', '_vendor/question', {}) const plan = agent.request('plan', '_vendor/plan', {}) + const silent = agent.request('silent', '_vendor/silent', {}) await tick() await runtime.cancel() expect(await question).toMatchObject({ error: { code: -32800 } }) expect(await plan).toMatchObject({ result: { outcome: 'abandoned' } }) + expect(await silent).toMatchObject({ error: { code: -32800 } }) await pending + await tick() + expect(agent.frames.filter((frame) => frame.id === 'plan')).toHaveLength(1) }) it('settles requests when the process owner closes on exit even if stdout stays open', async () => { diff --git a/src/main/acp/acp-session-runtime.ts b/src/main/acp/acp-session-runtime.ts index 7d185c108c4d..718fd2c716e4 100644 --- a/src/main/acp/acp-session-runtime.ts +++ b/src/main/acp/acp-session-runtime.ts @@ -52,6 +52,8 @@ export type AcpSessionRuntimeOptions = { peer?: AcpPeerOptions cancelTimeoutMs?: number onPermission?: AcpPermissionHandler + /** Agent requests other than permissions. When `context.signal` aborts on cancel, answer with + * the agent's own cancelled reply or throw right away; a handler still silent gets -32800. */ onRequest?: (method: string, params: unknown, context: AcpRequestContext) => unknown /** Agent notifications other than `session/update` (protocol extensions), delivered * synchronously in arrival order with the `subscribe` events. */ From 6e9d0c0ff7b1fa58ef5b8722943e91114ae0d854 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 00:37:30 -0700 Subject: [PATCH 42/62] Cover new running work in a turn the sweep ended --- .../provider-timeline-assembler-restart.test.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-restart.test.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-restart.test.ts index 5b4033e3ff6d..9772b13fc128 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-restart.test.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-restart.test.ts @@ -124,6 +124,12 @@ describe('a resumed session', () => { }) const late = { type: 'item.update', item: 'call-a', body: runningTool('read') } as const assembler.apply({ ...late, join: { turn: 'old' } }) + assembler.apply({ + type: 'item.open', + item: 'call-b', + body: runningTool('grep'), + join: { turn: 'old' } + }) assembler.apply({ type: 'turn.open', turn: 'new', at: 3_000 }) assembler.apply({ type: 'request.open', request: '0', body: pendingApproval }) // The sweep for the dead child lands before the drain. @@ -141,6 +147,8 @@ describe('a resumed session', () => { expect((await rig.row(providerItemId('item', 'call-a')))?.body).toMatchObject({ state: 'failed' }) + // Nor does new running work land in a turn the sweep ended: nothing would ever settle it. + expect(await rig.row(providerItemId('item', 'call-b'))).toBeUndefined() expect((await rig.row(providerItemId('request', '0')))?.body).toMatchObject({ resolution: { state: 'cancelled' } }) From fc8541f5dd1f1cd1fd293e7085413d0097747ef4 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 00:49:41 -0700 Subject: [PATCH 43/62] Run a transition's steps in one queued write that loops over them The steps of one event now share one turn in the journal's write queue: a loop writes each in its own transaction through the row writer's synchronous writeRows (split out of enqueueRows) and stops at the first throw. Prefix semantics and "nothing lands between the steps" now hold by construction, so the completed-write counter on the queue, the step gate and the allSettled barrier are gone; the queue is back to main's bytes. --- .../journal-lifecycle-batch-appender.ts | 38 ++--- .../journal-row-writer.ts | 55 ++++---- .../journal-step-writer.ts | 58 ++++++++ .../journal-store-collaborators.ts | 17 ++- .../agent-session-journal/journal-store.ts | 14 +- .../journal-write-queue.ts | 16 +-- ...tructured-agent-session-transition.test.ts | 132 +++++++++++++++++- .../structured-agent-session-transition.ts | 94 ++++--------- 8 files changed, 274 insertions(+), 150 deletions(-) create mode 100644 src/main/native-chat/agent-session-journal/journal-step-writer.ts diff --git a/src/main/native-chat/agent-session-journal/journal-lifecycle-batch-appender.ts b/src/main/native-chat/agent-session-journal/journal-lifecycle-batch-appender.ts index cc300a7b7497..3cac8d03c211 100644 --- a/src/main/native-chat/agent-session-journal/journal-lifecycle-batch-appender.ts +++ b/src/main/native-chat/agent-session-journal/journal-lifecycle-batch-appender.ts @@ -78,30 +78,20 @@ export class JournalLifecycleBatchAppender { }) } - /** Chooses the mutations at its turn in the queue; one too large for a row becomes consecutive - * rows, committed in one transaction, in the order resolved. */ - appendResolved(input: JournalResolvedLifecycleBatchInput): Promise { - return this.deps - .enqueueRows(() => { - const mutations = input.resolve() - // Every chunk is built before any commits, so a second chunk naming the same item would - // reuse the first chunk's revision. - this.assertDistinctItems(mutations) - return partitionJournalLifecycleMutations(input.settlementId, mutations) - .filter((chunk) => !this.wasApplied(chunk.settlementId)) - .map((chunk) => - journalLifecycleBatchRowBuilder( - this.deps.state, - chunk.settlementId, - chunk.mutations, - input - ) - ) - }) - .then((rows) => { - const last = rows.at(-1) - return last ? { epoch: last.epoch, sequence: last.seq } : null - }) + /** The rows a resolved settlement writes, planned at its own turn in the queue: its mutations, + * chosen then, in as many consecutive rows as they need, minus any already applied. */ + planResolved( + input: JournalResolvedLifecycleBatchInput + ): ((seq: number, ts: number) => JournalRow)[] { + const mutations = input.resolve() + // Every chunk is built before any commits, so a second chunk naming the same item would + // reuse the first chunk's revision. + this.assertDistinctItems(mutations) + return partitionJournalLifecycleMutations(input.settlementId, mutations) + .filter((chunk) => !this.wasApplied(chunk.settlementId)) + .map((chunk) => + journalLifecycleBatchRowBuilder(this.deps.state, chunk.settlementId, chunk.mutations, input) + ) } private assertDistinctItems(mutations: readonly JournalLifecycleMutationInput[]): void { diff --git a/src/main/native-chat/agent-session-journal/journal-row-writer.ts b/src/main/native-chat/agent-session-journal/journal-row-writer.ts index dde1a66741d1..60db6e0cbd1e 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-writer.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-writer.ts @@ -76,33 +76,36 @@ export class JournalRowWriter { enqueueRows( plan: () => readonly ((seq: number, ts: number) => JournalRow)[] ): Promise { - return this.deps.serialize(() => { - assertJournalWritable(this.deps.readOnly(), this.deps.sessionId) - const first = this.deps.nextSequence() - const ts = this.deps.now() - const rows = plan().map((build, index) => build(first + index, ts)) - if (rows.length === 0) { - return rows - } - for (const row of rows) { - assertJournalFence(row.fence, this.deps.highestFence()) - } - try { - this.deps.database().transaction((db) => { - for (const row of rows) { - insertJournalRow(db, this.deps.sessionId, row) - this.runBookkeeping(db, row) - } - }) - } catch (error) { - this.deps.rolledBack?.() - throw error - } - for (const row of rows) { - this.deps.commit(row) - } + return this.deps.serialize(() => this.writeRows(plan)) + } + + /** `enqueueRows`' write, for a caller already running at its own turn in the queue. */ + writeRows(plan: () => readonly ((seq: number, ts: number) => JournalRow)[]): JournalRow[] { + assertJournalWritable(this.deps.readOnly(), this.deps.sessionId) + const first = this.deps.nextSequence() + const ts = this.deps.now() + const rows = plan().map((build, index) => build(first + index, ts)) + if (rows.length === 0) { return rows - }) + } + for (const row of rows) { + assertJournalFence(row.fence, this.deps.highestFence()) + } + try { + this.deps.database().transaction((db) => { + for (const row of rows) { + insertJournalRow(db, this.deps.sessionId, row) + this.runBookkeeping(db, row) + } + }) + } catch (error) { + this.deps.rolledBack?.() + throw error + } + for (const row of rows) { + this.deps.commit(row) + } + return rows } /** Assign the next sequence, make the row durable, and fold it through the SAME reducer diff --git a/src/main/native-chat/agent-session-journal/journal-step-writer.ts b/src/main/native-chat/agent-session-journal/journal-step-writer.ts new file mode 100644 index 000000000000..c0b62ce50c4c --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-step-writer.ts @@ -0,0 +1,58 @@ +// Several writes run as ONE turn in the chat's write queue, one after another. +// +// Each step is planned from the fold with every step before it landed, and commits in its own +// transaction. The steps share one queue body, so no other write lands between them, and the +// first step that throws ends the run: the steps before it stay written and none after it runs. + +import type { + JournalItemAppendOptions, + JournalResolvedLifecycleBatchInput +} from './journal-store-contracts' +import type { JournalResolvedItem } from './journal-item-appender' +import type { JournalLifecycleBatchAppender } from './journal-lifecycle-batch-appender' +import type { JournalReducerState } from './journal-reducer' +import { journalItemRowBuilder } from './journal-row-builders' +import type { JournalRow } from './journal-row-schema' +import type { JournalRowWriter } from './journal-row-writer' +import type { JournalWriteBody } from './journal-write-queue' + +export type JournalStep = + | { + kind: 'item' + /** Read at the step's turn; null writes nothing. */ + resolve: () => JournalResolvedItem | null + options: JournalItemAppendOptions + } + | { kind: 'settlement'; batch: JournalResolvedLifecycleBatchInput } + +export class JournalStepWriter { + constructor( + private readonly deps: { + serialize: (run: JournalWriteBody) => Promise + state: () => JournalReducerState + writeRows: JournalRowWriter['writeRows'] + planSettlement: JournalLifecycleBatchAppender['planResolved'] + } + ) {} + + /** Whether each step wrote; rejects with the error of the step that threw. */ + append(steps: readonly JournalStep[]): Promise { + return this.deps.serialize(() => { + const wrote: boolean[] = [] + for (const step of steps) { + wrote.push(this.deps.writeRows(() => this.plan(step)).length > 0) + } + return wrote + }) + } + + private plan(step: JournalStep): ((seq: number, ts: number) => JournalRow)[] { + if (step.kind === 'settlement') { + return this.deps.planSettlement(step.batch) + } + const resolved = step.resolve() + return resolved === null + ? [] + : [journalItemRowBuilder(this.deps.state, resolved.identity, resolved.body, step.options)] + } +} diff --git a/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts b/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts index ba8796964692..e5f521e49aea 100644 --- a/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts +++ b/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts @@ -17,6 +17,7 @@ import { JournalStopMarks } from './journal-stop-marks' import { journalQueuePauseRestatement } from './queued-message-pause' import type { JournalReducerState } from './journal-reducer' import { JournalRowWriter } from './journal-row-writer' +import { JournalStepWriter } from './journal-step-writer' import { restoreJournalStore } from './journal-store-restore' import type { JournalRow } from './journal-row-schema' import type { AgentSessionJournal } from './journal-store' @@ -57,6 +58,7 @@ export type JournalStoreCollaborators = { epochController: JournalEpochController itemAppender: JournalItemAppender lifecycleBatchAppender: JournalLifecycleBatchAppender + stepWriter: JournalStepWriter queuedMessages: JournalQueuedMessages stopMarks: JournalStopMarks /** Restores the store's state from disk. Owned here because it needs the same @@ -106,6 +108,12 @@ export function createJournalStoreCollaborators(host: JournalStoreHost): Journal inTransaction: (db, row) => queuedMessages.onRowInTransaction(db, row), rolledBack: () => queuedMessages.invalidate() }) + const lifecycleBatchAppender = new JournalLifecycleBatchAppender({ + state: host.state, + cursor: host.cursor, + enqueue: host.enqueue, + enqueueRows: (plan) => rowWriter.enqueueRows(plan) + }) return { epochController, queuedMessages, @@ -121,11 +129,12 @@ export function createJournalStoreCollaborators(host: JournalStoreHost): Journal state: host.state, enqueue: host.enqueue }), - lifecycleBatchAppender: new JournalLifecycleBatchAppender({ + lifecycleBatchAppender, + stepWriter: new JournalStepWriter({ + serialize: host.serialize, state: host.state, - cursor: host.cursor, - enqueue: host.enqueue, - enqueueRows: (plan) => rowWriter.enqueueRows(plan) + writeRows: (plan) => rowWriter.writeRows(plan), + planSettlement: (batch) => lifecycleBatchAppender.planResolved(batch) }) } } diff --git a/src/main/native-chat/agent-session-journal/journal-store.ts b/src/main/native-chat/agent-session-journal/journal-store.ts index 3cabb7bd53ef..3edfbf0c1a75 100644 --- a/src/main/native-chat/agent-session-journal/journal-store.ts +++ b/src/main/native-chat/agent-session-journal/journal-store.ts @@ -71,6 +71,7 @@ import { createJournalStoreCollaborators } from './journal-store-collaborators' import { journalStoreLoadedFields } from './journal-store-open' import type { JournalItemAppender } from './journal-item-appender' import type { JournalLifecycleBatchAppender } from './journal-lifecycle-batch-appender' +import type { JournalStepWriter } from './journal-step-writer' import type { JournalStopMarks } from './journal-stop-marks' export { AgentSessionJournalError } from './journal-write-guards' @@ -92,6 +93,7 @@ export class AgentSessionJournal { private readonly epochController: JournalEpochController private readonly itemAppender: JournalItemAppender private readonly lifecycleBatchAppender: JournalLifecycleBatchAppender + private readonly stepWriter: JournalStepWriter private readonly restore: () => Promise /** Draft rows queued while the agent works; never reducer input or owed work. */ readonly queuedMessages: JournalQueuedMessages @@ -143,6 +145,7 @@ export class AgentSessionJournal { this.epochController = collaborators.epochController this.itemAppender = collaborators.itemAppender this.lifecycleBatchAppender = collaborators.lifecycleBatchAppender + this.stepWriter = collaborators.stepWriter this.queuedMessages = collaborators.queuedMessages this.stopMarks = collaborators.stopMarks this.restore = collaborators.restore @@ -208,11 +211,6 @@ export class AgentSessionJournal { return this.queue.owing } - /** Write bodies that returned so far; see `JournalWriteQueue.completedWrites`. */ - get completedWrites(): number { - return this.queue.completedWrites - } - cursor = (): AgentJournalCursor => ({ epoch: this.state.epoch, sequence: this.state.lastSequence @@ -336,10 +334,8 @@ export class AgentSessionJournal { return this.lifecycleBatchAppender.append(input) } - /** A lifecycle batch whose mutations are chosen from the fold at its own turn in the queue; - * null when none are. */ - appendResolvedLifecycleBatch: JournalLifecycleBatchAppender['appendResolved'] = (input) => - this.lifecycleBatchAppender.appendResolved(input) + /** Several writes as one turn in the queue; see `JournalStepWriter`. */ + appendSteps: JournalStepWriter['append'] = (steps) => this.stepWriter.append(steps) /** * Write-ahead submission row. It is durable before the caller dispatches diff --git a/src/main/native-chat/agent-session-journal/journal-write-queue.ts b/src/main/native-chat/agent-session-journal/journal-write-queue.ts index ae4f5c96d893..74ef33472bd4 100644 --- a/src/main/native-chat/agent-session-journal/journal-write-queue.ts +++ b/src/main/native-chat/agent-session-journal/journal-write-queue.ts @@ -30,7 +30,6 @@ export class JournalWriteQueue { private closed = false /** Runs before the next write, and stays owed until it succeeds. */ private owed: (() => Promise) | null = null - private completed = 0 constructor(private readonly sessionId: string) {} @@ -42,20 +41,9 @@ export class JournalWriteQueue { if (this.closed) { return Promise.reject(this.closedError()) } - const counted = (): JournalWriteResult => { - const result = run() - this.completed++ - return result - } return this.owed !== null || this.lineBusy - ? this.join(counted, this.owed !== null) - : this.runNow(counted) - } - - /** Write bodies that returned without throwing. Bodies run one at a time, so a body that reads - * this learns synchronously whether the write just ahead of it landed. */ - get completedWrites(): number { - return this.completed + ? this.join(run, this.owed !== null) + : this.runNow(run) } /** Runs `read` after every write admitted before it, whatever each one's outcome, and ahead of any diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-transition.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-transition.test.ts index b4232642dee0..6698b4cef5ec 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-transition.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-transition.test.ts @@ -13,6 +13,7 @@ import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import type { JournalLifecycleMutationInput } from '../agent-session-journal/journal-row-builders' +import { openJournalOwingImport } from '../agent-session-journal/journal-owed-import-test-support' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { createAgentSessionDeltaCoalescer } from './agent-session-delta-coalescer' import { @@ -86,6 +87,36 @@ async function rig(watermarks: Partial = { return { root, journal, deferred, sink: deferred.sink, publishes, bind } } +/** A bound sink over a chat whose copy into the host's database is still owed, so every write, + * the transition's included, waits in the journal's queue until the first one pays it. */ +async function owedRig() { + const root = await mkdtemp(join(tmpdir(), 'orca-transition-owed-')) + roots.push(root) + const { journal } = await openJournalOwingImport({ + stateDirectory: root, + identity: { + sessionId: SESSION, + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'grok', + providerHandle: { kind: 'opaque', agent: 'grok', value: 'provider-session-1' } + }, + now: () => 1_000 + }) + const failures: unknown[] = [] + const deferred = createDeferredStructuredAgentSessionEventSink({ + ...testEventSinkLogging(SESSION), + onFailed: (error) => failures.push(error) + }) + deferred.bind({ journal, fence: 1, publish: () => undefined }) + const ownKeys = () => + journal + .snapshot() + .items.map((item) => item.itemId) + .filter((itemId) => itemId.includes(SESSION)) + return { journal, deferred, sink: deferred.sink, failures, ownKeys, close: () => journal.close() } +} + describe('structured agent-session transitions', () => { it('lands its steps back to back, each resolved after the one before it', async () => { const { journal, deferred, sink, publishes, bind } = await rig() @@ -354,6 +385,92 @@ BEGIN SELECT RAISE(ABORT, 'second chunk refused'); END`) expect(journal.snapshot().items).toEqual([]) expect(publishes).toEqual([]) }) + + it('lands a write a step issues while it runs after the whole transition', async () => { + const { journal, deferred, sink, bind } = await rig() + bind() + sink.tryAppendTransition?.({ + lifecycle: false, + publish: false, + steps: [ + itemStep(() => { + // Another writer, reached from inside the step: it waits for the transition's turn to end. + sink.tryAppendItem?.(identity('nested'), tool('nested', 'running'), { + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + return { identity: identity('first'), body: tool('first', 'running') } + }), + itemStep(() => ({ identity: identity('second'), body: tool('second', 'running') })) + ] + }) + await deferred.drained() + + expect(journal.snapshot().items.map((item) => item.itemId)).toEqual( + ['first', 'second', 'nested'].map((recordId) => agentJournalItemKey(identity(recordId))) + ) + }) + + it('runs its steps back to back after an owed import, ahead of a write issued after it', async () => { + const { journal, deferred, sink, ownKeys, close } = await owedRig() + try { + sink.tryAppendTransition?.({ + lifecycle: false, + publish: false, + steps: [ + itemStep(() => ({ identity: identity('first'), body: tool('read', 'running') })), + itemStep((view) => + view.itemBody(agentJournalItemKey(identity('first'))) + ? { identity: identity('second'), body: tool('read', 'running') } + : null + ) + ] + }) + sink.tryAppendItem?.(identity('later'), tool('later', 'running'), { + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + expect(journal.importPending).toBe(true) + await expect(deferred.drained()).resolves.toEqual({ ok: true }) + + expect(journal.importPending).toBe(false) + expect(ownKeys()).toEqual( + ['first', 'second', 'later'].map((recordId) => agentJournalItemKey(identity(recordId))) + ) + } finally { + await close() + } + }) + + it('stops at a failed middle step after an owed import; a write accepted before the failure still lands', async () => { + const { deferred, sink, failures, ownKeys, close } = await owedRig() + try { + const third = vi.fn(() => ({ identity: identity('third'), body: tool('read', 'running') })) + sink.tryAppendTransition?.({ + lifecycle: false, + publish: false, + steps: [ + itemStep(() => ({ identity: identity('first'), body: tool('read', 'running') })), + itemStep( + () => ({ identity: identity('second'), body: tool('x'.repeat(10_000), 'running') }), + 64 + ), + itemStep(third) + ] + }) + // Accepted before the failure is known: the sink's existing rule lets it land. + sink.tryAppendItem?.(identity('later'), tool('later', 'running'), { + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + await expect(deferred.drained()).resolves.toMatchObject({ ok: false }) + + expect(ownKeys()).toEqual( + ['first', 'later'].map((recordId) => agentJournalItemKey(identity(recordId))) + ) + expect(third).not.toHaveBeenCalled() + expect(failures).toHaveLength(1) + } finally { + await close() + } + }) }) describe('resolved lifecycle batches', () => { @@ -363,11 +480,16 @@ describe('resolved lifecycle batches', () => { const twice = identity('twice') await expect( - journal.appendResolvedLifecycleBatch({ - settlementId: 'twice', - fence: 1, - resolve: () => [failedTool(identity('once')), failedTool(twice), failedTool(twice)] - }) + journal.appendSteps([ + { + kind: 'settlement', + batch: { + settlementId: 'twice', + fence: 1, + resolve: () => [failedTool(identity('once')), failedTool(twice), failedTool(twice)] + } + } + ]) ).rejects.toThrow('journal_resolved_lifecycle_batch_names_item_twice') expect(journal.cursor().sequence).toBe(before) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-transition.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-transition.ts index 57c0c7d0f5e8..f13b897f12bd 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-transition.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-transition.ts @@ -3,11 +3,10 @@ // A producer that keeps state about what it wrote must change that state only for writes the sink // took; when an event needs several rows, a refusal of the third after the first two were taken // would leave the producer and the journal disagreeing. A transition is admitted whole or not at -// all. At execution its steps are issued in the same tick, so they sit together in the journal's -// write queue, and each step resolves against the fold with every earlier write landed — the -// steps before it included — so what a step writes is decided by the journal, not by memory. -// Admitted whole, executed as a prefix: once a step fails, the steps after it write nothing, the -// steps before it stay written, and the sink fails, so nothing more is admitted after it. +// all. At execution its steps run as one turn in the journal's write queue, each resolved against +// the fold with every earlier write landed (the steps before it included), so what a step writes +// is decided by the journal, not by memory. Admitted whole, executed as a prefix: once a step +// fails, the steps after it never run, the steps before it stay written, and the sink fails. import type { AgentJournalItemBody, @@ -15,6 +14,7 @@ import type { } from '../../../shared/agent-session-journal-types' import type { JournalLifecycleMutationInput } from '../agent-session-journal/journal-row-builders' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import type { JournalStep } from '../agent-session-journal/journal-step-writer' import { estimateStructuredAgentSessionItemBytes } from './structured-agent-session-event-sink-estimate' import type { StructuredAgentSessionItemAppendOptions, @@ -90,79 +90,37 @@ function transitionAppend( lifecycle: transition.lifecycle, run: async (bound) => { const { journal, fence } = bound - const gate = createStepGate(journal) - // Issued in one tick, so no write submitted after this transition lands between its steps. - const writes = transition.steps.map((step, index) => - step.kind === 'item' - ? journal - .appendResolvedItem( - () => { - const resolved = gate.open(index) ? step.resolve(journal) : null - if (!resolved) { - gate.wroteNothing() - return null - } + const wrote = await journal.appendSteps( + transition.steps.map((step): JournalStep => + step.kind === 'item' + ? { + kind: 'item', + resolve: () => { + const resolved = step.resolve(journal) if ( + resolved && estimateStructuredAgentSessionItemBytes(resolved.identity, resolved.body) > - step.reservedBytes + step.reservedBytes ) { throw new Error(STEP_OVERFLOW) } return resolved }, - structuredAgentSessionJournalAppendOptions(fence, step.options) - ) - .then((landed) => landed !== null) - : journal - .appendResolvedLifecycleBatch({ - settlementId: step.settlementId, - resolve: () => (gate.open(index) ? step.resolve(journal) : []), - fence - }) - .then((landed) => landed !== null) - ) - // Every step, the ones a failure turned into no-ops included, has run before this settles. - const outcomes = await Promise.allSettled(writes) - const failure = outcomes.find( - (outcome): outcome is PromiseRejectedResult => outcome.status === 'rejected' + options: structuredAgentSessionJournalAppendOptions(fence, step.options) + } + : { + kind: 'settlement', + batch: { + settlementId: step.settlementId, + fence, + resolve: () => step.resolve(journal) + } + } + ) ) - if (failure) { - throw failure.reason - } - if ( - transition.publish && - outcomes.some((outcome) => outcome.status === 'fulfilled' && outcome.value) - ) { + if (transition.publish && wrote.includes(true)) { bound.publish() } } }) } - -/** Lets a step write only when every step before it landed. Steps run in queue order, so each - * step's write learns synchronously, from the journal's count of completed writes, whether the - * write handed over just ahead of it landed; a promise would say so only after it ran. */ -function createStepGate(journal: Pick): { - /** First thing step `index` does at its turn; false once an earlier step failed. */ - open: (index: number) => boolean - /** The step writes nothing, so whether its write completes says nothing about a failure. */ - wroteNothing: () => void -} { - let failed = false - let reached = 0 - let handedAt: number | null = null - return { - open: (index) => { - // A step that never reached its resolve failed before it: a refused import, a read-only journal. - if (reached !== index || (handedAt !== null && journal.completedWrites === handedAt)) { - failed = true - } - reached = index + 1 - handedAt = failed ? null : journal.completedWrites - return !failed - }, - wroteNothing: () => { - handedAt = null - } - } -} From 88490e58cf3aefd9276083912c9a0e3746779332 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 01:04:37 -0700 Subject: [PATCH 44/62] Let each ACP request handler own its answer after a cancel Removes the next-event-loop-turn fallback that answered request-cancelled for any handler still silent after a cancel. It raced answers that were still being saved (an approval mid-journal-write reached the agent as an error) and made the outcome depend on event-loop timing. The handler that owns an agent request now always sends its answer, or throws for request-cancelled; a request it never answers ends when the connection closes. A permission whose handler had not started still answers `cancelled`. --- src/main/acp/acp-incoming-requests.ts | 25 ++++-------- src/main/acp/acp-json-rpc-peer.ts | 4 +- src/main/acp/acp-session-lifecycle.test.ts | 45 ++++++++++++++++++++-- src/main/acp/acp-session-runtime.ts | 5 ++- 4 files changed, 53 insertions(+), 26 deletions(-) diff --git a/src/main/acp/acp-incoming-requests.ts b/src/main/acp/acp-incoming-requests.ts index 6b8d21124eb2..25c473f6bb59 100644 --- a/src/main/acp/acp-incoming-requests.ts +++ b/src/main/acp/acp-incoming-requests.ts @@ -28,25 +28,14 @@ export class AcpIncomingRequests { this.open.clear() } - // Each handler answers on abort (a permission answers `cancelled`); -32800 if it rejects, or if - // it is still silent once the abort has run through (the next event-loop turn). + // Each handler answers its own request (a permission answers `cancelled`); -32800 only if it + // throws. The runtime never answers for a live handler: an answer still being saved must win. cancel(): void { - const cancelled = [...this.open].filter(([, request]) => !request.cancelled) - for (const [, request] of cancelled) { - request.cancelled = true - request.controller.abort(new AcpRpcError(-32800, 'Request cancelled')) - } - if (cancelled.length > 0) { - setImmediate(() => { - for (const [id, request] of cancelled) { - if (this.open.get(id) === request) { - this.open.delete(id) - request.closed = true - request.abandon() - void this.sendError(id, new AcpRpcError(-32800, 'Request cancelled')) - } - } - }) + for (const request of this.open.values()) { + if (!request.cancelled) { + request.cancelled = true + request.controller.abort(new AcpRpcError(-32800, 'Request cancelled')) + } } } diff --git a/src/main/acp/acp-json-rpc-peer.ts b/src/main/acp/acp-json-rpc-peer.ts index 0cf0b1fab377..b4d57e246cd5 100644 --- a/src/main/acp/acp-json-rpc-peer.ts +++ b/src/main/acp/acp-json-rpc-peer.ts @@ -33,8 +33,8 @@ const envelopeSchema = z.looseObject({ export type AcpJsonRpcMessage = z.infer export type AcpRequestContext = { id: string | number | null; signal: AbortSignal } export type AcpPeerHandlers = { - // Void means handled; unsupported methods must throw AcpRpcError(-32601). On a cancel abort, - // answer (e.g. the protocol's own cancelled reply) or throw at once: silence is answered -32800. + // Void means handled; unsupported methods must throw AcpRpcError(-32601). The handler owns its + // request: once the signal aborts it still answers, or throws (-32800); unanswered ends at close(). onRequest?: (method: string, params: unknown, context: AcpRequestContext) => unknown onNotification?: (method: string, params: unknown) => void onDiagnostic?: (message: string) => void diff --git a/src/main/acp/acp-session-lifecycle.test.ts b/src/main/acp/acp-session-lifecycle.test.ts index ec36afe471d5..7230870a8c02 100644 --- a/src/main/acp/acp-session-lifecycle.test.ts +++ b/src/main/acp/acp-session-lifecycle.test.ts @@ -1,3 +1,4 @@ +import { readFile } from 'node:fs/promises' import { afterEach, describe, expect, it, vi } from 'vitest' import { AcpJsonRpcPeer } from './acp-json-rpc-peer' import { AcpSessionRuntime, type AcpSessionRuntimeOptions } from './acp-session-runtime' @@ -247,11 +248,11 @@ describe('ACP caller-owned waits', () => { await expect(authenticated).resolves.toEqual({}) }) - it('lets cancel abort vendor hooks so each sends its own answer, else request-cancelled', async () => { + it('lets cancel abort vendor hooks so each sends its own answer, or request-cancelled if it throws', async () => { const { agent, runtime } = fixture({ onRequest: (method, _params, context) => new Promise((resolve, reject) => { - // '_vendor/silent' ignores the abort: the runtime still answers the agent. + // '_vendor/silent' ignores the abort: the runtime never answers for it. if (method !== '_vendor/silent') { context.signal.addEventListener('abort', () => method === '_vendor/plan' @@ -268,15 +269,51 @@ describe('ACP caller-owned waits', () => { const pending = runtime.prompt([...prompt]) const question = agent.request('question', '_vendor/question', {}) const plan = agent.request('plan', '_vendor/plan', {}) - const silent = agent.request('silent', '_vendor/silent', {}) + void agent.request('silent', '_vendor/silent', {}) await tick() await runtime.cancel() expect(await question).toMatchObject({ error: { code: -32800 } }) expect(await plan).toMatchObject({ result: { outcome: 'abandoned' } }) - expect(await silent).toMatchObject({ error: { code: -32800 } }) await pending await tick() expect(agent.frames.filter((frame) => frame.id === 'plan')).toHaveLength(1) + expect(agent.frames.some((frame) => frame.id === 'silent')).toBe(false) + }) + + it('keeps a handler answer that finishes saving after the cancel', async () => { + // A real I/O hop, the shape of a journal write the handler commits before replying. + const save = (): Promise => readFile(import.meta.filename).then(() => undefined) + const userAnswer = deferred() + const { agent, runtime } = fixture({ + onRequest: (method, _params, context) => + new Promise((resolve) => { + if (method === '_vendor/plan') { + // The user already approved; the save started before the stop and replies after it. + void userAnswer.promise.then(save).then(() => resolve({ outcome: 'approved' })) + } else { + context.signal.addEventListener( + 'abort', + () => void save().then(() => resolve({ outcome: 'abandoned' })) + ) + } + }) + }) + agent.on('session/prompt', (frame) => + agent.on('session/cancel', () => agent.reply(frame, { stopReason: 'cancelled' })) + ) + await runtime.start(startOptions) + const pending = runtime.prompt([...prompt]) + const plan = agent.request('plan', '_vendor/plan', {}) + const question = agent.request('question', '_vendor/question', {}) + await tick() + userAnswer.resolve() + await runtime.cancel() + expect(await plan).toMatchObject({ result: { outcome: 'approved' } }) + expect(await question).toMatchObject({ result: { outcome: 'abandoned' } }) + await pending + await tick() + expect(agent.frames.filter((frame) => frame.id === 'plan')).toHaveLength(1) + expect(agent.frames.filter((frame) => frame.id === 'question')).toHaveLength(1) }) it('settles requests when the process owner closes on exit even if stdout stays open', async () => { diff --git a/src/main/acp/acp-session-runtime.ts b/src/main/acp/acp-session-runtime.ts index 718fd2c716e4..1c668fd49bb9 100644 --- a/src/main/acp/acp-session-runtime.ts +++ b/src/main/acp/acp-session-runtime.ts @@ -52,8 +52,9 @@ export type AcpSessionRuntimeOptions = { peer?: AcpPeerOptions cancelTimeoutMs?: number onPermission?: AcpPermissionHandler - /** Agent requests other than permissions. When `context.signal` aborts on cancel, answer with - * the agent's own cancelled reply or throw right away; a handler still silent gets -32800. */ + /** Agent requests other than permissions. The handler owns its request: once `context.signal` + * aborts, send the agent's own cancelled reply, finish an answer already in progress, or throw + * (-32800). The runtime never answers for it; a request left unanswered ends at `close()`. */ onRequest?: (method: string, params: unknown, context: AcpRequestContext) => unknown /** Agent notifications other than `session/update` (protocol extensions), delivered * synchronously in arrival order with the `subscribe` events. */ From 1f4cab0b927d9c303f65eaccbadd01939eef1c3d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 01:07:42 -0700 Subject: [PATCH 45/62] End a turn another writer settled the way the provider's end does A person's Stop settled the open turn's row without a word to the assembler. The assembler then forgot the turn: its running tools and pending prompts were never settled, the provider's own end and withdrawal were dropped, and the turn's text streams stayed counted against the open budget for the life of the process. Text the provider kept streaming afterwards could land as a message outside the stopped turn. - The open turn the journal shows settled ends first, as one transition, through the same settlement the provider's turn.end plans; its streams stop and their keys drop later text until that turn's end or the next turn opens. - turn.end and request.withdrawn are admitted for a turn or request the journal holds; their settlement writes nothing for rows already settled. - The budget's re-check frees streams whose turn settled. - A settled tool keeps its terminal body against any differing write. - Session-end settlement of lost background work uses the journal's own lostLiveWorkJournalBody instead of a copy. - The rig's window elapses before every read, and restart swaps and disposes the old assembler. --- .../journal-subagent-liveness.ts | 15 +- .../provider-timeline-assembler-items.test.ts | 50 ++++ ...rovider-timeline-assembler-refusal.test.ts | 4 +- ...rovider-timeline-assembler-restart.test.ts | 31 +++ ...rovider-timeline-assembler-session.test.ts | 6 +- .../provider-timeline-assembler-stop.test.ts | 231 ++++++++++++++++++ ...rovider-timeline-assembler-test-support.ts | 53 ++-- .../provider-timeline-assembler-turns.test.ts | 6 +- .../provider-timeline-assembler.ts | 29 ++- .../provider-timeline-background-tasks.ts | 37 +-- .../provider-timeline-budget.ts | 12 +- .../provider-timeline-context.ts | 4 +- .../provider-timeline-decision.ts | 15 +- .../provider-timeline-event.ts | 18 +- .../provider-timeline-item-decisions.ts | 33 ++- .../provider-timeline-rows.ts | 19 +- .../provider-timeline-settlement.ts | 21 +- .../provider-timeline-state.ts | 15 +- .../provider-timeline-text-events.ts | 11 +- .../provider-timeline-text-streams.ts | 106 ++++++-- .../provider-timeline-transition-layout.ts | 21 +- .../provider-timeline-turn-decisions.ts | 72 ++++-- 22 files changed, 638 insertions(+), 171 deletions(-) create mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-assembler-stop.test.ts diff --git a/src/main/native-chat/agent-session-journal/journal-subagent-liveness.ts b/src/main/native-chat/agent-session-journal/journal-subagent-liveness.ts index ec81136688e3..ea4cebab1504 100644 --- a/src/main/native-chat/agent-session-journal/journal-subagent-liveness.ts +++ b/src/main/native-chat/agent-session-journal/journal-subagent-liveness.ts @@ -52,8 +52,8 @@ export function staleSubagentRosterRevisions( ): JournalSubagentLivenessRevision[] { const revisions: JournalSubagentLivenessRevision[] = [] for (const item of items) { - const body = item.body - if (body.kind !== 'message' || !body.blocks.some(hasStaleLiveWork)) { + const body = lostLiveWorkJournalBody(item.body) + if (!body) { continue } // A key that will not parse cannot be re-addressed, and appending under a @@ -62,11 +62,20 @@ export function staleSubagentRosterRevisions( if (!identity || agentJournalItemKey(identity) !== item.itemId) { continue } - revisions.push({ identity, body: { ...body, blocks: settleBlocks(body.blocks) } }) + revisions.push({ identity, body }) } return revisions } +/** The row once the host lost the session running its live work: every working child and + * in-flight background task `unverifiable`, plain-text twins restated. Null when none is live. */ +export function lostLiveWorkJournalBody(body: AgentJournalItemBody): AgentJournalItemBody | null { + if (body.kind !== 'message' || !body.blocks.some(hasStaleLiveWork)) { + return null + } + return { ...body, blocks: settleBlocks(body.blocks) } +} + function hasStaleLiveWork(block: NativeChatBlock): boolean { return hasWorkingChild(block) || hasLiveBackgroundTask(block) } diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-items.test.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-items.test.ts index 7d6a12178a11..2bebb9ee5b14 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-items.test.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-items.test.ts @@ -101,6 +101,46 @@ describe('provider timeline items', () => { }) }) + it('keeps a settled tool terminal body against a later update carrying another', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_000 }) + rig.assembler.apply({ type: 'item.open', item: 'call-a', body: runningTool('read') }) + rig.assembler.apply({ + type: 'item.close', + item: 'call-a', + body: { ...runningTool('read'), state: 'failed' } + }) + const update = rig.assembler.apply({ + type: 'item.update', + item: 'call-a', + body: { ...runningTool('read'), state: 'completed' } + }) + expect(update.dropped).toBe('item-settled') + expect((await rig.row(providerItemId('item', 'call-a')))?.body).toMatchObject({ + state: 'failed' + }) + }) + + it("keeps the sweep's verdict on a tool against the next child's update", async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_000 }) + rig.assembler.apply({ type: 'item.open', item: 'call-a', body: runningTool('read') }) + const next = await rig.restart() + expect((await rig.row(providerItemId('item', 'call-a')))?.body).toMatchObject({ + state: 'failed' + }) + const update = next.apply({ + type: 'item.update', + item: 'call-a', + body: { ...runningTool('read'), state: 'completed' }, + join: { turn: 'turn-1' } + }) + expect(update.dropped).toBe('item-settled') + expect((await rig.row(providerItemId('item', 'call-a')))?.body).toMatchObject({ + state: 'failed' + }) + }) + it('reopens a settled item under the same row', async () => { const rig = await openProviderTimelineRig() rig.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_000 }) @@ -209,6 +249,16 @@ describe('provider timeline text streams', () => { expect(reply?.revision).toBe(single?.revision) }) + it('writes every delta of a window once it elapses', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_000 }) + const delta = { type: 'text.delta', item: { stream: 'reply' }, channel: 'assistant' } as const + rig.assembler.apply({ ...delta, text: 'Hel' }) + rig.assembler.apply({ ...delta, text: 'lo' }) + const messages = (await rig.rows()).filter((row) => row.body.kind === 'message') + expect(messages.map((row) => messageText(row.body))).toEqual(['Hello']) + }) + it('settles with the provider final text, and keys a named message by its id', async () => { const rig = await openProviderTimelineRig() rig.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_000 }) diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-refusal.test.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-refusal.test.ts index 2580926b30a2..f9dbff3efc54 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-refusal.test.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-refusal.test.ts @@ -30,7 +30,9 @@ describe('a refused event changes nothing and its retry lands it once', () => { expect(assembler.apply(end)).toEqual({ admission: { accepted: true } }) expect((await rig.row(providerItemId('item', 'tool')))?.body).toMatchObject({ state: 'failed' }) expect(await rig.turn('t1')).toMatchObject({ state: 'completed', completedAt: 2_000 }) - expect(assembler.apply(end).dropped).toBe('turn-unknown') + // A repeated end is admitted and writes nothing: the row keeps its first end. + expect(assembler.apply(end)).toEqual({ admission: { accepted: true } }) + expect(await rig.turn('t1')).toMatchObject({ state: 'completed', completedAt: 2_000 }) }) it('keeps observed text when the close that would write it is refused', async () => { diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-restart.test.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-restart.test.ts index 9772b13fc128..a61ae858b26f 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-restart.test.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-restart.test.ts @@ -108,6 +108,37 @@ describe('an adopted session', () => { }) expect(messageText((await rig.row(providerItemId('item', 'a1')))?.body)).toBe('Looking') }) + + // Temporary limit, for the adoption work to lift: the sweep settled the cut turn as the dead + // child's work, so the re-run cannot finish it. + it('leaves the turn a crash cut as the sweep settled it when the adoption re-runs', async () => { + const rig = await openProviderTimelineRig() + const reply: ProviderTimelineEvent[] = [ + { + type: 'text.delta', + item: { id: 'a2' }, + channel: 'assistant', + text: 'Done', + join: { turn: 'p1' } + }, + { type: 'text.close', item: { id: 'a2' }, join: { turn: 'p1' } } + ] + const full = [...history.slice(0, -1), ...reply, ...history.slice(-1)] + full.slice(0, 5).forEach((event) => rig.assembler.apply(event)) + await rig.restart() + const { assembler, bind } = openUnboundProviderTimelineAssembler(rig.journal, { + generation: 'gen-3' + }) + full.forEach((event) => assembler.apply(event)) + assembler.flush() + await bind() + + expect((await rig.turn('p1'))?.state).toBe('unverifiable') + expect((await rig.row(providerItemId('item', 'call-1')))?.body).toMatchObject({ + state: 'failed' + }) + expect(await rig.row(providerItemId('item', 'a2'))).toBeUndefined() + }) }) describe('a resumed session', () => { diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-session.test.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-session.test.ts index fff993850eb1..cbb0441ac9f6 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-session.test.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-session.test.ts @@ -141,7 +141,11 @@ describe('provider timeline requests', () => { rig.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_000 }) rig.assembler.apply({ type: 'request.open', request: 'perm-1', body: approval }) rig.assembler.apply({ type: 'request.withdrawn', request: 'perm-1' }) - expect(rig.assembler.apply({ type: 'request.withdrawn', request: 'perm-1' }).dropped).toBe( + // The journal holds the row it withdrew: a repeat is admitted and finds nothing pending. + expect(rig.assembler.apply({ type: 'request.withdrawn', request: 'perm-1' })).toEqual({ + admission: { accepted: true } + }) + expect(rig.assembler.apply({ type: 'request.withdrawn', request: 'perm-9' }).dropped).toBe( 'request-unknown' ) expect((await rig.row(providerItemId('request', 'perm-1')))?.body).toMatchObject({ diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-stop.test.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-stop.test.ts new file mode 100644 index 000000000000..7ce1ec3a3f76 --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-stop.test.ts @@ -0,0 +1,231 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { parseAgentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import { agentJournalTurnBody } from '../../../shared/agent-session-turn-record' +import { MAX_PROVIDER_TIMELINE_OPEN_ENTRIES } from './provider-timeline-budget' +import { + closeProviderTimelineRigs, + messageText, + openProviderTimelineRig, + openUnboundProviderTimelineAssembler, + pendingApproval, + providerItemId, + providerTurnItemId, + runningTool, + type ProviderTimelineRig +} from './provider-timeline-assembler-test-support' + +afterEach(closeProviderTimelineRigs) + +/** A person's Stop, settled by the host on the journal, with no word to the assembler. */ +async function stop(rig: ProviderTimelineRig, turnKey: string, at = 2_000): Promise { + const running = await rig.turn(turnKey) + const identity = parseAgentJournalItemKey(providerTurnItemId(turnKey)) + if (!running || !identity) { + throw new Error('the turn row is written') + } + await rig.journal.appendItem( + identity, + agentJournalTurnBody({ ...running, state: 'interrupted', completedAt: at }), + { fence: 1, turnScope: { kind: 'thread' } } + ) +} + +async function texts(rig: ProviderTimelineRig): Promise { + return (await rig.rows()).flatMap((row) => { + const text = messageText(row.body) + return text === undefined || row.body.kind !== 'message' || row.body.role === 'user' + ? [] + : [[row.turnScope, text]] + }) +} + +describe('a turn another writer settled ends as the provider end would end it', () => { + it("settles the turn's running tool and pending prompt, then takes the provider's own end", async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ type: 'item.open', item: 'call', body: runningTool('read') }) + rig.assembler.apply({ type: 'request.open', request: '7', body: pendingApproval }) + await stop(rig, 't1') + + // The provider's trailing end of the turn it was asked to stop, and its withdrawal. + expect( + rig.assembler.apply({ type: 'turn.end', turn: 't1', at: 2_100, state: 'interrupted' }) + ).toEqual({ admission: { accepted: true } }) + expect(rig.assembler.apply({ type: 'request.withdrawn', request: '7' })).toEqual({ + admission: { accepted: true } + }) + expect((await rig.row(providerItemId('item', 'call')))?.body).toMatchObject({ + state: 'failed' + }) + expect((await rig.row(providerItemId('request', '7')))?.body).toMatchObject({ + resolution: { state: 'cancelled' } + }) + // The Stop's row stands. + expect(await rig.turn('t1')).toMatchObject({ state: 'interrupted', completedAt: 2_000 }) + expect(rig.assembler.openTurnId).toBeNull() + }) + + it('settles the same work when the provider end lands before the Stop', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ type: 'item.open', item: 'call', body: runningTool('read') }) + rig.assembler.apply({ type: 'request.open', request: '7', body: pendingApproval }) + rig.assembler.apply({ type: 'turn.end', turn: 't1', at: 2_100, state: 'interrupted' }) + expect((await rig.row(providerItemId('item', 'call')))?.body).toMatchObject({ + state: 'failed' + }) + expect((await rig.row(providerItemId('request', '7')))?.body).toMatchObject({ + resolution: { state: 'cancelled' } + }) + expect(await rig.turn('t1')).toMatchObject({ state: 'interrupted', completedAt: 2_100 }) + }) + + it('settles the work even when the next event is one that writes nothing', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ type: 'item.open', item: 'call', body: runningTool('read') }) + await stop(rig, 't1') + expect(rig.assembler.apply({ type: 'activity', text: 'Thinking' }).dropped).toBe('no-turn') + expect((await rig.row(providerItemId('item', 'call')))?.body).toMatchObject({ + state: 'failed' + }) + }) + + it('never fills the open budget with the streams of turns a person stopped', async () => { + const rig = await openProviderTimelineRig() + for (let n = 1; n <= MAX_PROVIDER_TIMELINE_OPEN_ENTRIES + 12; n += 1) { + const turn = `t${n}` + rig.assembler.apply({ type: 'turn.open', turn, at: n * 10 }) + // Named messages with no close, as a provider that never closes its text sends them. + expect( + rig.assembler.apply({ + type: 'text.delta', + item: { id: `msg-${n}` }, + channel: 'assistant', + text: `reply ${n}`, + join: { turn } + }).admission + ).toEqual({ accepted: true }) + await stop(rig, turn, n * 10 + 5) + rig.assembler.apply({ type: 'turn.end', turn, at: n * 10 + 6, state: 'interrupted' }) + } + expect(messageText((await rig.row(providerItemId('item', 'msg-140')))?.body)).toBe('reply 140') + }) + + it('frees a stream whose turn another writer settled before refusing for the budget', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ type: 'turn.open', turn: 't2', at: 1_100 }) + // Streams joined to turn t1, which is not the open one, so no turn end of this run frees them. + for (let n = 1; n < MAX_PROVIDER_TIMELINE_OPEN_ENTRIES; n += 1) { + rig.assembler.apply({ + type: 'text.delta', + item: { id: `old-${n}` }, + channel: 'assistant', + text: 'x', + join: { turn: 't1' } + }) + } + rig.assembler.apply({ + type: 'text.delta', + item: { id: 'now' }, + channel: 'assistant', + text: 'a' + }) + expect( + rig.assembler.apply({ type: 'item.open', item: 'call', body: runningTool('read') }).admission + ).toEqual({ accepted: true }) + }) +}) + +describe('text after a person stopped its turn never lands outside that turn', () => { + it('drops the rest of an anonymous stream', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ + type: 'text.delta', + item: { stream: 'a' }, + channel: 'assistant', + text: 'Hel' + }) + await stop(rig, 't1') + const delta = { type: 'text.delta', item: { stream: 'a' }, channel: 'assistant' } as const + expect(rig.assembler.apply({ ...delta, text: 'lo' }).dropped).toBe('turn-settled') + expect(rig.assembler.apply({ ...delta, text: ' world' }).dropped).toBe('turn-settled') + expect(await texts(rig)).toEqual([ + [{ kind: 'turn', turnItemId: providerTurnItemId('t1') }, 'Hel'] + ]) + }) + + it('drops the rest of a named stream whose first write found the turn stopped', async () => { + const rig = await openProviderTimelineRig({ schedule: () => () => {} }) + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ + type: 'text.delta', + item: { id: 'm' }, + channel: 'assistant', + text: 'Hel' + }) + await stop(rig, 't1') + rig.assembler.flush() + expect( + rig.assembler.apply({ + type: 'text.delta', + item: { id: 'm' }, + channel: 'assistant', + text: 'lo' + }).dropped + ).toBe('turn-settled') + rig.assembler.flush() + expect(await texts(rig)).toEqual([]) + }) + + it('drops a stopped stream admitted before the sink bound once its write found the turn over', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + await stop(rig, 't1') + const { assembler, bind } = openUnboundProviderTimelineAssembler(rig.journal) + assembler.apply({ + type: 'text.delta', + item: { stream: 'a' }, + channel: 'assistant', + text: 'Hel', + join: { turn: 't1' } + }) + assembler.flush() + await bind() + expect( + assembler.apply({ + type: 'text.delta', + item: { stream: 'a' }, + channel: 'assistant', + text: 'lo', + join: { turn: 't1' } + }).dropped + ).toBe('turn-settled') + expect(await texts(rig)).toEqual([]) + }) + + it('starts a new message for the same stream once the next turn opens', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ + type: 'text.delta', + item: { stream: 'a' }, + channel: 'assistant', + text: 'A' + }) + await stop(rig, 't1') + rig.assembler.apply({ type: 'turn.open', turn: 't2', at: 3_000 }) + rig.assembler.apply({ + type: 'text.delta', + item: { stream: 'a' }, + channel: 'assistant', + text: 'B' + }) + expect(await texts(rig)).toEqual([ + [{ kind: 'turn', turnItemId: providerTurnItemId('t1') }, 'A'], + [{ kind: 'turn', turnItemId: providerTurnItemId('t2') }, 'B'] + ]) + }) +}) diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-test-support.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-test-support.ts index 15b5963db5ea..1009ab6aa532 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-test-support.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-test-support.ts @@ -186,12 +186,14 @@ export function openUnboundProviderTimelineAssembler( export type ProviderTimelineRig = { journal: AgentSessionJournal + /** The current child's assembler; `restart` replaces it. */ assembler: ProviderTimelineAssembler sink: ProviderTimelineSink /** The same journal's event sink, for a lane that writes it directly. */ eventSink: StructuredAgentSessionEventSink - /** What a restarted host does: the dead-generation sweep settles what the old child left, then - * a new child gets a new assembler in a new generation. */ + /** What a restarted host does: the old child's assembler is gone, the dead-generation sweep + * settles what it left, then a new child gets a new assembler in a new generation, which + * becomes `assembler`. */ restart(overrides?: Partial): Promise /** Another assembler of the same generation on the same journal, for a test that needs its own * sink; the rig's own assembler must then stay unused. */ @@ -241,6 +243,15 @@ export async function openProviderTimelineRig( if (!sink) { throw new Error('the deferred sink offers transitions') } + // The coalescing window elapses before every read, so all text streamed so far is written, + // unless a test overrides `schedule` to drive the window itself. + const windows = new Set<() => void>() + const elapse = () => { + // Only the windows open now: a flush the sink refused schedules another for the next read. + const due = Array.from(windows) + windows.clear() + due.forEach((run) => run()) + } const build = (more: Partial = {}) => createProviderTimelineAssembler({ sink, @@ -248,15 +259,15 @@ export async function openProviderTimelineRig( agent: AGENT, generation: GENERATION, namespace: NAMESPACE, - // Every delta writes at once unless a test drives the window itself. schedule: (run) => { - run() - return () => {} + windows.add(run) + return () => windows.delete(run) }, ...overrides, ...more }) const rows = async () => { + elapse() await deferred.drained() return journal.snapshot().items } @@ -266,25 +277,26 @@ export async function openProviderTimelineRig( return turn ? [turn] : [] }) let generations = 1 - const restart = async (more: Partial = {}) => { - generations += 1 - const generation = more.generation ?? `gen-${generations}` - await deferred.drained() - await settleStaleStructuredAgentSessionState({ - journal, - sessionId: SESSION, - fence: 1, - acquisitionGeneration: generation, - deathEvidence: null - }) - return build({ ...more, generation }) - } - return { + const rig: ProviderTimelineRig = { journal, assembler: build(), sink, eventSink: deferred.sink, - restart, + restart: async (more = {}) => { + generations += 1 + const generation = more.generation ?? `gen-${generations}` + await rows() + rig.assembler.dispose() + await settleStaleStructuredAgentSessionState({ + journal, + sessionId: SESSION, + fence: 1, + acquisitionGeneration: generation, + deathEvidence: null + }) + rig.assembler = build({ ...more, generation }) + return rig.assembler + }, assemble: build, rows, row: async (itemId) => (await rows()).find((item) => item.itemId === itemId), @@ -297,4 +309,5 @@ export async function openProviderTimelineRig( ) } } + return rig } diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-turns.test.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-turns.test.ts index 19a7750b089c..cd31a2c8e1e7 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-turns.test.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-turns.test.ts @@ -130,7 +130,7 @@ describe('provider timeline turns', () => { expect(await rig.turn('turn-2')).toMatchObject({ state: 'running', startedAt: 2_000 }) }) - it('drops a repeated open, a repeated end, and an end for a turn it never opened', async () => { + it('drops a repeated open and an end for a turn it never opened; a repeated end writes nothing', async () => { const rig = await openProviderTimelineRig() rig.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_000 }) expect(rig.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_100 }).dropped).toBe( @@ -143,10 +143,10 @@ describe('provider timeline turns', () => { .dropped ).toBe('turn-unknown') rig.assembler.apply({ type: 'turn.end', at: 2_000, state: 'completed', outcome: 'success' }) + // The journal holds the turn: its end is admitted and finds nothing left to settle. expect( rig.assembler.apply({ type: 'turn.end', turn: 'turn-1', at: 3_000, state: 'interrupted' }) - .dropped - ).toBe('turn-unknown') + ).toEqual({ admission: { accepted: true } }) expect(rig.assembler.apply({ type: 'turn.end', at: 3_000, state: 'completed' }).dropped).toBe( 'no-turn' ) diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler.ts index 581c9703be00..0bc2da8b7204 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler.ts @@ -29,7 +29,7 @@ import { import type { ProviderTimelineEvent } from './provider-timeline-event' import { createLegacyProviderTimelineIdentityScheme } from './provider-timeline-identity' import { ProviderTimelinePlan, type ProviderTimelineSink } from './provider-timeline-plan' -import { ProviderTimelineRows } from './provider-timeline-rows' +import { ProviderTimelineRows, providerTimelineTurnRowState } from './provider-timeline-rows' import { ProviderTimelineState } from './provider-timeline-state' import { applyProviderTimelineTextClose, @@ -52,6 +52,7 @@ export type ProviderTimelineAssembler = { readonly openTurnId: string | null /** Writes the text the coalescing window holds. */ flush(): void + /** Drops the text the window holds: apply `session.ended` first, which writes it. */ dispose(): void } @@ -105,7 +106,7 @@ export function createProviderTimelineAssembler( }) const admits = (hold: ProviderTimelineHold, journal: ProviderTimelineTextHost['journal']) => - providerTimelineBudgetAdmits({ hold, state, streams: streams.open, journal }) + providerTimelineBudgetAdmits({ hold, state, streams, journal }) const applyDecided = ( event: ProviderTimelineDecidedEvent, @@ -129,16 +130,34 @@ export function createProviderTimelineAssembler( decision.commit?.(state) serials.commit() }) - if (event.type === 'turn.open' || event.type === 'turn.end' || event.type === 'session.ended') { + if ( + event.type === 'turn.open' || + event.type === 'turn.end' || + event.type === 'turn.settled' || + event.type === 'session.ended' + ) { plan.onAdmitted(() => deps.sink.setActivity?.(null)) } return { admission: plan.submit(deps.sink) } } + /** The open turn another writer settled (a person's Stop) ends here first, as one transition; + * refused, the event that found it is refused with it and its retry finds it again. */ + const endSettledTurn = ( + journal: NonNullable + ): ProviderTimelineApplyResult | null => { + const open = state.open + if (state.ended || !open || providerTimelineTurnRowState(journal, open.itemId) !== 'settled') { + return null + } + return applyDecided({ type: 'turn.settled', turn: open }, journal) + } + const apply = (event: ProviderTimelineEvent): ProviderTimelineApplyResult => { const journal = deps.sink.journalItems() - if (journal) { - state.reconcile(journal) + const settled = journal ? endSettledTurn(journal) : null + if (settled && !settled.admission.accepted) { + return settled } switch (event.type) { case 'text.delta': diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-background-tasks.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-background-tasks.ts index 3b4d004160fd..c8522462245c 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-background-tasks.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-background-tasks.ts @@ -4,44 +4,9 @@ // that turn; only its own updates, or the session's end, change its state. import type { AgentJournalItemBody } from '../../../shared/agent-session-journal-types' -import { - backgroundTaskFallbackText, - canReplaceBackgroundTaskState, - isSettledBackgroundTaskState -} from '../../../shared/native-chat-background-task-row' +import { canReplaceBackgroundTaskState } from '../../../shared/native-chat-background-task-row' import { isBackgroundTaskBlock } from '../../../shared/native-chat-types' -/** The row once the host lost the session that ran its tasks: every task still in flight is - * `unverifiable` (nothing says it exited), with its plain-text twin restated to match. Null when - * no task in it is in flight. */ -export function lostProviderTimelineBackgroundTasks( - body: AgentJournalItemBody -): AgentJournalItemBody | null { - if (body.kind !== 'message') { - return null - } - const restated = new Map() - const blocks = body.blocks.map((block) => { - if (!isBackgroundTaskBlock(block) || isSettledBackgroundTaskState(block.state)) { - return block - } - const lost = { ...block, state: 'unverifiable' as const } - restated.set(backgroundTaskFallbackText(block), backgroundTaskFallbackText(lost)) - return lost - }) - if (restated.size === 0) { - return null - } - return { - ...body, - blocks: blocks.map((block) => - block.type === 'text' && restated.has(block.text) - ? { ...block, text: restated.get(block.text) ?? block.text } - : block - ) - } -} - /** Whether `next` would move a task `held` already settled back to a state it may not take. */ export function relightsProviderTimelineBackgroundTask( held: AgentJournalItemBody | null, diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-budget.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-budget.ts index a097bf689d69..3422ad01f6e3 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-budget.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-budget.ts @@ -2,13 +2,14 @@ // and open text streams. The open set is the state's open items plus the open streams, one entry // per item (an item and its stream share its row id) and one per request key whatever its // incarnation. Before refusing, each entry is checked against the journal by key, so an answer a -// client gave or a row another writer settled frees its room. Past the budget an event is refused +// client gave, a row another writer settled, or a stream whose turn settled frees its room. Past the budget an event is refused // as `failed`, which ends the session the way a failed journal write does, and the journal-derived // dead-generation settlement closes whatever it left open. import type { StructuredAgentSessionSinkAdmission } from '../agent-session-wire/structured-agent-session-event-sink' import type { StructuredAgentSessionTransitionJournal } from '../agent-session-wire/structured-agent-session-transition' import type { ProviderTimelineState } from './provider-timeline-state' +import type { ProviderTimelineTextStreams } from './provider-timeline-text-streams' export const MAX_PROVIDER_TIMELINE_OPEN_ENTRIES = 128 export const MAX_PROVIDER_TIMELINE_OPEN_BYTES = 1024 * 1024 @@ -23,13 +24,13 @@ export type ProviderTimelineHold = { key: string; bytes: number } export function providerTimelineBudgetAdmits(input: { hold: ProviderTimelineHold state: ProviderTimelineState - streams: readonly ProviderTimelineHold[] + streams: ProviderTimelineTextStreams journal: StructuredAgentSessionTransitionJournal | null }): boolean { if (fits(input)) { return true } - const { journal, state } = input + const { journal, state, streams } = input if (!journal) { return false } @@ -39,13 +40,14 @@ export function providerTimelineBudgetAdmits(input: { state.items.delete(key) } } + streams.stopSettled(journal) return fits(input) } function fits(input: { hold: ProviderTimelineHold state: ProviderTimelineState - streams: readonly ProviderTimelineHold[] + streams: ProviderTimelineTextStreams }): boolean { const open = new Map() for (const [key, entry] of input.state.items) { @@ -53,7 +55,7 @@ function fits(input: { open.set(key, entry.bytes) } } - for (const stream of input.streams) { + for (const stream of input.streams.open) { open.set(stream.key, Math.max(open.get(stream.key) ?? 0, stream.bytes)) } open.set(input.hold.key, Math.max(open.get(input.hold.key) ?? 0, input.hold.bytes)) diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-context.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-context.ts index e65c134ad888..d321dfbe7d8c 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-context.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-context.ts @@ -27,8 +27,8 @@ export function providerTimelineSettlementId( return `provider-timeline:${context.sessionId}:${context.generation}:${serial}:${what}` } -/** The turn a new row joins: the one the provider names (subagent work joins the open turn while - * keeping its own turn in its identity), else the open one, else none. */ +/** The turn a new row joins: the one the provider names, else the open one, else none. Subagent + * work on a thread of its own joins the open turn whatever turn it names. */ export function providerTimelinePlacement( context: ProviderTimelineContext, state: ProviderTimelineState, diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-decision.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-decision.ts index bd1274934676..884f735b50fe 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-decision.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-decision.ts @@ -22,6 +22,7 @@ import { decideRequest, decideWithdrawal } from './provider-timeline-item-decisions' +import type { ProviderTimelineTurnRef } from './provider-timeline-rows' import type { ProviderTimelineState } from './provider-timeline-state' import { decideContextUsage, @@ -29,7 +30,8 @@ import { decideInput, decideSessionEnd, decideTurnEnd, - decideTurnOpen + decideTurnOpen, + decideTurnSettled } from './provider-timeline-turn-decisions' /** Why an event wrote nothing. Each is a grammar rule the adapter broke or a fact already held. */ @@ -45,10 +47,11 @@ export type ProviderTimelineDropRule = | 'stream-unknown' | 'stream-mismatch' -export type ProviderTimelineDecidedEvent = Exclude< - ProviderTimelineEvent, - { type: 'text.delta' | 'text.close' | 'activity' } -> +export type ProviderTimelineDecidedEvent = + | Exclude + /** The assembler's own: the journal shows the open turn settled by another writer (a person's + * Stop), so it ends here exactly as the provider's `turn.end` would end it. */ + | { type: 'turn.settled'; turn: ProviderTimelineTurnRef } type Journal = StructuredAgentSessionTransitionJournal @@ -104,6 +107,8 @@ export function decideProviderTimelineEvent( return decideTurnOpen(input, event) case 'turn.end': return decideTurnEnd(input, event) + case 'turn.settled': + return decideTurnSettled(event) case 'item.open': case 'item.update': case 'item.close': diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-event.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-event.ts index a745709210cd..b578088d8ef2 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-event.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-event.ts @@ -16,11 +16,16 @@ // before that work's items. // - `join.turn` attaches a row to the turn the provider says it belongs to, open or not. // - A turn settles once, with only the provider's verdict. A row the journal already holds is -// never written back to an earlier state: a settled turn stays settled, a settled tool never -// runs again, an answered request stays answered. +// never written back to an earlier state: a settled turn stays settled, a settled tool keeps its +// terminal body, an answered request stays answered. +// - A turn another writer settled (a person's Stop) ends here as the provider's `turn.end` would +// end it: its running work settles, and text still streaming into it is dropped until the +// provider's end of that turn or the next `turn.open`. // - Saved history the provider replays goes only into an empty journal (an adopted session), as // ordinary events with the provider's own ids, so re-running an interrupted adoption writes the -// same rows again. +// same rows again. Limit, until the adoption work lifts it: a crash that cut the first run +// inside a turn leaves that turn as the restart's sweep settled it (`unverifiable`, its running +// tools `failed`), and the re-run writes nothing more into it. // - An event the sink refused changed nothing. Re-apply the same event to retry it (after // `backpressure`); `failed` and `closed` are final. // - A provider item is (thread, id): the same id on another thread is another item. Text and @@ -87,7 +92,8 @@ export type ProviderTimelineEvent = } /** A turn began. `turn` is the provider's turn id when it has one; the assembler mints one otherwise. */ | { type: 'turn.open'; turn?: string; at: number } - /** The provider ended a turn. Absent `turn` means the open turn. */ + /** The provider ended a turn. Absent `turn` means the open turn. A turn already over (another + * writer's Stop, a newer turn) takes it too: what it left open settles, its row stays. */ | { type: 'turn.end' turn?: string @@ -103,7 +109,7 @@ export type ProviderTimelineEvent = * started it, which closes as usual: no turn's end settles that row; its own updates do, and the * session's end leaves one still in flight `unverifiable`. */ | ({ type: 'item.open'; item: string; body: ProviderTimelineItemBody } & Produced & Joined) - /** The item's whole current body. Content may be replaced; a settled tool never runs again. */ + /** The item's whole current body. Content may be replaced; a settled tool keeps its terminal body. */ | ({ type: 'item.update'; item: string; body: ProviderTimelineItemBody } & Produced & Joined) /** The item's whole terminal body; it replaces any text streamed into the same item. */ | ({ type: 'item.close'; item: string; body: ProviderTimelineItemBody } & Produced & Joined) @@ -121,7 +127,7 @@ export type ProviderTimelineEvent = /** The provider asked the user something and waits on the answer. */ | ({ type: 'request.open'; request: string; body: ProviderTimelineRequestBody } & Produced & Joined) - /** The provider stopped waiting for an answer it never got. */ + /** The provider stopped waiting for an answer it never got; one already settled stays settled. */ | { type: 'request.withdrawn'; request: string } /** What the provider said about its context window: for `join.turn`, else the open turn, else the last. */ | ({ type: 'context.usage'; usage: AgentSessionContextUsage } & Joined) diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-item-decisions.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-item-decisions.ts index e3d9ff8ec461..db7ee10131b6 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-item-decisions.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-item-decisions.ts @@ -1,5 +1,6 @@ // What item, request and frame events do: admitted on the state, written against the journal. +import { isDeepStrictEqual } from 'node:util' import { AGENT_JOURNAL_THREAD_SCOPE, type AgentJournalItemBody @@ -38,9 +39,9 @@ function pendingPrompt(body: AgentJournalItemBody | null): body is ProviderTimel ) } -/** Whether the row the journal holds refuses this write: a settled tool never runs again and keeps - * its first terminal body, a settled background task is never relit, and a turn that is over - * takes no work that waits on a settlement it already ran. */ +/** Whether the row the journal holds refuses this write: a settled tool keeps its first terminal + * body (whoever settled it, the sweep included), a settled background task is never relit, and a + * turn that is over takes no work that waits on a settlement it already ran. */ function refusesItemWrite( journal: Journal, row: ProviderTimelineRowId, @@ -50,7 +51,11 @@ function refusesItemWrite( ): boolean { const held = journal.item(row.itemId) const turn = held ? turnOf(held.turnScope ?? AGENT_JOURNAL_THREAD_SCOPE) : placed - if (settledTool(held?.body ?? null) && (change === 'close' || requiresTerminalSettlement(body))) { + if ( + held && + settledTool(held.body) && + (change === 'close' || !isDeepStrictEqual(held.body, body)) + ) { return true } if (relightsProviderTimelineBackgroundTask(held?.body ?? null, body)) { @@ -172,27 +177,31 @@ export function decideWithdrawal( input: ProviderTimelineDecisionInput, event: Extract ): ProviderTimelineDecision { + const { state, journal, context } = input const key = requestKey(event.request) - const opened = input.state.items.get(key) - if (opened?.kind !== 'request') { + const entry = state.items.get(key) + const opened = entry?.kind === 'request' ? entry : null + // One its turn's end already let go is still the journal's newest row under its id. + if (!opened && journal && !context.rows.heldRequest(event.request, journal)) { return { dropped: 'request-unknown' } } return { settle: { what: 'request-withdrawn', - resolve: (journal) => { - // The open ran first and named its row. Only while it is pending: a client's answer that - // landed first stands. - const held = opened.row ? journal.item(opened.row.itemId) : null + resolve: (at) => { + // The open ran first and named its row. Only while it is pending: a client's answer, or + // the settlement of its turn, that landed first stands. + const row = opened ? opened.row : context.rows.heldRequest(event.request, at) + const held = row ? at.item(row.itemId) : null const cancelled = held && pendingPrompt(held.body) ? cancelledJournalPromptBody(held.body) : null - if (!opened.row || !held || !cancelled) { + if (!row || !held || !cancelled) { return [] } return [ { kind: 'item', - identity: opened.row.identity, + identity: row.identity, body: cancelled, turnScope: held.turnScope ?? AGENT_JOURNAL_THREAD_SCOPE } diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-rows.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-rows.ts index 72c892cfbe9f..8ce41039c302 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-rows.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-rows.ts @@ -90,12 +90,21 @@ export class ProviderTimelineRows { /** The first incarnation under `key` the journal holds no row for: never one already written. */ nextRequest(key: string, journal: Journal): ProviderTimelineRowId { - for (let incarnation = 1; ; incarnation += 1) { - const row = this.request(key, incarnation) - if (journal.itemBody(row.itemId) === null) { - return row - } + return this.request(key, this.heldIncarnations(key, journal) + 1) + } + + /** The newest incarnation under `key` the journal holds, if any. */ + heldRequest(key: string, journal: Journal): ProviderTimelineRowId | null { + const held = this.heldIncarnations(key, journal) + return held === 0 ? null : this.request(key, held) + } + + private heldIncarnations(key: string, journal: Journal): number { + let held = 0 + while (journal.itemBody(this.request(key, held + 1).itemId) !== null) { + held += 1 } + return held } private request(key: string, incarnation: number): ProviderTimelineRowId { diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-settlement.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-settlement.ts index b8fb5f532233..1baa7c54996b 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-settlement.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-settlement.ts @@ -18,9 +18,9 @@ import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' import type { JournalLifecycleMutationInput } from '../agent-session-journal/journal-row-builders' +import { lostLiveWorkJournalBody } from '../agent-session-journal/journal-subagent-liveness' import { terminalAgentJournalBody } from '../agent-session-journal/journal-terminal-settlement' import type { StructuredAgentSessionTransitionJournal } from '../agent-session-wire/structured-agent-session-transition' -import { lostProviderTimelineBackgroundTasks } from './provider-timeline-background-tasks' import { agentJournalTurnRowReservedBytes, resolveAgentJournalTurnRowWrite @@ -64,12 +64,17 @@ export function endedProviderTimelineTurn( /** Which open rows a settlement covers: one turn's, or every row when the session ends. */ export type ProviderTimelineSettlementScope = { turnItemId: string } | 'session' -/** The settled rows of `scope`, then each listed turn's end while the journal holds it running. */ +/** The turns a settlement ends, and how; absent when another writer already ended the turn. */ +export type ProviderTimelineSettlementEnding = { + turns: readonly { identity: AgentJournalItemIdentity; itemId: string }[] + end: ProviderTimelineTurnEnd +} + +/** The settled rows of `scope`, then each ended turn's row while the journal holds it running. */ export function providerTimelineSettlement( journal: StructuredAgentSessionTransitionJournal, scope: ProviderTimelineSettlementScope, - turns: readonly { identity: AgentJournalItemIdentity; itemId: string }[], - end: ProviderTimelineTurnEnd + ending?: ProviderTimelineSettlementEnding ): JournalLifecycleMutationInput[] { const mutations: JournalLifecycleMutationInput[] = [] journal.visitItemsWithLinkage((itemId, _sequence, body, attribution) => { @@ -77,19 +82,19 @@ export function providerTimelineSettlement( const covered = scope === 'session' || (turnScope.kind === 'turn' && turnScope.turnItemId === scope.turnItemId) - // Background tasks outlive turns; only the session's end leaves them past seeing. + // Background tasks and subagents outlive turns; only the session's end leaves them past seeing. const settled = !covered ? null : (terminalAgentJournalBody(body) ?? - (scope === 'session' ? lostProviderTimelineBackgroundTasks(body) : null)) + (scope === 'session' ? lostLiveWorkJournalBody(body) : null)) const identity = settled ? parseAgentJournalItemKey(itemId) : null if (settled && identity) { // No linkage: a revision keeps the row's own producer. mutations.push({ kind: 'item', identity, body: settled, turnScope }) } }) - for (const turn of turns) { - const ended = endedTurnRow(journal, turn, end) + for (const turn of ending?.turns ?? []) { + const ended = ending ? endedTurnRow(journal, turn, ending.end) : null if (ended) { mutations.push({ kind: 'item', ...ended, turnScope: AGENT_JOURNAL_THREAD_SCOPE }) } diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-state.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-state.ts index d236f2536fab..22d461b8ba87 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-state.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-state.ts @@ -12,11 +12,7 @@ import { } from '../../../shared/agent-session-journal-types' import { requiresTerminalSettlement } from '../agent-session-journal/journal-terminal-settlement' import type { StructuredAgentSessionTransitionJournal } from '../agent-session-wire/structured-agent-session-transition' -import { - providerTimelineTurnRowState, - type ProviderTimelineRowId, - type ProviderTimelineTurnRef -} from './provider-timeline-rows' +import type { ProviderTimelineRowId, ProviderTimelineTurnRef } from './provider-timeline-rows' /** Closed items remembered so a repeat close is dropped; past this the journal decides alone. */ const MAX_CLOSED_ITEMS = 512 @@ -78,13 +74,6 @@ export class ProviderTimelineState { } } - /** Ends the open turn when another writer of the journal settled its row. */ - reconcile(journal: StructuredAgentSessionTransitionJournal): void { - if (this.open && providerTimelineTurnRowState(journal, this.open.itemId) === 'settled') { - this.endTurn(this.open) - } - } - /** Whether the journal shows the work under `key` settled by any writer (a client's answer). * Work whose write is still queued stays open. */ settledInJournal(key: string, journal: StructuredAgentSessionTransitionJournal): boolean { @@ -135,8 +124,8 @@ export class ProviderTimelineState { endTurn(turn: ProviderTimelineTurnRef): void { if (this.open?.itemId === turn.itemId) { this.open = null + this.latest = turn } - this.latest = turn for (const [key, entry] of this.items) { if (entry.turnItemId === turn.itemId) { this.items.delete(key) diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-text-events.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-text-events.ts index 0cf3452440bb..ec93367efd48 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-text-events.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-text-events.ts @@ -51,6 +51,9 @@ export function applyProviderTimelineTextDelta( stream = undefined } if (!stream) { + if (streams.stoppedFor(key, event.join)) { + return { admission: ADMITTED, dropped: 'turn-settled' } + } if ('id' in event.item && settledNamedItem(host, key)) { return { admission: ADMITTED, dropped: 'item-settled' } } @@ -94,9 +97,13 @@ export function applyProviderTimelineTextClose( if (state.ended) { return { admission: ADMITTED, dropped: 'session-ended' } } - const stream = streams.get(streams.key(event.item, event.join)) + const key = streams.key(event.item, event.join) + const stream = streams.get(key) if (!stream) { - return { admission: ADMITTED, dropped: 'stream-unknown' } + return { + admission: ADMITTED, + dropped: streams.stoppedFor(key, event.join) ? 'turn-settled' : 'stream-unknown' + } } const plan = new ProviderTimelinePlan() streams.planFlush(plan, stream) diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-text-streams.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-text-streams.ts index da869954907d..eda78997e6d2 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-text-streams.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-text-streams.ts @@ -7,7 +7,9 @@ // Text owed to the journal is written inside the next event's transition — every other event is // an ordering barrier — or by the coalescer's window, and is marked written once the sink takes it. // Every write first reads the turn its row is in: once any writer settled that turn (a person's -// Stop, the turn's own end), the stream writes nothing more. +// Stop, the turn's own end), the stream writes nothing more. A stream stopped that way keeps its +// key until the turn's boundary, so the provider's trailing deltas drop rather than start a +// message outside the stopped turn. import { AGENT_JOURNAL_THREAD_SCOPE, @@ -61,13 +63,18 @@ export type ProviderTimelineStream = { /** Whether any text was written; a whitespace-only stream completes nothing. */ written: boolean bytes: number - /** A write found its row's turn settled: it writes nothing more. */ - stopped: boolean + /** The settled turn a write found its row in: it writes nothing more. */ + stoppedIn: string | null } +/** Stopped streams remembered until their turn's boundary; past this the oldest is forgotten. */ +const MAX_STOPPED_STREAMS = 128 + export class ProviderTimelineTextStreams { private readonly streams = new Map() private readonly byId = new Map() + /** Keys of streams stopped by their turn's settlement, to that turn's row id. */ + private readonly stopped = new Map() private readonly coalescer constructor( @@ -89,7 +96,7 @@ export class ProviderTimelineTextStreams { /** What the budget counts: one entry per open stream. */ get open(): readonly ProviderTimelineStream[] { - return [...this.streams.values()].filter((stream) => !stream.stopped) + return [...this.streams.values()].filter((stream) => stream.stoppedIn === null) } /** The stream slot `item` names on its thread. */ @@ -100,16 +107,27 @@ export class ProviderTimelineTextStreams { : `stream:${providerTimelineKeyPart(thread ?? '')}:${providerTimelineKeyPart(item.stream)}` } - /** The open stream in `key`; a stopped one is let go, so the next delta starts another. */ + /** The open stream in `key`; a stopped one is let go and its key kept as stopped. */ get(key: string): ProviderTimelineStream | undefined { const stream = this.streams.get(key) - if (stream?.stopped) { - this.forget(stream) + if (stream?.stoppedIn) { + this.stop(stream, stream.stoppedIn) return undefined } return stream } + /** Whether text for `key` would continue a stream its turn's settlement stopped: it names no + * turn, or names that one. */ + stoppedFor(key: string, join: ProviderTimelineJoin | undefined): boolean { + const turn = this.stopped.get(key) + return ( + turn !== undefined && + (join?.turn === undefined || + this.deps.context.rows.turn(providerKey(join.turn)).itemId === turn) + ) + } + /** A new stream, not yet open; `serial` names it (and an anonymous one's row). */ start(input: { item: ProviderTimelineTextItem @@ -137,7 +155,7 @@ export class ProviderTimelineTextStreams { join: input.join, producer: input.producer }), - stopped: false + stoppedIn: null } } @@ -171,6 +189,39 @@ export class ProviderTimelineTextStreams { } } + /** Stops every stream of a turn another writer settled once the event is admitted: their text + * can never land. */ + planStop(plan: ProviderTimelinePlan, turnItemId: string): void { + const stopped = [...this.streams.values()].filter( + (stream) => turnOf(stream.scope) === turnItemId + ) + if (stopped.length > 0) { + plan.onAdmitted(() => stopped.forEach((stream) => this.stop(stream, turnItemId))) + } + } + + /** A turn's boundary: what its settlement stopped no longer holds the key; null is every turn. */ + planBoundary(plan: ProviderTimelinePlan, turnItemId: string | null): void { + plan.onAdmitted(() => { + for (const [key, turn] of this.stopped) { + if (turnItemId === null || turn === turnItemId) { + this.stopped.delete(key) + } + } + }) + } + + /** Before the budget refuses: a stream whose row's turn the journal shows settled holds nothing, + * since none of its text can land. */ + stopSettled(journal: StructuredAgentSessionTransitionJournal): void { + for (const stream of this.open) { + const turn = this.rowTurn(stream, journal) + if (turn !== null && providerTimelineTurnRowState(journal, turn) === 'settled') { + this.stop(stream, turn) + } + } + } + /** Ends the streams `which` selects once the event is admitted; their text was flushed by it. */ planRelease( plan: ProviderTimelinePlan, @@ -200,8 +251,10 @@ export class ProviderTimelineTextStreams { this.coalescer.flushAll() } + /** Drops the window's unwritten text: apply `session.ended` first, which writes it. */ dispose(): void { - this.open.forEach((stream) => this.forget(stream)) + Array.from(this.byId.values()).forEach((stream) => this.forget(stream)) + this.stopped.clear() this.coalescer.dispose() } @@ -248,18 +301,26 @@ export class ProviderTimelineTextStreams { body: AgentJournalItemBody, journal: StructuredAgentSessionTransitionJournal ): ProviderTimelineResolvedWrite | null { - const held = journal.item(stream.row.itemId) - const turn = turnOf(held ? (held.turnScope ?? AGENT_JOURNAL_THREAD_SCOPE) : stream.scope) - if ( - stream.stopped || - (turn !== null && providerTimelineTurnRowState(journal, turn) === 'settled') - ) { - stream.stopped = true + if (stream.stoppedIn !== null) { + return null + } + const turn = this.rowTurn(stream, journal) + if (turn !== null && providerTimelineTurnRowState(journal, turn) === 'settled') { + stream.stoppedIn = turn return null } return { identity: stream.row.identity, body } } + /** The turn the stream's row is in: the journal's for a written row, else where it would go. */ + private rowTurn( + stream: ProviderTimelineStream, + journal: StructuredAgentSessionTransitionJournal + ): string | null { + const held = journal.item(stream.row.itemId) + return turnOf(held ? (held.turnScope ?? AGENT_JOURNAL_THREAD_SCOPE) : stream.scope) + } + private message(stream: ProviderTimelineStream, text: string): AgentJournalItemBody { return { kind: 'message', @@ -268,6 +329,19 @@ export class ProviderTimelineTextStreams { } } + private stop(stream: ProviderTimelineStream, turnItemId: string): void { + stream.stoppedIn = turnItemId + this.forget(stream) + this.stopped.delete(stream.key) + this.stopped.set(stream.key, turnItemId) + for (const [oldest] of [...this.stopped].slice( + 0, + Math.max(0, this.stopped.size - MAX_STOPPED_STREAMS) + )) { + this.stopped.delete(oldest) + } + } + private forget(stream: ProviderTimelineStream): void { this.coalescer.forget(stream.id) if (this.streams.get(stream.key) === stream) { diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-transition-layout.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-transition-layout.ts index a51cc9064dac..e497b5a66df1 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-transition-layout.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-transition-layout.ts @@ -18,7 +18,9 @@ import type { ProviderTimelineTextStreams } from './provider-timeline-text-strea const SETTLEMENT_RESERVED_BYTES = 64 * 1024 /** Text owed ahead of the event lands first; a row-writing event also ends the messages it - * separates: anonymous ones of its producer, every stream of a turn it ends, all on a session end. */ + * separates: anonymous ones of its producer, every stream of a turn it ends, all on a session end. + * A turn another writer settled stops its streams until that turn's boundary: its own end, or + * the next turn's open. */ export function planProviderTimelineBarrier( input: { streams: ProviderTimelineTextStreams @@ -40,11 +42,20 @@ export function planProviderTimelineBarrier( streams.planRelease(plan, () => true) return } - if (event.type === 'turn.end' || event.type === 'turn.open') { + if (event.type === 'turn.end' || event.type === 'turn.open' || event.type === 'turn.settled') { const ending = - event.type === 'turn.end' && event.turn !== undefined - ? context.rows.turn(providerKey(event.turn)).itemId - : state.open?.itemId + event.type === 'turn.settled' + ? event.turn.itemId + : event.type === 'turn.end' && event.turn !== undefined + ? context.rows.turn(providerKey(event.turn)).itemId + : state.open?.itemId + if (event.type === 'turn.settled') { + streams.planStop(plan, event.turn.itemId) + } else if (event.type === 'turn.open') { + streams.planBoundary(plan, null) + } else if (ending !== undefined) { + streams.planBoundary(plan, ending) + } streams.planRelease( plan, (stream) => diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-turn-decisions.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-turn-decisions.ts index f242a7573e7b..9584ef55ad85 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-turn-decisions.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-turn-decisions.ts @@ -31,7 +31,8 @@ import { } from './provider-timeline-rows' import { providerTimelineSettlement, - runningProviderTimelineTurns + runningProviderTimelineTurns, + type ProviderTimelineSettlementEnding } from './provider-timeline-settlement' import type { ProviderTimelineOpenTurn, @@ -80,11 +81,14 @@ export function decideTurnOpen( settle: { what: 'turn-superseded', resolve: (journal) => - providerTimelineSettlement(journal, { turnItemId: open.itemId }, [open], { - state: 'interrupted', - completedAt: event.at, - outcome: 'superseded' - }) + providerTimelineSettlement( + journal, + { turnItemId: open.itemId }, + { + turns: [open], + end: { state: 'interrupted', completedAt: event.at, outcome: 'superseded' } + } + ) } } : {}), @@ -114,26 +118,50 @@ export function decideTurnEnd( input: ProviderTimelineDecisionInput, event: Extract ): ProviderTimelineDecision { - const { state } = input + const { state, journal } = input const turn = event.turn === undefined ? state.open : input.context.rows.turn(providerKey(event.turn)) if (!turn) { return { dropped: 'no-turn' } } - // Only the open turn ends here; any other is already over (superseded, or ended by its writer). - if (state.open?.itemId !== turn.itemId) { + // A turn this run opened, or one the journal holds (open, superseded, or ended by another + // writer): its end settles whatever is left, and a settled row is not written again. + const known = + state.open?.itemId === turn.itemId || + state.latest?.itemId === turn.itemId || + !journal || + providerTimelineTurnRowState(journal, turn.itemId) !== 'absent' + if (!known) { return { dropped: 'turn-unknown' } } + return endedTurn(turn, { + turns: [turn], + end: { + state: event.state, + completedAt: event.at, + ...(event.outcome !== undefined ? { outcome: event.outcome } : {}), + ...(event.durationMs !== undefined ? { durationMs: event.durationMs } : {}) + } + }) +} + +/** The open turn another writer settled: its work ends as the provider's end would end it, and + * its row stays as that writer left it. */ +export function decideTurnSettled( + event: Extract +): ProviderTimelineDecision { + return endedTurn(event.turn) +} + +/** The one end of a turn: the journal-derived settlement of its open work, then the state. */ +function endedTurn( + turn: ProviderTimelineTurnRef, + ending?: ProviderTimelineSettlementEnding +): ProviderTimelineDecision { return { settle: { - what: 'turn-end', - resolve: (journal) => - providerTimelineSettlement(journal, { turnItemId: turn.itemId }, [turn], { - state: event.state, - completedAt: event.at, - ...(event.outcome !== undefined ? { outcome: event.outcome } : {}), - ...(event.durationMs !== undefined ? { durationMs: event.durationMs } : {}) - }) + what: ending ? 'turn-end' : 'turn-settled', + resolve: (journal) => providerTimelineSettlement(journal, { turnItemId: turn.itemId }, ending) }, commit: (next) => next.endTurn(turn) } @@ -253,12 +281,10 @@ export function decideSessionEnd( settle: { what: 'session-end', resolve: (journal) => - providerTimelineSettlement( - journal, - 'session', - runningProviderTimelineTurns(journal), - event.verdict - ) + providerTimelineSettlement(journal, 'session', { + turns: runningProviderTimelineTurns(journal), + end: event.verdict + }) }, commit: (next) => next.endSession() } From 8b4b497bc708195117706fb1f9b86049cfc1ccad Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 01:25:47 -0700 Subject: [PATCH 46/62] Adopt ACP load history only into an empty journal, chosen by the translator's creator The translator now takes `adopt` from whoever creates it instead of guessing from the journal (which reads empty before the sink binds). Without adoption, history a provider replays during session/load is dropped except its context usage. With adoption, history becomes ordinary events with provider or position ids and the user's saved messages become `input.history`, so re-running an interrupted adoption lands the same rows. The translator no longer reads the journal: it lives exactly as long as its provider child. Its tool snapshots are bounded by the assembler's open budget, and a small record of each tool's turn keeps a late background-task notice beside the tool that started it. --- src/main/acp/acp-background-task-timeline.ts | 42 +-- src/main/acp/acp-history-adoption.ts | 60 +++++ src/main/acp/acp-journal-turns.ts | 62 ----- src/main/acp/acp-replay-user-messages.ts | 43 --- src/main/acp/acp-session-update.ts | 52 +--- ...-timeline-background-task-evidence.test.ts | 57 +--- ...p-timeline-background-task-results.test.ts | 2 +- .../acp/acp-timeline-background-tasks.test.ts | 94 +++---- src/main/acp/acp-timeline-dialect.test.ts | 1 - .../acp/acp-timeline-fixture.test-support.ts | 17 +- src/main/acp/acp-timeline-generic.test.ts | 28 +- src/main/acp/acp-timeline-joins.test.ts | 106 ++------ src/main/acp/acp-timeline-recordings.test.ts | 65 ++++- src/main/acp/acp-timeline-recovery.test.ts | 64 +---- src/main/acp/acp-timeline-requests.ts | 7 +- src/main/acp/acp-timeline-translator.ts | 253 +++++++++--------- src/main/acp/acp-tool-timeline.ts | 57 ++-- 17 files changed, 409 insertions(+), 601 deletions(-) create mode 100644 src/main/acp/acp-history-adoption.ts delete mode 100644 src/main/acp/acp-journal-turns.ts delete mode 100644 src/main/acp/acp-replay-user-messages.ts diff --git a/src/main/acp/acp-background-task-timeline.ts b/src/main/acp/acp-background-task-timeline.ts index 0156a10c223f..058223ac1520 100644 --- a/src/main/acp/acp-background-task-timeline.ts +++ b/src/main/acp/acp-background-task-timeline.ts @@ -1,10 +1,6 @@ -import type { AgentJournalRenderItem } from '../../shared/agent-session-journal-types' import { BoundedMap } from '../../shared/bounded-map' import { backgroundTaskJournalBody } from '../../shared/native-chat-background-task-row' -import { - isBackgroundTaskBlock, - type NativeChatBackgroundTaskBlock -} from '../../shared/native-chat-types' +import type { NativeChatBackgroundTaskBlock } from '../../shared/native-chat-types' import { boundInlineText, DEFAULT_JOURNAL_PAYLOAD_LIMITS @@ -14,9 +10,10 @@ import type { ProviderTimelineJoin } from '../native-chat/agent-session-timeline/provider-timeline-event' import type { AcpBackgroundTaskUpdate } from './acp-dialects/acp-dialect' -import { acpJournalToolTurn } from './acp-journal-turns' -/** Partial provider snapshots only; the assembler owns settlement and durable placement. */ +/** Partial provider snapshots only; the assembler owns settlement and durable placement. A task + * the bound evicted (128 others updated since) loses its earlier label, parent tool and output + * file on its next update; its row keeps the turn it was placed in. */ export class AcpBackgroundTaskTimeline { private readonly snapshots = new BoundedMap({ maxEntries: 128, @@ -24,19 +21,16 @@ export class AcpBackgroundTaskTimeline { sizeOf: (block, key) => Buffer.byteLength(key) + Buffer.byteLength(JSON.stringify(block)) }) - constructor( - private readonly journalItems: () => readonly AgentJournalRenderItem[], - private readonly toolTurn: (callId: string) => string | undefined - ) {} + constructor(private readonly toolTurn: (callId: string) => string | undefined) {} translate( updates: AcpBackgroundTaskUpdate[], join: ProviderTimelineJoin, - replay = false + history = false ): ProviderTimelineEvent[] { return updates.map((update) => { const { fallbackLabel, ...fields } = update - const previous = this.snapshots.get(update.taskId) ?? this.persisted(update.taskId) + const previous = this.snapshots.get(update.taskId) const block: NativeChatBackgroundTaskBlock = { type: 'background-task', kind: 'unknown', @@ -45,7 +39,7 @@ export class AcpBackgroundTaskTimeline { label: update.label ?? previous?.label ?? fallbackLabel ?? update.taskId, // A historical launch proves no current liveness; explicit outcomes still settle it. state: - replay && ['working', 'monitoring', 'waiting'].includes(update.state) + history && ['working', 'monitoring', 'waiting'].includes(update.state) ? 'unverifiable' : update.state } @@ -56,10 +50,7 @@ export class AcpBackgroundTaskTimeline { } } this.snapshots.set(block.taskId, block) - const turn = block.parentToolUseId - ? (this.toolTurn(block.parentToolUseId) ?? - acpJournalToolTurn(this.journalItems(), block.parentToolUseId)) - : undefined + const turn = block.parentToolUseId ? this.toolTurn(block.parentToolUseId) : undefined return { type: 'item.update', item: `background-task:${block.taskId}`, @@ -68,19 +59,4 @@ export class AcpBackgroundTaskTimeline { } }) } - - private persisted(taskId: string): NativeChatBackgroundTaskBlock | undefined { - for (const row of this.journalItems()) { - if (row.body.kind !== 'message') { - continue - } - const block = row.body.blocks.find( - (candidate) => isBackgroundTaskBlock(candidate) && candidate.taskId === taskId - ) - if (block && isBackgroundTaskBlock(block)) { - return block - } - } - return undefined - } } diff --git a/src/main/acp/acp-history-adoption.ts b/src/main/acp/acp-history-adoption.ts new file mode 100644 index 000000000000..46c5181c5050 --- /dev/null +++ b/src/main/acp/acp-history-adoption.ts @@ -0,0 +1,60 @@ +import type { ProviderTimelineEvent } from '../native-chat/agent-session-timeline/provider-timeline-event' +import { + boundInlineText, + DEFAULT_JOURNAL_PAYLOAD_LIMITS +} from '../native-chat/agent-session-journal/journal-payload-bounds' +import type { SessionUpdate } from './generated/acp-protocol.generated' + +type UserMessageChunk = Extract + +/** Saved history replayed into an empty journal (an adopted session). Ids come from the provider + * or from the replay's own order, so re-running an interrupted adoption lands the same rows. */ +export class AcpHistoryAdoption { + /** The history turn later frames that name none belong to. */ + turn?: string + private serial = 0 + private user?: { messageId: string | null; text: string } + + constructor(private readonly thread: string) {} + + turnFor(providerTurn: string | undefined): string { + return providerTurn ?? this.turn ?? `history:${this.serial++}` + } + + /** A chunk of the saved user message already buffered. */ + continuesUser(update: UserMessageChunk): boolean { + return this.user !== undefined && this.user.messageId === (update.messageId ?? null) + } + + /** Buffers a saved user message: `input.history` takes its whole body once. */ + observeUser(update: UserMessageChunk): void { + const messageId = update.messageId ?? null + const previous = this.continuesUser(update) ? (this.user?.text ?? '') : '' + const text = update.content.type === 'text' ? update.content.text : '' + this.user = { + messageId, + text: boundInlineText(previous + text, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text + } + } + + /** The buffered user message, as the opener of `turn`. */ + takeUser(turn: string): ProviderTimelineEvent[] { + const user = this.user + this.user = undefined + if (!user?.text) { + return [] + } + return [ + { + type: 'input.history', + item: `history-user:${JSON.stringify([turn, user.messageId])}`, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: user.text }] }, + join: { thread: this.thread, turn } + } + ] + } + + get holdsUser(): boolean { + return this.user !== undefined + } +} diff --git a/src/main/acp/acp-journal-turns.ts b/src/main/acp/acp-journal-turns.ts deleted file mode 100644 index 6d1b95c71274..000000000000 --- a/src/main/acp/acp-journal-turns.ts +++ /dev/null @@ -1,62 +0,0 @@ -import type { AgentJournalRenderItem } from '../../shared/agent-session-journal-types' -import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' -import { providerTimelineKeyPart } from '../native-chat/agent-session-timeline/provider-timeline-identity' - -/** Read provider ids back from the assembler's existing turn records. */ -export function acpJournalTurnKey(row: AgentJournalRenderItem): string | undefined { - const turn = readAgentJournalTurn(row.body) - if (!turn) { - return undefined - } - if (!turn.turnId.startsWith('p:')) { - return turn.turnId - } - const value = turn.turnId.slice(turn.turnId.lastIndexOf(':') + 1) - try { - return decodeURIComponent(value) - } catch { - return undefined - } -} - -export function acpJournalToolTurn( - rows: readonly AgentJournalRenderItem[], - callId: string -): string | undefined { - const tool = rows.find((row) => row.body.kind === 'tool-call' && row.body.callId === callId) - const turnItemId = tool?.turnScope?.kind === 'turn' ? tool.turnScope.turnItemId : undefined - const turn = rows.find((row) => row.itemId === turnItemId) - return turn && acpJournalTurnKey(turn) -} - -export function acpJournalTurnIsSettled( - rows: readonly AgentJournalRenderItem[], - key: string -): boolean { - return rows.some((row) => { - const turn = readAgentJournalTurn(row.body) - return ( - turn !== null && - turn.state !== 'running' && - (turn.turnId === key || turn.turnId.endsWith(`:${providerTimelineKeyPart(key)}`)) - ) - }) -} - -export function acpJournalTurnHasUser( - rows: readonly AgentJournalRenderItem[], - key: string -): boolean { - const record = rows.find((row) => acpJournalTurnKey(row) === key) - if (!record) { - return false - } - const turn = readAgentJournalTurn(record.body) - return rows.some( - (row) => - row.body.kind === 'message' && - row.body.role === 'user' && - (row.itemId === turn?.userItemId || - (row.turnScope?.kind === 'turn' && row.turnScope.turnItemId === record.itemId)) - ) -} diff --git a/src/main/acp/acp-replay-user-messages.ts b/src/main/acp/acp-replay-user-messages.ts deleted file mode 100644 index 189d2e6aead5..000000000000 --- a/src/main/acp/acp-replay-user-messages.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types' -import { - boundInlineText, - DEFAULT_JOURNAL_PAYLOAD_LIMITS -} from '../native-chat/agent-session-journal/journal-payload-bounds' -import type { SessionUpdate } from './generated/acp-protocol.generated' - -/** User text has no grammar stream channel; adopted history needs a bounded whole-body snapshot. */ -export class AcpReplayUserMessages { - private current?: { id: string | null; text: string } - - observe(update: SessionUpdate): { startsMessage: boolean; body?: AgentJournalMessageItem } { - if (update.sessionUpdate !== 'user_message_chunk') { - if ( - [ - 'agent_message_chunk', - 'agent_thought_chunk', - 'tool_call', - 'tool_call_update', - 'plan' - ].includes(update.sessionUpdate) - ) { - this.current = undefined - } - return { startsMessage: false } - } - const id = update.messageId ?? null - const startsMessage = !this.current || this.current.id !== id - const previous = startsMessage ? '' : this.current!.text - if (update.content.type !== 'text') { - return { startsMessage } - } - const text = boundInlineText( - previous + update.content.text, - DEFAULT_JOURNAL_PAYLOAD_LIMITS - ).text - this.current = { id, text } - return { - startsMessage, - body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text }] } - } - } -} diff --git a/src/main/acp/acp-session-update.ts b/src/main/acp/acp-session-update.ts index c398c129dcd6..74df1762fa10 100644 --- a/src/main/acp/acp-session-update.ts +++ b/src/main/acp/acp-session-update.ts @@ -1,4 +1,3 @@ -import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types' import type { ProviderTimelineEvent } from '../native-chat/agent-session-timeline/provider-timeline-event' import { boundInlineText, @@ -14,38 +13,26 @@ export function acpSessionUpdate( notification: SessionNotification, turn: string | undefined, at: number, - replay: boolean, - tools: AcpToolTimeline, - dialect: AcpDialect, - backgroundTasks: AcpBackgroundTaskTimeline, - replayUserBody?: AgentJournalMessageItem, - messageKey?: string + context: { + /** Adopted history: background-task starts in it prove no current liveness. */ + history: boolean + tools: AcpToolTimeline + dialect: AcpDialect + backgroundTasks: AcpBackgroundTaskTimeline + messageKey?: string + } ): ProviderTimelineEvent[] { const update = notification.update const join = { join: { thread: notification.sessionId, ...(turn === undefined ? {} : { turn }) } } switch (update.sessionUpdate) { case 'agent_message_chunk': case 'agent_thought_chunk': - if (replay && messageKey && update.content.type === 'text') { - return [ - { - type: 'item.update', - item: messageKey, - body: { - kind: 'message', - role: update.sessionUpdate === 'agent_thought_chunk' ? 'reasoning' : 'assistant', - blocks: [{ type: 'text', text: update.content.text }] - }, - ...join - } - ] - } return update.content.type === 'text' ? [ { type: 'text.delta', - item: messageKey - ? { id: messageKey } + item: context.messageKey + ? { id: context.messageKey } : update.messageId ? { id: `message:${update.messageId}` } : { stream: update.sessionUpdate }, @@ -56,29 +43,18 @@ export function acpSessionUpdate( ] : [{ type: 'provider.frame', frameKind: update.sessionUpdate, payload: update, ...join }] case 'user_message_chunk': - return replay && update.content.type === 'text' - ? [ - { - type: 'item.update', - item: `replay-user:${turn}:${update.messageId ?? ''}`, - body: replayUserBody ?? { - kind: 'message', - role: 'user', - blocks: [{ type: 'text', text: update.content.text }] - }, - ...join - } - ] - : [] + // A live echo of the send is the send's own row; adopted history comes as `input.history`. + return [] case 'tool_call': case 'tool_call_update': { + const { tools, dialect, backgroundTasks } = context const events = tools.translate(update, dialect, join.join) const tool = events[0] const tasks = tool && 'body' in tool && tool.body?.kind === 'tool-call' ? (dialect.toolBackgroundTasks?.(update, tool.body) ?? []) : [] - return [...events, ...backgroundTasks.translate(tasks, join.join, replay)] + return [...events, ...backgroundTasks.translate(tasks, join.join, context.history)] } case 'plan': return [ diff --git a/src/main/acp/acp-timeline-background-task-evidence.test.ts b/src/main/acp/acp-timeline-background-task-evidence.test.ts index 7b98bcce2a0d..5abadae51958 100644 --- a/src/main/acp/acp-timeline-background-task-evidence.test.ts +++ b/src/main/acp/acp-timeline-background-task-evidence.test.ts @@ -110,12 +110,11 @@ describe('background task outcome evidence', () => { ) }) - it('replays the captured task and queue notices into one completed row beside its launch tool', async () => { + it('joins the captured task and queue notices into one completed row beside its launch tool', async () => { const fixture = await openAcpFixtureRig() const tail = await launch(fixture) const [before] = await taskRows(fixture) expect(before?.block.state).toBe('working') - fixture.restart() await fixture.feed(tail) const [after] = await taskRows(fixture) expect(await taskRows(fixture)).toHaveLength(1) @@ -130,7 +129,7 @@ describe('background task outcome evidence', () => { expect((await taskRows(fixture))[0]?.block.state).toBe('done') }) - it.each(['partial', 'restart', 'eviction'] as const)( + it.each(['partial', 'eviction'] as const)( 'SF2 settles named tasks on a completed kill call after %s', async (boundary) => { const fixture = await openAcpFixtureRig() @@ -144,12 +143,9 @@ describe('background task outcome evidence', () => { rawInput: { task_id: taskId, task_ids: [taskId, 'other-task'] } }) await fixture.rig.rows() - if (boundary === 'restart') { - fixture.restart() - } if (boundary === 'eviction') { - for (let index = 0; index < 70; index += 1) { - // Pressure only the translator cache; the target still goes through the journal. + for (let index = 0; index < 200; index += 1) { + // Pressure only the translator's snapshots: settled ones go first, the running call stays. fixture.lane().notification( 'session/update', { @@ -159,7 +155,7 @@ describe('background task outcome evidence', () => { sessionUpdate: 'tool_call', title: 'Read file', toolCallId: `unrelated-${index}`, - status: 'in_progress', + status: 'completed', name: 'read_file', rawInput: { content: 'x'.repeat(40000) }, rawOutput: 'y'.repeat(40000) @@ -202,7 +198,7 @@ describe('background task outcome evidence', () => { it.each(['launch', 'notification'] as const)( 'SF3 treats replayed %s evidence as unverifiable and accepts a later completion', async (source) => { - const fixture = await openAcpFixtureRig() + const fixture = await openAcpFixtureRig({ adopt: true }) fixture.lane().beginLoad() if (source === 'launch') { tool( @@ -225,7 +221,6 @@ describe('background task outcome evidence', () => { } fixture.finishLoad() expect((await taskRows(fixture))[0]?.block.state).toBe('unverifiable') - fixture.restart() taskNotice(fixture, taskId, false, true) expect((await taskRows(fixture))[0]?.block.state).toBe('done') } @@ -234,7 +229,7 @@ describe('background task outcome evidence', () => { it.each(['notification', 'kill'] as const)( 'SF3 preserves the explicit %s outcome included in adopted history', async (completion) => { - const fixture = await openAcpFixtureRig() + const fixture = await openAcpFixtureRig({ adopt: true }) fixture.lane().beginLoad() tool( fixture, @@ -270,42 +265,4 @@ describe('background task outcome evidence', () => { ) } ) - - it('SF3 adopts a missing task into unfinished journal history without claiming it is live', async () => { - const fixture = await openAcpFixtureRig() - fixture.apply( - fixture.lane().notification( - 'session/update', - { - sessionId: 'session-1', - _meta: { promptId: 'historic-turn' }, - update: { - sessionUpdate: 'tool_call', - title: 'Historical launch', - toolCallId: 'historic-tool', - status: 'in_progress' - } - }, - 1000 - ) - ) - await fixture.rig.rows() - fixture.lane().beginLoad() - tool( - fixture, - { - toolCallId: 'historic-tool', - status: 'completed', - rawOutput: { - type: 'BackgroundTaskStarted', - task_id: taskId, - task_type: 'bash', - command: 'sleep 99' - } - }, - true - ) - fixture.finishLoad() - expect((await taskRows(fixture))[0]?.block.state).toBe('unverifiable') - }) }) diff --git a/src/main/acp/acp-timeline-background-task-results.test.ts b/src/main/acp/acp-timeline-background-task-results.test.ts index 49cba692cd36..5a863b45cdfd 100644 --- a/src/main/acp/acp-timeline-background-task-results.test.ts +++ b/src/main/acp/acp-timeline-background-task-results.test.ts @@ -16,7 +16,7 @@ describe('background task results in adopted history', () => { ] as const)( 'settles replay from a TaskOutput result with status %s and exit code %s', async (status, exitCode, state) => { - const fixture = await openAcpFixtureRig() + const fixture = await openAcpFixtureRig({ adopt: true }) const lane = fixture.lane() lane.beginLoad() for (const [index, rawOutput] of [ diff --git a/src/main/acp/acp-timeline-background-tasks.test.ts b/src/main/acp/acp-timeline-background-tasks.test.ts index eb1c926f251b..798e96cdaeff 100644 --- a/src/main/acp/acp-timeline-background-tasks.test.ts +++ b/src/main/acp/acp-timeline-background-tasks.test.ts @@ -55,51 +55,43 @@ function completed(snapshot: TaskCompletionEvidence = { exit_code: 0 }) { } describe('Grok background tasks through the shared timeline', () => { - it.each(['end', 'reset'] as const)( - 'renders the recorded task outside turn settlement and marks it unverifiable on session %s after restart', - async (boundary) => { - const fixture = await openAcpFixtureRig() - await fixture.feed(await runningFrames()) - const [before] = await taskRows(fixture) - expect(before?.block).toMatchObject({ - taskId, - kind: 'command', - state: 'working', - parentToolUseId: 'call-1', - outputFile: '/workspace/file-1' - }) - expect(before?.row.turnScope?.kind).toBe('turn') - expect((await fixture.rig.turns()).map((turn) => turn.state)).toEqual([ - 'completed', - 'completed' - ]) - expect( - (await fixture.rig.rows()).find((row) => row.body.kind === 'tool-call')?.body - ).toMatchObject({ state: 'completed' }) - if (!before || before.row.body.kind !== 'message') { - throw new Error('Missing background-task row') - } - const render = deriveNativeChatRowContent(before.row.body.blocks) - expect(render.backgroundTasks).toEqual([before.block]) - expect(render.prose).toEqual([]) - - fixture.restart() - fixture.apply([ - boundary === 'end' - ? { type: 'session.ended', verdict: { state: 'unverifiable' } } - : { type: 'session.reset', namespace: 'replacement-session' } - ]) - const [after] = await taskRows(fixture) - expect(after?.row.itemId).toBe(before.row.itemId) - expect(after?.block.state).toBe('unverifiable') - expect(after?.row.body).toMatchObject({ - blocks: [{ type: 'text', text: backgroundTaskFallbackText(after!.block) }, after!.block] - }) + it('renders the recorded task outside turn settlement and marks it unverifiable when the session ends', async () => { + const fixture = await openAcpFixtureRig() + await fixture.feed(await runningFrames()) + const [before] = await taskRows(fixture) + expect(before?.block).toMatchObject({ + taskId, + kind: 'command', + state: 'working', + parentToolUseId: 'call-1', + outputFile: '/workspace/file-1' + }) + expect(before?.row.turnScope?.kind).toBe('turn') + expect((await fixture.rig.turns()).map((turn) => turn.state)).toEqual([ + 'completed', + 'completed' + ]) + expect( + (await fixture.rig.rows()).find((row) => row.body.kind === 'tool-call')?.body + ).toMatchObject({ state: 'completed' }) + if (!before || before.row.body.kind !== 'message') { + throw new Error('Missing background-task row') } - ) + const render = deriveNativeChatRowContent(before.row.body.blocks) + expect(render.backgroundTasks).toEqual([before.block]) + expect(render.prose).toEqual([]) + + fixture.apply([{ type: 'session.ended', verdict: { state: 'unverifiable' } }]) + const [after] = await taskRows(fixture) + expect(after?.row.itemId).toBe(before.row.itemId) + expect(after?.block.state).toBe('unverifiable') + expect(after?.row.body).toMatchObject({ + blocks: [{ type: 'text', text: backgroundTaskFallbackText(after!.block) }, after!.block] + }) + }) it.each(['x.ai/', '_x.ai/'])( - 'joins %s task notifications and the tool result into one row and keeps its origin after restart', + 'joins %s task notifications and the tool result into one row that keeps its origin', async (prefix) => { const fixture = await openAcpFixtureRig() const frames = await runningFrames() @@ -109,7 +101,6 @@ describe('Grok background tasks through the shared timeline', () => { const [before] = await taskRows(fixture) expect(await taskRows(fixture)).toHaveLength(1) expect(before?.block.label).toBe('Sleep then write bg-marker.txt') - fixture.restart() fixture.apply( fixture .lane() @@ -150,9 +141,10 @@ describe('Grok background tasks through the shared timeline', () => { } ) - it('recovers partial task metadata from the journal after bounded snapshots are evicted', async () => { + it('keeps the row and placement of a task whose snapshot the bound evicted', async () => { const fixture = await openAcpFixtureRig() await fixture.feed(await runningFrames()) + const [before] = await taskRows(fixture) for (let index = 0; index < 130; index += 1) { fixture.apply( fixture @@ -160,19 +152,14 @@ describe('Grok background tasks through the shared timeline', () => { .notification('x.ai/task_backgrounded', started(`task-${index}`), 1500 + index) ) } - await fixture.rig.rows() fixture.apply(fixture.lane().notification('x.ai/task_completed', completed(), 2000)) const original = (await taskRows(fixture)).find((task) => task.block.taskId === taskId) - expect(original?.block).toMatchObject({ - state: 'done', - parentToolUseId: 'call-1', - outputFile: '/workspace/file-1', - kind: 'command' - }) - expect(original && backgroundTaskFallbackText(original.block)).toContain('finished') + expect(original?.row.itemId).toBe(before?.row.itemId) + expect(original?.row.turnScope).toEqual(before?.row.turnScope) + expect(original?.block.state).toBe('done') }) - it('places a first task notice after restart beside its original tool while a different turn is active', async () => { + it('places a first task notice beside its original tool after its turn ended and another began', async () => { const fixture = await openAcpFixtureRig() await fixture.feed((await runningFrames()).slice(0, 5)) fixture.apply( @@ -187,7 +174,6 @@ describe('Grok background tasks through the shared timeline', () => { ) fixture.apply(fixture.lane().promptResult('p1:3', { stopReason: 'end_turn' }, 1300)) const original = (await fixture.rig.rows()).find((row) => row.body.kind === 'tool-call') - fixture.restart() fixture.apply(fixture.lane().openPrompt('next', 1400).events) fixture.apply( fixture.lane().notification( diff --git a/src/main/acp/acp-timeline-dialect.test.ts b/src/main/acp/acp-timeline-dialect.test.ts index 23fd7024ff13..f58ce2618d8c 100644 --- a/src/main/acp/acp-timeline-dialect.test.ts +++ b/src/main/acp/acp-timeline-dialect.test.ts @@ -14,7 +14,6 @@ async function dialectRig() { const rig = await openProviderTimelineRig() const translator = new AcpTimelineTranslator({ sessionId: 'provider-1', - journalItems: () => rig.journal.snapshot().items, dialect: GROK_ACP_DIALECT }) const apply = (events: ProviderTimelineEvent[]) => { diff --git a/src/main/acp/acp-timeline-fixture.test-support.ts b/src/main/acp/acp-timeline-fixture.test-support.ts index 3e2bdd39afcb..24e94f926a3a 100644 --- a/src/main/acp/acp-timeline-fixture.test-support.ts +++ b/src/main/acp/acp-timeline-fixture.test-support.ts @@ -65,15 +65,17 @@ function fixtureAnswer( } } -/** Captured frames go through translation, assembly and the real serialized journal. */ -export async function openAcpFixtureRig() { +/** Captured frames go through translation, assembly and the real serialized journal. `adopt` is + * the creator's decision that `session/load` history goes into an empty journal. */ +export async function openAcpFixtureRig(options: { adopt?: boolean } = {}) { const rig = await openProviderTimelineRig() let providerSessionId = 'session-1' + let adopt = options.adopt ?? false const translator = () => new AcpTimelineTranslator({ sessionId: providerSessionId, - journalItems: () => rig.journal.snapshot().items, - dialect: GROK_ACP_DIALECT + dialect: GROK_ACP_DIALECT, + adopt }) let lane = translator() const requests = new Map< @@ -90,7 +92,6 @@ export async function openAcpFixtureRig() { const { message, direction } = frame const at = 1000 + index if (direction === 'out' && message.method === 'session/load') { - await rig.rows() lane.beginLoad() } else if (direction === 'out' && message.method === 'session/prompt') { const session = z.object({ sessionId: z.string() }).parse(message.params) @@ -163,8 +164,10 @@ export async function openAcpFixtureRig() { apply, feed, lane: () => lane, - restart: () => { - rig.assembler = rig.restart({ generation: 'gen-2' }) + /** A new provider child: the sweep, a new generation's assembler and a new translator. */ + restart: async (next: { adopt?: boolean } = {}) => { + await rig.restart() + adopt = next.adopt ?? false lane = translator() }, finishLoad: () => apply(lane.finishLoad(2000)) diff --git a/src/main/acp/acp-timeline-generic.test.ts b/src/main/acp/acp-timeline-generic.test.ts index 2b9ebb51dcd3..a18c67b03b04 100644 --- a/src/main/acp/acp-timeline-generic.test.ts +++ b/src/main/acp/acp-timeline-generic.test.ts @@ -11,12 +11,9 @@ import { AcpTimelineTranslator, acpTurnEnd } from './acp-timeline-translator' afterEach(closeProviderTimelineRigs) -async function genericRig() { +async function genericRig(options: { adopt?: boolean } = {}) { const rig = await openProviderTimelineRig() - const translator = new AcpTimelineTranslator({ - sessionId: 'provider-1', - journalItems: () => rig.journal.snapshot().items - }) + const translator = new AcpTimelineTranslator({ sessionId: 'provider-1', ...options }) const apply = (events: ProviderTimelineEvent[]) => { for (const event of events) { expect(rig.assembler.apply(event).admission.accepted).toBe(true) @@ -220,7 +217,7 @@ describe('generic ACP translation', () => { }) it('adopts generic unmarked load history and never invents a success verdict', async () => { - const { rig, translator, apply, update } = await genericRig() + const { rig, translator, apply, update } = await genericRig({ adopt: true }) translator.beginLoad() update({ sessionUpdate: 'user_message_chunk', content: { type: 'text', text: 'User' } }) update({ sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'History' } }) @@ -232,6 +229,23 @@ describe('generic ACP translation', () => { ).toEqual(['user', 'assistant']) }) + it('drops unmarked load history its creator does not adopt, except context usage', () => { + const translator = new AcpTimelineTranslator({ sessionId: 'provider-1' }) + translator.beginLoad() + const events = [ + { sessionUpdate: 'user_message_chunk', content: { type: 'text', text: 'User' } }, + { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'History' } }, + { sessionUpdate: 'tool_call', toolCallId: 'old-tool', title: 'Read', status: 'completed' }, + { sessionUpdate: 'usage_update', used: 5000, size: 256000 } + ].flatMap((update) => + translator.notification('session/update', { sessionId: 'provider-1', update }, 1100) + ) + expect(events).toEqual([ + expect.objectContaining({ type: 'context.usage', join: { thread: 'provider-1' } }) + ]) + expect(translator.finishLoad(1200)).toEqual([]) + }) + it('requires an explicit journal decision for marked replay and uses typed standard events', async () => { const { rig, translator, apply } = await genericRig() expect( @@ -268,7 +282,7 @@ describe('generic ACP translation', () => { expect((await rig.rows()).some((row) => messageText(row.body) === 'typed')).toBe(true) }) it('adopts streamed user history across multiple turns without replacing earlier users', async () => { - const { rig, translator, apply, update } = await genericRig() + const { rig, translator, apply, update } = await genericRig({ adopt: true }) translator.beginLoad() update({ sessionUpdate: 'user_message_chunk', diff --git a/src/main/acp/acp-timeline-joins.test.ts b/src/main/acp/acp-timeline-joins.test.ts index af14c305f1e3..722ccfb1a8f7 100644 --- a/src/main/acp/acp-timeline-joins.test.ts +++ b/src/main/acp/acp-timeline-joins.test.ts @@ -1,23 +1,20 @@ import { afterEach, describe, expect, it } from 'vitest' -import { createCodexProviderTimelineIdentityScheme } from '../codex/codex-provider-timeline-identity' import { closeProviderTimelineRigs, + GENERATION, messageText, - openProviderTimelineRig + openProviderTimelineRig, + providerItemId } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' -import type { ProviderTimelineEvent } from '../native-chat/agent-session-timeline/provider-timeline-event' import { AcpTimelineTranslator } from './acp-timeline-translator' afterEach(closeProviderTimelineRigs) -describe('ACP joins through the v3 assembler', () => { +describe('ACP joins through the assembler', () => { it('keeps provider message and tool ids apart across ACP sessions', async () => { const rig = await openProviderTimelineRig() for (const sessionId of ['session-a', 'session-b']) { - const translator = new AcpTimelineTranslator({ - sessionId, - journalItems: () => rig.journal.snapshot().items - }) + const translator = new AcpTimelineTranslator({ sessionId }) for (const update of [ { sessionUpdate: 'agent_message_chunk', @@ -50,85 +47,34 @@ describe('ACP joins through the v3 assembler', () => { ]) }) - it('resumes named text in its existing ordinal row and closes the same joined stream', async () => { - const rig = await openProviderTimelineRig({ - scheme: createCodexProviderTimelineIdentityScheme({ - sessionId: 'session-timeline', - primaryThreadId: () => 'provider-1' - }), - ownThread: () => 'provider-1' - }) - const translator = new AcpTimelineTranslator({ - sessionId: 'provider-1', - journalItems: () => rig.journal.snapshot().items - }) - const apply = (events: ProviderTimelineEvent[]) => { - for (const event of events) { - expect(rig.assembler.apply(event).admission.accepted).toBe(true) - } - } - const chunk = (text: string) => - translator.notification( - 'session/update', - { - sessionId: 'provider-1', - update: { - sessionUpdate: 'agent_message_chunk', - messageId: 'm1', - content: { type: 'text', text } - } - }, - 1100 - ) - apply(translator.openPrompt('send-1', 1000).events) - apply(chunk('Hel')) - const before = (await rig.rows()).find((row) => row.body.kind === 'message')! - rig.assembler = rig.restart({ generation: 'gen-2' }) - const delta = chunk('lo')[0]! - if (delta.type !== 'text.delta') { - throw new Error('Expected a translated text delta') - } - expect(delta.join).toEqual({ thread: 'provider-1', turn: 'prompt:send-1' }) - apply([delta, { type: 'text.close', item: delta.item, join: delta.join }]) - expect(rig.assembler.apply(delta).dropped).toBe('item-settled') - const messages = (await rig.rows()).filter((row) => row.body.kind === 'message') - expect(messages).toHaveLength(1) - expect(messages[0]!.itemId).toBe(before.itemId) - expect(messageText(messages[0]!.body)).toBe('Hello') - }) - - it('lets the assembler recover request incarnations and the open turn after a restart', async () => { + it('keeps a request id a new child reuses apart from the old child', async () => { const rig = await openProviderTimelineRig() - const build = () => - new AcpTimelineTranslator({ - sessionId: 'provider-1', - journalItems: () => rig.journal.snapshot().items - }) const params = { sessionId: 'provider-1', toolCall: { toolCallId: 'call-1', title: 'Run?' }, options: [{ optionId: 'allow', name: 'Allow', kind: 'allow_once' }] } - const translator = build() - for (const event of translator.openPrompt('send-1', 1000).events) { - rig.assembler.apply(event) - } - const event = translator.request('session/request_permission', params, 0).events[0]! - if (event.type !== 'request.open') { - throw new Error('Expected a translated request') - } - expect(event.join).toEqual({ thread: 'provider-1' }) - rig.assembler.apply(event) - rig.assembler.apply({ type: 'request.withdrawn', request: event.request }) - const first = (await rig.rows()).find((row) => row.body.kind === 'approval')! - rig.assembler = rig.restart({ generation: 'gen-2' }) - for (const reopened of build().request('session/request_permission', params, 0).events) { - expect(rig.assembler.apply(reopened).admission.accepted).toBe(true) + const ask = () => { + const translator = new AcpTimelineTranslator({ sessionId: 'provider-1' }) + for (const event of [ + ...translator.openPrompt('send-1', 1000).events, + ...translator.request('session/request_permission', params, 0).events + ]) { + expect(rig.assembler.apply(event).admission.accepted).toBe(true) + } } + ask() + await rig.restart() + ask() + const request = 'session/request_permission:0' const approvals = (await rig.rows()).filter((row) => row.body.kind === 'approval') - expect(approvals).toHaveLength(2) - expect(approvals[1]!.itemId).not.toBe(first.itemId) - expect(approvals[1]!.turnScope).toEqual(first.turnScope) - expect(approvals[1]!.body).toMatchObject({ resolution: { state: 'pending' } }) + expect(approvals.map((row) => row.itemId)).toEqual([ + providerItemId('request', request, { generation: GENERATION }), + providerItemId('request', request, { generation: 'gen-2' }) + ]) + // The sweep cancelled the dead child's pending request; the new child's waits. + expect( + approvals.map((row) => row.body.kind === 'approval' && row.body.resolution.state) + ).toEqual(['cancelled', 'pending']) }) }) diff --git a/src/main/acp/acp-timeline-recordings.test.ts b/src/main/acp/acp-timeline-recordings.test.ts index e3dfe80ef58a..5bcb22d73cb4 100644 --- a/src/main/acp/acp-timeline-recordings.test.ts +++ b/src/main/acp/acp-timeline-recordings.test.ts @@ -1,7 +1,9 @@ import { afterEach, describe, expect, it } from 'vitest' +import { contextTokensFromUsage } from '../../shared/agent-session-context-usage' import { closeProviderTimelineRigs, - messageText + messageText, + providerItemId } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' import { openAcpFixtureRig, readAcpFixture } from './acp-timeline-fixture.test-support' @@ -71,23 +73,42 @@ describe('recorded ACP traffic through the journal', () => { expect(rows.some((row) => messageText(row.body)?.includes('ok'))).toBe(true) }) - it('suppresses load replay from the existing journal after an assembler restart', async () => { + it('drops load history a journal already holds, except its context usage', async () => { const fixture = await openAcpFixtureRig() const frames = await readAcpFixture('s4-resume') await fixture.feed(frames.filter((frame) => frame.process === 'A')) const before = await fixture.rig.rows() - fixture.restart() + await fixture.restart({ adopt: false }) const loading = frames.filter((frame) => frame.process === 'B') const nextPrompt = loading.findIndex((frame) => frame.message.method === 'session/prompt') await fixture.feed(loading.slice(0, nextPrompt)) + fixture.apply( + fixture.lane().notification( + 'session/update', + { + sessionId: 'session-1', + _meta: { isReplay: true }, + update: { sessionUpdate: 'usage_update', used: 5000, size: 256000 } + }, + 1900 + ) + ) fixture.finishLoad() - expect(await fixture.rig.rows()).toEqual(before) + const after = await fixture.rig.rows() + const items = (rows: typeof before) => rows.filter((row) => row.body.kind !== 'turn') + expect(items(after)).toEqual(items(before)) + const [turn] = await fixture.rig.turns() + expect(turn?.contextUsage?.window?.tokens).toBe(256000) + expect( + turn?.contextUsage?.used?.kind === 'estimate' && + contextTokensFromUsage(turn.contextUsage.used.usage) + ).toBe(5000) await fixture.feed(loading.slice(nextPrompt)) expect(await fixture.rig.turns()).toHaveLength(2) }) it('adopts coalesced load replay into an empty journal, including user and completed tools', async () => { - const fixture = await openAcpFixtureRig() + const fixture = await openAcpFixtureRig({ adopt: true }) const frames = (await readAcpFixture('s4-resume')).filter((frame) => frame.process === 'B') const nextPrompt = frames.findIndex((frame) => frame.message.method === 'session/prompt') await fixture.feed(frames.slice(0, nextPrompt)) @@ -106,6 +127,40 @@ describe('recorded ACP traffic through the journal', () => { expect(await fixture.rig.turns()).toHaveLength(2) }) + it('adopts history as rows with the provider ids, and a re-run of the adoption adds no copy', async () => { + const fixture = await openAcpFixtureRig({ adopt: true }) + const frames = (await readAcpFixture('s4-resume')).filter((frame) => frame.process === 'B') + const load = frames.slice( + 0, + frames.findIndex((frame) => frame.message.method === 'session/prompt') + ) + await fixture.feed(load) + fixture.finishLoad() + const adopted = await fixture.rig.rows() + const user = adopted.find((row) => row.body.kind === 'message' && row.body.role === 'user') + expect(user?.itemId).toBe( + providerItemId('item', `history-user:${JSON.stringify(['prompt:A:3', null])}`, { + thread: 'session-1' + }) + ) + expect(await fixture.rig.turn('prompt:A:3')).toMatchObject({ + userItemId: user?.itemId, + state: 'completed' + }) + expect( + adopted.flatMap((row) => (row.body.kind === 'tool-call' ? [row.itemId] : [])).sort() + ).toEqual( + ['tool:call-1', 'tool:call-2'] + .map((key) => providerItemId('item', key, { thread: 'session-1' })) + .sort() + ) + + await fixture.restart({ adopt: true }) + await fixture.feed(load) + fixture.finishLoad() + expect(await fixture.rig.rows()).toEqual(adopted) + }) + it('answers questions and plan approval with the recorded exact reply shapes', async () => { const fixture = await openAcpFixtureRig() const rows = await fixture.feed(await readAcpFixture('s5-plan-approved')) diff --git a/src/main/acp/acp-timeline-recovery.test.ts b/src/main/acp/acp-timeline-recovery.test.ts index e300d7a3e203..6bc11b594756 100644 --- a/src/main/acp/acp-timeline-recovery.test.ts +++ b/src/main/acp/acp-timeline-recovery.test.ts @@ -1,9 +1,6 @@ import { afterEach, describe, expect, it } from 'vitest' import { contextTokensFromUsage } from '../../shared/agent-session-context-usage' -import { - closeProviderTimelineRigs, - messageText -} from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import { closeProviderTimelineRigs } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' import { openAcpFixtureRig, readAcpFixture } from './acp-timeline-fixture.test-support' import { AcpTimelineTranslator } from './acp-timeline-translator' import { GROK_ACP_DIALECT } from './acp-dialects/grok-dialect' @@ -35,59 +32,6 @@ describe('ACP reviewed traffic and recovery', () => { expect(usage?.kind === 'estimate' && contextTokensFromUsage(usage.usage)).toBe(39190) }) - it('adopts missing history into a partial journal after a restart without duplicating partial text', async () => { - const f = await openAcpFixtureRig() - const frames = await readAcpFixture('s4-resume') - const live = frames.filter((frame) => frame.process === 'A') - const firstText = live.findIndex( - (frame) => - frame.message.params && JSON.stringify(frame.message.params).includes('agent_message_chunk') - ) - await f.feed(live.slice(0, firstText + 1)) - const partial = (await f.rig.rows()).find((row) => row.body.kind === 'message') - expect(partial).toBeDefined() - f.restart() - const replay = frames.filter((frame) => frame.process === 'B') - const next = replay.findIndex((frame) => frame.message.method === 'session/prompt') - await f.feed(replay.slice(0, next)) - f.finishLoad() - const rows = await f.rig.rows() - const messages = rows.filter( - (row) => row.body.kind === 'message' && row.body.role === 'assistant' - ) - expect(messages).toHaveLength(2) - expect(messages[0]?.itemId).toBe(partial?.itemId) - expect(messages.map((row) => messageText(row.body))).toEqual([ - "I'll read `notes.txt` and report the second line.", - 'The second line of `notes.txt` is `bananas`.' - ]) - expect(rows.filter((row) => row.body.kind === 'tool-call')).toHaveLength(2) - expect((await f.rig.turns())[0]?.outcome).toBe('success') - }) - - it('re-derives replay adoption after adoption itself was interrupted', async () => { - const f = await openAcpFixtureRig() - const replay = (await readAcpFixture('s4-resume')).filter((frame) => frame.process === 'B') - const next = replay.findIndex((frame) => frame.message.method === 'session/prompt') - await f.feed(replay.slice(0, 4)) - const original = (await f.rig.rows()).find( - (row) => row.body.kind === 'message' && row.body.role === 'assistant' - ) - f.restart() - await f.feed(replay.slice(0, next)) - f.finishLoad() - const rows = await f.rig.rows() - expect( - rows.filter((row) => row.body.kind === 'message' && row.body.role === 'user') - ).toHaveLength(1) - const assistants = rows.filter( - (row) => row.body.kind === 'message' && row.body.role === 'assistant' - ) - expect(assistants).toHaveLength(2) - expect(assistants[0]?.itemId).toBe(original?.itemId) - expect((await f.rig.turns())[0]?.outcome).toBe('success') - }) - it.each([undefined, null])( 'accepts an empty plan request and replies with the valid approval shape (%s)', async (planContent) => { @@ -112,11 +56,7 @@ describe('ACP reviewed traffic and recovery', () => { ) it('degrades tool snapshots under forty large concurrent tools without throwing or losing later updates', () => { - const lane = new AcpTimelineTranslator({ - sessionId: 'session-1', - journalItems: () => [], - dialect: GROK_ACP_DIALECT - }) + const lane = new AcpTimelineTranslator({ sessionId: 'session-1', dialect: GROK_ACP_DIALECT }) const big = 'x"\\'.repeat(12000) for (let index = 0; index < 40; index += 1) { expect(() => diff --git a/src/main/acp/acp-timeline-requests.ts b/src/main/acp/acp-timeline-requests.ts index 23bc215e80b7..b60e860d2cda 100644 --- a/src/main/acp/acp-timeline-requests.ts +++ b/src/main/acp/acp-timeline-requests.ts @@ -1,8 +1,6 @@ import { z } from 'zod' -import type { AgentJournalRenderItem } from '../../shared/agent-session-journal-types' import type { ProviderTimelineEvent } from '../native-chat/agent-session-timeline/provider-timeline-event' import type { AcpDialect, AcpRequestPresentation } from './acp-dialects/acp-dialect' -import { acpJournalToolTurn } from './acp-journal-turns' import type { AcpToolTimeline } from './acp-tool-timeline' import { AcpRpcError } from './acp-errors' import { RequestPermissionRequestSchema } from './generated/acp-protocol.generated' @@ -55,7 +53,6 @@ export function translateAcpRequest( id: string | number, options: { sessionId: string - journalItems(): readonly AgentJournalRenderItem[] dialect: AcpDialect tools: AcpToolTimeline } @@ -70,9 +67,7 @@ export function translateAcpRequest( : options.dialect.request?.(method, params) const tool = requestToolSchema.safeParse(params) const callId = tool.success ? (tool.data.toolCall?.toolCallId ?? tool.data.toolCallId) : undefined - const turn = callId - ? (options.tools.turn(callId) ?? acpJournalToolTurn(options.journalItems(), callId)) - : undefined + const turn = callId ? options.tools.turn(callId) : undefined const join = { thread: options.sessionId, ...(turn === undefined ? {} : { turn }) } if (!presentation) { return { diff --git a/src/main/acp/acp-timeline-translator.ts b/src/main/acp/acp-timeline-translator.ts index 1a93fe55dcf9..7fabbb48000f 100644 --- a/src/main/acp/acp-timeline-translator.ts +++ b/src/main/acp/acp-timeline-translator.ts @@ -1,61 +1,67 @@ import { z } from 'zod' -import type { - AgentJournalMessageItem, - AgentJournalRenderItem -} from '../../shared/agent-session-journal-types' import { BoundedMap } from '../../shared/bounded-map' import type { ProviderTimelineEvent } from '../native-chat/agent-session-timeline/provider-timeline-event' -import { acpNotificationEnvelopeSchema, AcpContextTimeline } from './acp-context-usage' +import { + acpNotificationEnvelopeSchema, + AcpContextTimeline, + acpWindowUsage +} from './acp-context-usage' import { AcpBackgroundTaskTimeline } from './acp-background-task-timeline' import { GENERIC_ACP_DIALECT, type AcpDialect, + type AcpDialectNotification, type AcpRequestPresentation } from './acp-dialects/acp-dialect' -import { acpJournalTurnIsSettled, acpJournalTurnHasUser } from './acp-journal-turns' +import { AcpHistoryAdoption } from './acp-history-adoption' import { acpTurnEnd, AcpPromptTurns } from './acp-prompt-turns' -import { AcpReplayUserMessages } from './acp-replay-user-messages' import type { AcpSessionEvent } from './acp-session-runtime' import { translateAcpRequest } from './acp-timeline-requests' export { acpTurnEnd } from './acp-prompt-turns' import { acpSessionUpdate } from './acp-session-update' import { AcpToolTimeline } from './acp-tool-timeline' import { AcpTurnMessages } from './acp-turn-messages' -import { SessionNotificationSchema, type PromptResponse } from './generated/acp-protocol.generated' +import { + SessionNotificationSchema, + type PromptResponse, + type SessionNotification +} from './generated/acp-protocol.generated' const requestSessionSchema = z.object({ sessionId: z.string() }) -type LoadReplay = { - adopt: boolean - turn?: string - serial: number - users: AcpReplayUserMessages - pendingUser?: AgentJournalMessageItem -} +const SUBSTANTIVE_UPDATES = [ + 'user_message_chunk', + 'agent_message_chunk', + 'agent_thought_chunk', + 'tool_call', + 'tool_call_update', + 'plan' +] + export type AcpTimelineTranslatorOptions = { sessionId: string - /** Committed journal after the host drains earlier writes before session/load. */ - journalItems(): readonly AgentJournalRenderItem[] dialect?: AcpDialect + /** The creator's decision, made from the journal before spawn: translate `session/load` history + * because the journal is empty (an adopted session). Otherwise history stays out of the + * timeline and only its context usage reads on. */ + adopt?: boolean } -/** Consumes each frame once; the host retries the returned grammar events. */ +/** Consumes each frame once; the host retries the returned grammar events. Lives exactly as long + * as its provider child, so it never reads the journal. */ export class AcpTimelineTranslator { private readonly dialect: AcpDialect private readonly prompts: AcpPromptTurns - private readonly tools: AcpToolTimeline + private readonly tools = new AcpToolTimeline() private readonly backgroundTasks: AcpBackgroundTaskTimeline private readonly messages = new AcpTurnMessages() private readonly started = new BoundedMap({ maxEntries: 128 }) - private replay?: LoadReplay + private load?: { adoption?: AcpHistoryAdoption } private readonly context = new AcpContextTimeline() private activeTurn?: string constructor(private readonly options: AcpTimelineTranslatorOptions) { this.dialect = options.dialect ?? GENERIC_ACP_DIALECT - this.tools = new AcpToolTimeline(options.journalItems) - this.backgroundTasks = new AcpBackgroundTaskTimeline(options.journalItems, (callId) => - this.tools.turn(callId) - ) + this.backgroundTasks = new AcpBackgroundTaskTimeline((callId) => this.tools.turn(callId)) this.prompts = new AcpPromptTurns( options.sessionId, this.dialect.injectedPromptIdentity === true @@ -67,7 +73,7 @@ export class AcpTimelineTranslator { clientMessageId: string, at: number ): { promptId: string; events: ProviderTimelineEvent[] } { - if (this.replay) { + if (this.load) { throw new Error('ACP prompt overlaps a prompt or load') } return this.prompts.open(clientMessageId, at) @@ -106,24 +112,24 @@ export class AcpTimelineTranslator { } beginLoad(): void { - if (this.prompts.current || this.replay) { + if (this.prompts.current || this.load) { throw new Error('ACP load overlaps a prompt or load') } - this.replay = { - adopt: this.options.journalItems().length === 0, - serial: 0, - users: new AcpReplayUserMessages() - } + this.load = this.options.adopt + ? { adoption: new AcpHistoryAdoption(this.options.sessionId) } + : {} } finishLoad(at: number): ProviderTimelineEvent[] { - const turn = this.replay?.turn - this.replay = undefined - if (!turn) { + const adoption = this.load?.adoption + this.load = undefined + if (!adoption) { return [] } - this.end(turn) - return [{ type: 'turn.end', turn, at, state: 'completed' }] + const events = adoption.holdsUser + ? this.openHistory(adoption, adoption.turnFor(undefined), at) + : [] + return [...events, ...this.endHistory(adoption, at)] } sessionEvent(event: AcpSessionEvent, at: number): ProviderTimelineEvent[] { @@ -148,85 +154,41 @@ export class AcpTimelineTranslator { return [] } const envelope = acpNotificationEnvelopeSchema.safeParse(params) + const standard = + method === 'session/update' ? SessionNotificationSchema.safeParse(params) : undefined + const update = standard?.success ? standard.data.update : undefined const markedReplay = (envelope.success && envelope.data._meta?.isReplay === true) || extension?.replay === true - if (markedReplay && !this.replay) { - return [] + const history = markedReplay || (extension?.replay === undefined && this.load !== undefined) + const adoption = history ? this.load?.adoption : undefined + if (history && !adoption) { + return this.historyUsage(update, extension, at) } - const isReplay = extension?.replay ?? (markedReplay || this.replay !== undefined) - const providerTurn = extension?.turn - if ( - isReplay && - this.replay && - (providerTurn - ? acpJournalTurnIsSettled(this.options.journalItems(), providerTurn) - : !this.replay.adopt && !this.dialect.injectedPromptIdentity) - ) { - this.replay.pendingUser = undefined - return [] + if (adoption && update?.sessionUpdate === 'user_message_chunk') { + return this.historyUser(adoption, update, at) } - const standard = - method === 'session/update' ? SessionNotificationSchema.safeParse(params) : undefined + const providerTurn = extension?.turn + const opens = + (update !== undefined && SUBSTANTIVE_UPDATES.includes(update.sessionUpdate)) || + extension?.end !== undefined || + extension?.started === true + const turn = adoption + ? opens + ? adoption.turnFor(providerTurn) + : (providerTurn ?? adoption.turn) + : (providerTurn ?? + (this.prompts.current?.opened ? this.prompts.current.turn : this.activeTurn)) const events: ProviderTimelineEvent[] = [] - const replayUser = - isReplay && this.replay && standard?.success - ? this.replay.users.observe(standard.data.update) - : undefined - if ( - isReplay && - this.replay && - this.dialect.injectedPromptIdentity && - replayUser?.body && - !providerTurn - ) { - this.replay.pendingUser = replayUser.body - return [] - } - if (replayUser?.startsMessage && this.replay?.turn && !providerTurn) { - events.push({ type: 'turn.end', turn: this.replay.turn, at, state: 'completed' }) - this.end(this.replay.turn) - } - const substantive = - standard?.success && - [ - 'user_message_chunk', - 'agent_message_chunk', - 'agent_thought_chunk', - 'tool_call', - 'tool_call_update', - 'plan' - ].includes(standard.data.update.sessionUpdate) - let turn = - providerTurn ?? - (isReplay - ? this.replay?.turn - : this.prompts.current?.opened - ? this.prompts.current.turn - : this.activeTurn) - if (!turn && isReplay && this.replay?.adopt && substantive) { - turn = `replay:${this.replay.serial++}` - } - if (turn && (substantive || extension?.end || extension?.started)) { - events.push(...this.start(turn, extension?.at ?? at)) - if (isReplay && this.replay) { - this.replay.turn = turn - if (this.replay.pendingUser) { - const existing = acpJournalTurnHasUser(this.options.journalItems(), turn) - if (!existing) { - events.push({ - type: 'item.update', - item: `replay-user:${turn}:`, - body: this.replay.pendingUser, - join: { thread: this.options.sessionId, turn } - }) - } - this.replay.pendingUser = undefined - } - } + if (turn && opens) { + events.push( + ...(adoption + ? this.openHistory(adoption, turn, extension?.at ?? at) + : this.start(turn, extension?.at ?? at)) + ) } const join = { thread: this.options.sessionId, ...(turn === undefined ? {} : { turn }) } if (extension?.backgroundTasks) { - events.push(...this.backgroundTasks.translate(extension.backgroundTasks, join, isReplay)) + events.push(...this.backgroundTasks.translate(extension.backgroundTasks, join, history)) } if (extension?.usage) { events.push(...this.context.update(extension.usage, join)) @@ -247,23 +209,20 @@ export class AcpTimelineTranslator { return events } if (standard?.success) { + // History text is keyed by its position so a re-run of the adoption lands on the same rows. const messageKey = - turn && this.dialect.injectedPromptIdentity + turn && (this.dialect.injectedPromptIdentity || history) ? this.messages.key(turn, standard.data.update) : undefined return [ ...events, - ...acpSessionUpdate( - standard.data, - turn, - at, - isReplay, - this.tools, - this.dialect, - this.backgroundTasks, - replayUser?.body, + ...acpSessionUpdate(standard.data, turn, at, { + history, + tools: this.tools, + dialect: this.dialect, + backgroundTasks: this.backgroundTasks, messageKey - ) + }) ] } if (method === 'session/update') { @@ -278,12 +237,64 @@ export class AcpTimelineTranslator { id: string | number ): { events: ProviderTimelineEvent[]; presentation?: AcpRequestPresentation } { return translateAcpRequest(method, params, id, { - ...this.options, + sessionId: this.options.sessionId, dialect: this.dialect, tools: this.tools }) } + /** History that is not adopted is dropped except what it says about the context window. */ + private historyUsage( + update: SessionNotification['update'] | undefined, + extension: Extract | undefined, + at: number + ): ProviderTimelineEvent[] { + const join = { thread: this.options.sessionId } + const events = extension?.usage ? this.context.update(extension.usage, join) : [] + if (update?.sessionUpdate === 'usage_update') { + events.push({ type: 'context.usage', usage: acpWindowUsage(update, at), join }) + } + return events + } + + /** A saved user message opens a new history turn, unless the provider names its turn. */ + private historyUser( + adoption: AcpHistoryAdoption, + update: Extract, + at: number + ): ProviderTimelineEvent[] { + if (adoption.continuesUser(update)) { + adoption.observeUser(update) + return [] + } + const events = adoption.holdsUser + ? this.openHistory(adoption, adoption.turnFor(undefined), at) + : [] + events.push(...this.endHistory(adoption, at)) + adoption.observeUser(update) + return events + } + + private openHistory( + adoption: AcpHistoryAdoption, + turn: string, + at: number + ): ProviderTimelineEvent[] { + adoption.turn = turn + return [...this.start(turn, at), ...adoption.takeUser(turn)] + } + + /** History carries no verdict for a turn it does not end itself. */ + private endHistory(adoption: AcpHistoryAdoption, at: number): ProviderTimelineEvent[] { + const turn = adoption.turn + if (!turn) { + return [] + } + this.end(turn) + adoption.turn = undefined + return [{ type: 'turn.end', turn, at, state: 'completed' }] + } + private start(turn: string, at: number): ProviderTimelineEvent[] { if (turn === this.prompts.current?.turn) { const events = this.prompts.start(turn, at) @@ -304,8 +315,8 @@ export class AcpTimelineTranslator { if (this.activeTurn === turn) { this.activeTurn = undefined } - if (this.replay?.turn === turn) { - this.replay.turn = undefined + if (this.load?.adoption?.turn === turn) { + this.load.adoption.turn = undefined } } } diff --git a/src/main/acp/acp-tool-timeline.ts b/src/main/acp/acp-tool-timeline.ts index 515e62c7cadc..1a4675ed4330 100644 --- a/src/main/acp/acp-tool-timeline.ts +++ b/src/main/acp/acp-tool-timeline.ts @@ -1,12 +1,11 @@ import { createPatch } from 'diff' import { z } from 'zod' -import { parseAgentJournalItemKey } from '../../shared/agent-session-journal-item-key' -import type { - AgentJournalRenderItem, - AgentJournalToolCallItem -} from '../../shared/agent-session-journal-types' -import { acpJournalToolTurn } from './acp-journal-turns' -import { providerTimelineKeyPart } from '../native-chat/agent-session-timeline/provider-timeline-identity' +import type { AgentJournalToolCallItem } from '../../shared/agent-session-journal-types' +import { BoundedMap } from '../../shared/bounded-map' +import { + MAX_PROVIDER_TIMELINE_OPEN_BYTES, + MAX_PROVIDER_TIMELINE_OPEN_ENTRIES +} from '../native-chat/agent-session-timeline/provider-timeline-budget' import { boundPayload, boundToolInput, @@ -75,20 +74,24 @@ function boundedDiffPatch(path: string, oldText: string, newText: string): strin type ToolSnapshot = { body: AgentJournalToolCallItem; turn?: string } -/** Holds only bounded snapshots needed to fill ACP's partial tool updates. */ +/** Holds the snapshots that fill ACP's partial tool updates, settled ones evicted first. Its bound + * is the assembler's open budget, which counts each running tool at no fewer bytes, so a running + * tool is evicted only after the assembler refused one past that budget, which ends the session. + * An update for an evicted settled tool is dropped by the assembler as settled. */ export class AcpToolTimeline { private readonly tools = new Map() private bytes = 0 - - constructor(private readonly journalItems: () => readonly AgentJournalRenderItem[] = () => []) {} + /** Outlives the turn, so a later task or request still lands beside the tool that started it. */ + private readonly turns = new BoundedMap({ + maxEntries: MAX_PROVIDER_TIMELINE_OPEN_ENTRIES + }) translate( update: ToolCallUpdate, dialect: AcpDialect, join: ProviderTimelineJoin ): ProviderTimelineEvent[] { - const previous = - this.tools.get(update.toolCallId) ?? this.persisted(update.toolCallId, join.thread) + const previous = this.tools.get(update.toolCallId) const output = outputText(update) const state = update.status === 'completed' @@ -124,6 +127,9 @@ export class AcpToolTimeline { ...(join.turn === undefined ? {} : { turn: join.turn }) } this.remember(update.toolCallId, snapshot) + if (join.turn !== undefined) { + this.turns.set(update.toolCallId, join.turn) + } const events: ProviderTimelineEvent[] = [ { type: state === 'running' ? (previous ? 'item.update' : 'item.open') : 'item.close', @@ -160,22 +166,7 @@ export class AcpToolTimeline { } turn(toolCallId: string): string | undefined { - return this.tools.get(toolCallId)?.turn - } - - private persisted(callId: string, thread: string | undefined): ToolSnapshot | undefined { - const rows = this.journalItems() - const suffix = `${thread ? `${providerTimelineKeyPart(thread)}/` : ''}${providerTimelineKeyPart(`tool:${callId}`)}` - const row = rows.find((item) => { - if (item.body.kind !== 'tool-call' || item.body.callId !== callId) { - return false - } - const identity = parseAgentJournalItemKey(item.itemId) - return identity?.provider === 'legacy' && identity.recordId.endsWith(`:${suffix}`) - }) - return row?.body.kind === 'tool-call' - ? { body: row.body, turn: acpJournalToolTurn(rows, callId) } - : undefined + return this.tools.get(toolCallId)?.turn ?? this.turns.get(toolCallId) } end(turn: string): void { @@ -187,13 +178,17 @@ export class AcpToolTimeline { } } + private fits(count: number, bytes: number): boolean { + return count <= MAX_PROVIDER_TIMELINE_OPEN_ENTRIES && bytes <= MAX_PROVIDER_TIMELINE_OPEN_BYTES + } + private size(key: string, snapshot: ToolSnapshot): number { return Buffer.byteLength(key) + Buffer.byteLength(JSON.stringify(snapshot)) } private remember(key: string, snapshot: ToolSnapshot): void { const previous = this.tools.get(key) - if (this.size(key, snapshot) > 1024 * 1024) { + if (this.size(key, snapshot) > MAX_PROVIDER_TIMELINE_OPEN_BYTES) { if (previous) { this.bytes -= this.size(key, previous) this.tools.delete(key) @@ -204,7 +199,7 @@ export class AcpToolTimeline { let count = this.tools.size + (previous ? 0 : 1) const evicted: string[] = [] for (const [candidate, stored] of this.tools) { - if (count <= 128 && bytes <= 1024 * 1024) { + if (this.fits(count, bytes)) { break } if (candidate !== key && stored.body.state !== 'running') { @@ -214,7 +209,7 @@ export class AcpToolTimeline { } } for (const [candidate, stored] of this.tools) { - if (count <= 128 && bytes <= 1024 * 1024) { + if (this.fits(count, bytes)) { break } if (candidate !== key && !evicted.includes(candidate)) { From 430d82053599e34f36282fb816e6ed82d44f33fa Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 01:30:57 -0700 Subject: [PATCH 47/62] Leave a stopped turn's running tools to the agent's own end When another writer settles the open turn (a person's Stop), the assembler now only stops that turn's text and cancels its pending prompts. Running tool calls stay the agent's: a progress update or completion it reports after the Stop lands as reported, and whatever is still running settles at the agent's turn end for that turn, the next turn's open, or the session's end. An agent's end for an earlier turn while a newer one is open no longer clears the open turn's activity line or ends its anonymous reply. An unnamed end right after a Stop ends the stopped turn instead of being dropped. The test rig's restart no longer writes the dead assembler's window text, matching dispose. --- ...imeline-assembler-earlier-turn-end.test.ts | 62 +++++++++++ .../provider-timeline-assembler-stop.test.ts | 102 +++++++++++++++++- ...rovider-timeline-assembler-test-support.ts | 9 +- .../provider-timeline-assembler.ts | 13 +-- .../provider-timeline-decision.ts | 6 +- .../provider-timeline-event.ts | 14 ++- .../provider-timeline-item-decisions.ts | 4 +- .../provider-timeline-settlement.ts | 10 +- .../provider-timeline-state.ts | 33 +++++- .../provider-timeline-transition-layout.ts | 19 ++-- .../provider-timeline-turn-decisions.ts | 67 ++++++------ 11 files changed, 271 insertions(+), 68 deletions(-) create mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-assembler-earlier-turn-end.test.ts diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-earlier-turn-end.test.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-earlier-turn-end.test.ts new file mode 100644 index 000000000000..0e79ec50d040 --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-earlier-turn-end.test.ts @@ -0,0 +1,62 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { + closeProviderTimelineRigs, + messageText, + openProviderTimelineRig, + providerItemId, + providerTurnId, + providerTurnItemId, + runningTool +} from './provider-timeline-assembler-test-support' + +afterEach(closeProviderTimelineRigs) + +describe("an earlier turn's late end leaves the open turn alone", () => { + it("keeps the open turn's activity line", async () => { + const rig = await openProviderTimelineRig() + const activity: unknown[] = [] + const assembler = rig.assemble({ + sink: { ...rig.sink, setActivity: (each) => activity.push(each) } + }) + assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + assembler.apply({ type: 'turn.end', turn: 't1', at: 1_500, state: 'completed' }) + assembler.apply({ type: 'turn.open', turn: 't2', at: 2_000 }) + assembler.apply({ type: 'activity', text: 'Thinking' }) + activity.length = 0 + expect( + assembler.apply({ type: 'turn.end', turn: 't1', at: 2_100, state: 'completed' }) + ).toEqual({ admission: { accepted: true } }) + expect(activity).toEqual([]) + expect(assembler.openTurnId).toBe(providerTurnId('t2')) + }) + + it("keeps the open turn's anonymous reply one message", async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ type: 'turn.end', turn: 't1', at: 1_500, state: 'completed' }) + rig.assembler.apply({ type: 'turn.open', turn: 't2', at: 2_000 }) + const delta = { type: 'text.delta', item: { stream: 'reply' }, channel: 'assistant' } as const + rig.assembler.apply({ ...delta, text: 'Hello' }) + rig.assembler.apply({ type: 'turn.end', turn: 't1', at: 2_100, state: 'completed' }) + rig.assembler.apply({ ...delta, text: ' world' }) + const texts = (await rig.rows()).flatMap((row) => + row.body.kind === 'message' ? [[row.turnScope, messageText(row.body)]] : [] + ) + expect(texts).toEqual([[{ kind: 'turn', turnItemId: providerTurnItemId('t2') }, 'Hello world']]) + }) + + it("leaves the open turn's running work to that turn's own end", async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ type: 'turn.open', turn: 't2', at: 2_000 }) + rig.assembler.apply({ type: 'item.open', item: 'call', body: runningTool('read') }) + rig.assembler.apply({ type: 'turn.end', turn: 't1', at: 2_100, state: 'completed' }) + expect((await rig.row(providerItemId('item', 'call')))?.body).toMatchObject({ + state: 'running' + }) + rig.assembler.apply({ type: 'turn.end', turn: 't2', at: 2_200, state: 'interrupted' }) + expect((await rig.row(providerItemId('item', 'call')))?.body).toMatchObject({ + state: 'failed' + }) + }) +}) diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-stop.test.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-stop.test.ts index 7ce1ec3a3f76..4ee9190ad244 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-stop.test.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-stop.test.ts @@ -9,6 +9,7 @@ import { openUnboundProviderTimelineAssembler, pendingApproval, providerItemId, + providerTurnId, providerTurnItemId, runningTool, type ProviderTimelineRig @@ -30,6 +31,8 @@ async function stop(rig: ProviderTimelineRig, turnKey: string, at = 2_000): Prom ) } +const completed = { ...runningTool('read'), state: 'completed' as const } + async function texts(rig: ProviderTimelineRig): Promise { return (await rig.rows()).flatMap((row) => { const text = messageText(row.body) @@ -39,8 +42,8 @@ async function texts(rig: ProviderTimelineRig): Promise { }) } -describe('a turn another writer settled ends as the provider end would end it', () => { - it("settles the turn's running tool and pending prompt, then takes the provider's own end", async () => { +describe('a turn another writer settled stops its text and prompts; its tools wait for the provider', () => { + it("cancels the turn's prompt at once and settles its running tool at the provider's own end", async () => { const rig = await openProviderTimelineRig() rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) rig.assembler.apply({ type: 'item.open', item: 'call', body: runningTool('read') }) @@ -80,15 +83,92 @@ describe('a turn another writer settled ends as the provider end would end it', expect(await rig.turn('t1')).toMatchObject({ state: 'interrupted', completedAt: 2_100 }) }) - it('settles the work even when the next event is one that writes nothing', async () => { + it('cancels the prompt even when the next event writes nothing, and leaves the tool running', async () => { const rig = await openProviderTimelineRig() rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) rig.assembler.apply({ type: 'item.open', item: 'call', body: runningTool('read') }) + rig.assembler.apply({ type: 'request.open', request: '7', body: pendingApproval }) await stop(rig, 't1') expect(rig.assembler.apply({ type: 'activity', text: 'Thinking' }).dropped).toBe('no-turn') + expect(rig.assembler.openTurnId).toBeNull() + expect((await rig.row(providerItemId('request', '7')))?.body).toMatchObject({ + resolution: { state: 'cancelled' } + }) + expect((await rig.row(providerItemId('item', 'call')))?.body).toMatchObject({ + state: 'running' + }) + }) + + it('lands a tool the provider completes after the Stop as completed', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ type: 'item.open', item: 'call', body: runningTool('read') }) + await stop(rig, 't1') + // Its progress, then its completion, both reported between the cancel and the turn's end. + const progress = { ...runningTool('read'), output: 'half' } + expect( + rig.assembler.apply({ + type: 'item.update', + item: 'call', + body: progress, + join: { turn: 't1' } + }) + ).toEqual({ admission: { accepted: true } }) + expect((await rig.row(providerItemId('item', 'call')))?.body).toMatchObject({ output: 'half' }) + expect( + rig.assembler.apply({ + type: 'item.close', + item: 'call', + body: completed, + join: { turn: 't1' } + }) + ).toEqual({ admission: { accepted: true } }) + rig.assembler.apply({ type: 'turn.end', turn: 't1', at: 2_100, state: 'interrupted' }) + expect((await rig.row(providerItemId('item', 'call')))?.body).toMatchObject({ + state: 'completed' + }) + }) + + it('lands the completion the same way when it arrives before the Stop', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ type: 'item.open', item: 'call', body: runningTool('read') }) + rig.assembler.apply({ type: 'item.close', item: 'call', body: completed, join: { turn: 't1' } }) + await stop(rig, 't1') + rig.assembler.apply({ type: 'turn.end', turn: 't1', at: 2_100, state: 'interrupted' }) + expect((await rig.row(providerItemId('item', 'call')))?.body).toMatchObject({ + state: 'completed' + }) + }) + + it("ends the stopped turn on the provider's unnamed end", async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ type: 'item.open', item: 'call', body: runningTool('read') }) + await stop(rig, 't1') + expect(rig.assembler.apply({ type: 'turn.end', at: 2_100, state: 'interrupted' })).toEqual({ + admission: { accepted: true } + }) + expect((await rig.row(providerItemId('item', 'call')))?.body).toMatchObject({ + state: 'failed' + }) + // Ended once: a second unnamed end names no turn. + expect(rig.assembler.apply({ type: 'turn.end', at: 2_200, state: 'interrupted' }).dropped).toBe( + 'no-turn' + ) + }) + + it('settles the running tools of a stopped turn the provider never ended when the next opens', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ type: 'item.open', item: 'call', body: runningTool('read') }) + await stop(rig, 't1') + rig.assembler.apply({ type: 'turn.open', turn: 't2', at: 3_000 }) expect((await rig.row(providerItemId('item', 'call')))?.body).toMatchObject({ state: 'failed' }) + expect(await rig.turn('t1')).toMatchObject({ state: 'interrupted', completedAt: 2_000 }) + expect(rig.assembler.openTurnId).toBe(providerTurnId('t2')) }) it('never fills the open budget with the streams of turns a person stopped', async () => { @@ -157,6 +237,22 @@ describe('text after a person stopped its turn never lands outside that turn', ( ]) }) + it("keeps dropping an anonymous stream after the provider's unnamed end until the next turn", async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + const delta = { type: 'text.delta', item: { stream: 'a' }, channel: 'assistant' } as const + rig.assembler.apply({ ...delta, text: 'Hel' }) + await stop(rig, 't1') + expect(rig.assembler.apply({ ...delta, text: 'lo' }).dropped).toBe('turn-settled') + rig.assembler.apply({ type: 'turn.end', at: 2_100, state: 'interrupted' }) + // The provider's end of the stopped turn is that turn's boundary: its marker is gone. + expect(rig.assembler.apply({ ...delta, text: 'B' }).dropped).toBeUndefined() + expect(await texts(rig)).toEqual([ + [{ kind: 'turn', turnItemId: providerTurnItemId('t1') }, 'Hel'], + [{ kind: 'thread' }, 'B'] + ]) + }) + it('drops the rest of a named stream whose first write found the turn stopped', async () => { const rig = await openProviderTimelineRig({ schedule: () => () => {} }) rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-test-support.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-test-support.ts index 1009ab6aa532..b0e5eb833a22 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-test-support.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-test-support.ts @@ -191,9 +191,9 @@ export type ProviderTimelineRig = { sink: ProviderTimelineSink /** The same journal's event sink, for a lane that writes it directly. */ eventSink: StructuredAgentSessionEventSink - /** What a restarted host does: the old child's assembler is gone, the dead-generation sweep - * settles what it left, then a new child gets a new assembler in a new generation, which - * becomes `assembler`. */ + /** What a restarted host does: the old child's assembler is gone (text still in its window is + * lost, as `dispose` drops it), the dead-generation sweep settles what it left, then a new + * child gets a new assembler in a new generation, which becomes `assembler`. */ restart(overrides?: Partial): Promise /** Another assembler of the same generation on the same journal, for a test that needs its own * sink; the rig's own assembler must then stay unused. */ @@ -285,7 +285,8 @@ export async function openProviderTimelineRig( restart: async (more = {}) => { generations += 1 const generation = more.generation ?? `gen-${generations}` - await rows() + // The dead child's window never elapses: as in production, dispose drops its text. + await deferred.drained() rig.assembler.dispose() await settleStaleStructuredAgentSessionState({ journal, diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler.ts index 0bc2da8b7204..a4bd51d4e807 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler.ts @@ -124,25 +124,26 @@ export function createProviderTimelineAssembler( return { admission: PROVIDER_TIMELINE_OVER_BUDGET } } const plan = new ProviderTimelinePlan() - planProviderTimelineBarrier({ streams, state, context }, plan, event, decision) + planProviderTimelineBarrier({ streams, state }, plan, event, decision) planProviderTimelineWrites(context, plan, decision, serials.next) plan.onAdmitted(() => { decision.commit?.(state) serials.commit() }) + // An earlier turn's end leaves the open turn's activity line alone. if ( event.type === 'turn.open' || - event.type === 'turn.end' || - event.type === 'turn.settled' || - event.type === 'session.ended' + event.type === 'session.ended' || + decision.ends?.current === true ) { plan.onAdmitted(() => deps.sink.setActivity?.(null)) } return { admission: plan.submit(deps.sink) } } - /** The open turn another writer settled (a person's Stop) ends here first, as one transition; - * refused, the event that found it is refused with it and its retry finds it again. */ + /** The open turn another writer settled (a person's Stop) ends here first, as one transition + * that stops its text and cancels its prompts; refused, the event that found it is refused with + * it and its retry finds it again. */ const endSettledTurn = ( journal: NonNullable ): ProviderTimelineApplyResult | null => { diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-decision.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-decision.ts index 884f735b50fe..e5c985aaece1 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-decision.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-decision.ts @@ -50,7 +50,8 @@ export type ProviderTimelineDropRule = export type ProviderTimelineDecidedEvent = | Exclude /** The assembler's own: the journal shows the open turn settled by another writer (a person's - * Stop), so it ends here exactly as the provider's `turn.end` would end it. */ + * Stop), so its text stops and its prompts are cancelled; its running tool calls wait for the + * provider's own `turn.end`. */ | { type: 'turn.settled'; turn: ProviderTimelineTurnRef } type Journal = StructuredAgentSessionTransitionJournal @@ -80,6 +81,9 @@ export type ProviderTimelineDecision = { writes?: readonly ProviderTimelineItemWrite[] /** The streamed item whose text this event's full snapshot replaces. */ closes?: string + /** The turn this event ends; `current` unless another turn is open, whose text and activity + * an earlier turn's end leaves alone. */ + ends?: { turnItemId: string; current: boolean } } export type ProviderTimelineDecisionInput = { diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-event.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-event.ts index b578088d8ef2..dd2f7d1497fb 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-event.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-event.ts @@ -18,9 +18,11 @@ // - A turn settles once, with only the provider's verdict. A row the journal already holds is // never written back to an earlier state: a settled turn stays settled, a settled tool keeps its // terminal body, an answered request stays answered. -// - A turn another writer settled (a person's Stop) ends here as the provider's `turn.end` would -// end it: its running work settles, and text still streaming into it is dropped until the -// provider's end of that turn or the next `turn.open`. +// - A turn another writer settled (a person's Stop) is over here: its pending requests are +// cancelled, and text still streaming into it is dropped until the provider's end of that turn +// or the next `turn.open`. Its running tool calls are still the provider's: a close reported +// after the Stop lands as reported, and whatever is still running settles at the provider's +// `turn.end` for it (or the next `turn.open`, or `session.ended`). // - Saved history the provider replays goes only into an empty journal (an adopted session), as // ordinary events with the provider's own ids, so re-running an interrupted adoption writes the // same rows again. Limit, until the adoption work lifts it: a crash that cut the first run @@ -92,8 +94,10 @@ export type ProviderTimelineEvent = } /** A turn began. `turn` is the provider's turn id when it has one; the assembler mints one otherwise. */ | { type: 'turn.open'; turn?: string; at: number } - /** The provider ended a turn. Absent `turn` means the open turn. A turn already over (another - * writer's Stop, a newer turn) takes it too: what it left open settles, its row stays. */ + /** The provider ended a turn. Absent `turn` means the open turn, else the one a person stopped + * that the provider had not ended yet. A turn already over (another writer's Stop, a newer + * turn) takes it too: what it left open settles, its row stays, and the open turn's text and + * activity are untouched. */ | { type: 'turn.end' turn?: string diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-item-decisions.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-item-decisions.ts index db7ee10131b6..22e8a1c6f1e6 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-item-decisions.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-item-decisions.ts @@ -41,7 +41,8 @@ function pendingPrompt(body: AgentJournalItemBody | null): body is ProviderTimel /** Whether the row the journal holds refuses this write: a settled tool keeps its first terminal * body (whoever settled it, the sweep included), a settled background task is never relit, and a - * turn that is over takes no work that waits on a settlement it already ran. */ + * turn that is over takes no new work that waits on a settlement. Work it still holds open (a + * person's Stop leaves the provider's running tools) takes the provider's updates until settled. */ function refusesItemWrite( journal: Journal, row: ProviderTimelineRowId, @@ -63,6 +64,7 @@ function refusesItemWrite( } return ( requiresTerminalSettlement(body) && + !(held && requiresTerminalSettlement(held.body)) && turn !== null && providerTimelineTurnRowState(journal, turn) === 'settled' ) diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-settlement.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-settlement.ts index 1baa7c54996b..24d35559a2ed 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-settlement.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-settlement.ts @@ -61,8 +61,9 @@ export function endedProviderTimelineTurn( } } -/** Which open rows a settlement covers: one turn's, or every row when the session ends. */ -export type ProviderTimelineSettlementScope = { turnItemId: string } | 'session' +/** Which open rows a settlement covers: one turn's, or every row when the session ends. A turn + * another writer settled covers only its prompts: its tool calls are the provider's to finish. */ +export type ProviderTimelineSettlementScope = { turnItemId: string; promptsOnly?: true } | 'session' /** The turns a settlement ends, and how; absent when another writer already ended the turn. */ export type ProviderTimelineSettlementEnding = { @@ -81,7 +82,9 @@ export function providerTimelineSettlement( const turnScope = attribution.turnScope ?? AGENT_JOURNAL_THREAD_SCOPE const covered = scope === 'session' || - (turnScope.kind === 'turn' && turnScope.turnItemId === scope.turnItemId) + (turnScope.kind === 'turn' && + turnScope.turnItemId === scope.turnItemId && + !(scope.promptsOnly && body.kind === 'tool-call')) // Background tasks and subagents outlive turns; only the session's end leaves them past seeing. const settled = !covered ? null @@ -129,6 +132,7 @@ function endedTurnRow( const target = { identity: turn.identity } const write = { lifecycle: agentJournalTurnBody(endedProviderTimelineTurn(running, end)), + // Defence only: the check above reads the same snapshot; the revision refuses an ended row too. onlyWhileRunning: true as const } return resolveAgentJournalTurnRowWrite( diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-state.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-state.ts index 22d461b8ba87..aa8b0a1bc82c 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-state.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-state.ts @@ -53,6 +53,9 @@ export class ProviderTimelineState { open: ProviderTimelineOpenTurn | null = null /** The turn that ended last, for context facts that arrive after it. */ latest: ProviderTimelineTurnRef | null = null + /** The turn another writer settled (a person's Stop) that the provider has not ended yet: its + * running work is the provider's until then. */ + stopped: ProviderTimelineTurnRef | null = null inputs: ProviderTimelinePendingInput[] = [] /** Keyed by row id (an item) or `request:` (a request, whatever its incarnation). */ items = new Map() @@ -126,16 +129,38 @@ export class ProviderTimelineState { this.open = null this.latest = turn } - for (const [key, entry] of this.items) { - if (entry.turnItemId === turn.itemId) { - this.items.delete(key) - } + if (this.stopped?.itemId === turn.itemId) { + this.stopped = null } + this.forget(turn, () => true) + } + + /** Another writer settled the open turn: it is over here and its prompts were cancelled with it, + * but its running work stays open until the provider ends the turn. */ + stopTurn(turn: ProviderTimelineTurnRef): void { + if (this.open?.itemId === turn.itemId) { + this.open = null + this.latest = turn + this.stopped = turn + } + this.forget(turn, (entry) => entry.kind === 'request') } endSession(): void { this.ended = true this.open = null + this.stopped = null this.items.clear() } + + private forget( + turn: ProviderTimelineTurnRef, + which: (entry: ProviderTimelineOpenItem) => boolean + ): void { + for (const [key, entry] of this.items) { + if (entry.turnItemId === turn.itemId && which(entry)) { + this.items.delete(key) + } + } + } } diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-transition-layout.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-transition-layout.ts index e497b5a66df1..f2c221ed8e81 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-transition-layout.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-transition-layout.ts @@ -10,7 +10,7 @@ import type { ProviderTimelineDecision } from './provider-timeline-decision' import type { ProviderTimelinePlan } from './provider-timeline-plan' -import { providerKey, turnOf } from './provider-timeline-rows' +import { turnOf } from './provider-timeline-rows' import type { ProviderTimelineState } from './provider-timeline-state' import type { ProviderTimelineTextStreams } from './provider-timeline-text-streams' @@ -19,19 +19,19 @@ const SETTLEMENT_RESERVED_BYTES = 64 * 1024 /** Text owed ahead of the event lands first; a row-writing event also ends the messages it * separates: anonymous ones of its producer, every stream of a turn it ends, all on a session end. - * A turn another writer settled stops its streams until that turn's boundary: its own end, or - * the next turn's open. */ + * An earlier turn's end, while another turn is open, ends only that earlier turn's streams. A turn + * another writer settled stops its streams until that turn's boundary: the provider's end of it, + * or the next turn's open. */ export function planProviderTimelineBarrier( input: { streams: ProviderTimelineTextStreams state: ProviderTimelineState - context: ProviderTimelineContext }, plan: ProviderTimelinePlan, event: ProviderTimelineDecidedEvent, decision: ProviderTimelineDecision ): void { - const { streams, state, context } = input + const { streams, state } = input // A full snapshot of a streamed message replaces what streamed, so that text is not flushed. const replaced = decision.closes ? streams.get(decision.closes) : undefined streams.planFlush(plan, replaced) @@ -44,11 +44,8 @@ export function planProviderTimelineBarrier( } if (event.type === 'turn.end' || event.type === 'turn.open' || event.type === 'turn.settled') { const ending = - event.type === 'turn.settled' - ? event.turn.itemId - : event.type === 'turn.end' && event.turn !== undefined - ? context.rows.turn(providerKey(event.turn)).itemId - : state.open?.itemId + decision.ends?.turnItemId ?? (event.type === 'turn.open' ? state.open?.itemId : undefined) + const current = event.type === 'turn.open' || decision.ends?.current === true if (event.type === 'turn.settled') { streams.planStop(plan, event.turn.itemId) } else if (event.type === 'turn.open') { @@ -59,7 +56,7 @@ export function planProviderTimelineBarrier( streams.planRelease( plan, (stream) => - (!stream.named && stream.producer?.agentId === undefined) || + (current && !stream.named && stream.producer?.agentId === undefined) || (ending !== undefined && turnOf(stream.scope) === ending) ) return diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-turn-decisions.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-turn-decisions.ts index 9584ef55ad85..e9c4471f6076 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-turn-decisions.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-turn-decisions.ts @@ -32,7 +32,7 @@ import { import { providerTimelineSettlement, runningProviderTimelineTurns, - type ProviderTimelineSettlementEnding + type ProviderTimelineTurnEnd } from './provider-timeline-settlement' import type { ProviderTimelineOpenTurn, @@ -48,6 +48,8 @@ export function decideTurnOpen( ): ProviderTimelineDecision { const { state, journal, context } = input const open = state.open + // A newer turn ends this one, whoever asked for it; or the stopped one the provider never ended. + const superseded = open ?? state.stopped // An open naming no turn while one is open is the same turn, not a new one. if (event.turn === undefined && open) { return { dropped: 'turn-duplicate' } @@ -75,17 +77,16 @@ export function decideTurnOpen( body: agentJournalTurnBody(running) } return { - // A newer turn ended this one, whoever asked for it. - ...(open + ...(superseded ? { settle: { what: 'turn-superseded', resolve: (journal) => providerTimelineSettlement( journal, - { turnItemId: open.itemId }, + { turnItemId: superseded.itemId }, { - turns: [open], + turns: [superseded], end: { state: 'interrupted', completedAt: event.at, outcome: 'superseded' } } ) @@ -103,8 +104,8 @@ export function decideTurnOpen( } ], commit: (next) => { - if (next.open) { - next.endTurn(next.open) + if (superseded) { + next.endTurn(superseded) } if (pending) { next.inputs = next.inputs.filter((each) => each !== pending) @@ -119,8 +120,11 @@ export function decideTurnEnd( event: Extract ): ProviderTimelineDecision { const { state, journal } = input + // Unnamed: the open turn, else the one a person stopped, which the provider is now ending. const turn = - event.turn === undefined ? state.open : input.context.rows.turn(providerKey(event.turn)) + event.turn === undefined + ? (state.open ?? state.stopped) + : input.context.rows.turn(providerKey(event.turn)) if (!turn) { return { dropped: 'no-turn' } } @@ -134,36 +138,38 @@ export function decideTurnEnd( if (!known) { return { dropped: 'turn-unknown' } } - return endedTurn(turn, { - turns: [turn], - end: { - state: event.state, - completedAt: event.at, - ...(event.outcome !== undefined ? { outcome: event.outcome } : {}), - ...(event.durationMs !== undefined ? { durationMs: event.durationMs } : {}) - } - }) + const end: ProviderTimelineTurnEnd = { + state: event.state, + completedAt: event.at, + ...(event.outcome !== undefined ? { outcome: event.outcome } : {}), + ...(event.durationMs !== undefined ? { durationMs: event.durationMs } : {}) + } + return { + ends: { turnItemId: turn.itemId, current: !state.open || state.open.itemId === turn.itemId }, + settle: { + what: 'turn-end', + resolve: (journal) => + providerTimelineSettlement(journal, { turnItemId: turn.itemId }, { turns: [turn], end }) + }, + commit: (next) => next.endTurn(turn) + } } -/** The open turn another writer settled: its work ends as the provider's end would end it, and - * its row stays as that writer left it. */ +/** The open turn another writer settled (a person's Stop): its text stops and its prompts are + * cancelled; its row stays as that writer left it, and its running tool calls stay the + * provider's until the provider ends the turn (or a newer turn, or the session's end, does). */ export function decideTurnSettled( event: Extract ): ProviderTimelineDecision { - return endedTurn(event.turn) -} - -/** The one end of a turn: the journal-derived settlement of its open work, then the state. */ -function endedTurn( - turn: ProviderTimelineTurnRef, - ending?: ProviderTimelineSettlementEnding -): ProviderTimelineDecision { + const { turn } = event return { + ends: { turnItemId: turn.itemId, current: true }, settle: { - what: ending ? 'turn-end' : 'turn-settled', - resolve: (journal) => providerTimelineSettlement(journal, { turnItemId: turn.itemId }, ending) + what: 'turn-settled', + resolve: (journal) => + providerTimelineSettlement(journal, { turnItemId: turn.itemId, promptsOnly: true }) }, - commit: (next) => next.endTurn(turn) + commit: (next) => next.stopTurn(turn) } } @@ -263,6 +269,7 @@ function reviseOpener( const target = { identity: open.identity } const write = { lifecycle: agentJournalTurnBody({ ...row, ...message }), + // Defence only: the check above reads the same snapshot; the revision refuses an ended row too. onlyWhileRunning: true as const } return resolveAgentJournalTurnRowWrite( From d22b154908646d91f8d9b3791cab5cb0a60cb5bd Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 01:31:39 -0700 Subject: [PATCH 48/62] Pin that a stopped turn's running tools hold budget until the agent's end --- .../provider-timeline-assembler-stop.test.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-stop.test.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-stop.test.ts index 4ee9190ad244..d554eee9d91d 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-stop.test.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-stop.test.ts @@ -171,6 +171,19 @@ describe('a turn another writer settled stops its text and prompts; its tools wa expect(rig.assembler.openTurnId).toBe(providerTurnId('t2')) }) + it("counts a stopped turn's running tools against the budget until the provider ends it", async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + for (let n = 1; n <= MAX_PROVIDER_TIMELINE_OPEN_ENTRIES; n += 1) { + rig.assembler.apply({ type: 'item.open', item: `call-${n}`, body: runningTool('read') }) + } + await stop(rig, 't1') + const extra = { type: 'item.open', item: 'extra', body: runningTool('read') } as const + expect(rig.assembler.apply(extra).admission).toEqual({ accepted: false, reason: 'failed' }) + rig.assembler.apply({ type: 'turn.end', turn: 't1', at: 2_100, state: 'interrupted' }) + expect(rig.assembler.apply(extra).admission).toEqual({ accepted: true }) + }) + it('never fills the open budget with the streams of turns a person stopped', async () => { const rig = await openProviderTimelineRig() for (let n = 1; n <= MAX_PROVIDER_TIMELINE_OPEN_ENTRIES + 12; n += 1) { From 1c49877cbb4ff58c38b8cb3f9ee207bd6590862c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 01:33:08 -0700 Subject: [PATCH 49/62] Type the stopped turn's tool progress update as a tool body --- .../provider-timeline-assembler-stop.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-stop.test.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-stop.test.ts index d554eee9d91d..aaaf89b814de 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-stop.test.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-stop.test.ts @@ -105,7 +105,7 @@ describe('a turn another writer settled stops its text and prompts; its tools wa rig.assembler.apply({ type: 'item.open', item: 'call', body: runningTool('read') }) await stop(rig, 't1') // Its progress, then its completion, both reported between the cancel and the turn's end. - const progress = { ...runningTool('read'), output: 'half' } + const progress = { ...runningTool('read'), input: { name: 'read', path: 'a.ts' } } expect( rig.assembler.apply({ type: 'item.update', @@ -114,7 +114,9 @@ describe('a turn another writer settled stops its text and prompts; its tools wa join: { turn: 't1' } }) ).toEqual({ admission: { accepted: true } }) - expect((await rig.row(providerItemId('item', 'call')))?.body).toMatchObject({ output: 'half' }) + expect((await rig.row(providerItemId('item', 'call')))?.body).toMatchObject({ + input: { path: 'a.ts' } + }) expect( rig.assembler.apply({ type: 'item.close', From 9fcb95406eaa1a8d327194836bc1c54d7a4fb811 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 01:45:09 -0700 Subject: [PATCH 50/62] List every event the assembler hands to the decision step The type-aware lint requires an exhaustive switch with no default case. Also retitle a Stop test to say what it asserts. --- .../provider-timeline-assembler-stop.test.ts | 2 +- .../provider-timeline-assembler.ts | 13 ++++++++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-stop.test.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-stop.test.ts index aaaf89b814de..e0e4541402be 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-stop.test.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-stop.test.ts @@ -252,7 +252,7 @@ describe('text after a person stopped its turn never lands outside that turn', ( ]) }) - it("keeps dropping an anonymous stream after the provider's unnamed end until the next turn", async () => { + it("drops a stopped turn's anonymous stream until the provider's unnamed end, then lands it at thread level", async () => { const rig = await openProviderTimelineRig() rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) const delta = { type: 'text.delta', item: { stream: 'a' }, channel: 'assistant' } as const diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler.ts index a4bd51d4e807..f1b6cb0a68b0 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler.ts @@ -175,7 +175,18 @@ export function createProviderTimelineAssembler( ) return { admission: ADMITTED } } - default: + case 'input.accepted': + case 'input.history': + case 'turn.open': + case 'turn.end': + case 'item.open': + case 'item.update': + case 'item.close': + case 'request.open': + case 'request.withdrawn': + case 'context.usage': + case 'provider.frame': + case 'session.ended': return applyDecided(event, journal) } } From cf9d0ee4b0ec46c8edce1cf29c731ef2000b4ed0 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 01:57:06 -0700 Subject: [PATCH 51/62] Say why a Grok turn failed, and keep task rows in Grok's own words A failed Grok turn ended with no reason on screen: the translator dropped every copy of Grok's message. The failed turn now gets one status row in Orca's existing "provider did not accept this message" words with Grok's reason, read from whichever copy arrives first (the given-up retry, the turn's end, the prompt's completion notice, or the prompt's error answer); later copies only fill a reason the row still lacks. A running background command no longer reads "Background task started": a task's summary is mapped only once it has settled. A monitor stays a monitor when the agent reads its output: a frame that names no kind keeps the known one, and a "[monitor" command is a monitor. A prompt's turn is marked started, so a late frame for an ended prompt neither reopens it nor becomes the active turn. A tool's turn is held in one place at a time. --- src/main/acp/acp-background-task-timeline.ts | 4 +- src/main/acp/acp-context-usage.ts | 16 +- src/main/acp/acp-dialects/acp-dialect.ts | 8 +- .../acp/acp-dialects/grok-background-tasks.ts | 24 +- src/main/acp/acp-dialects/grok-dialect.ts | 58 ++++- src/main/acp/acp-prompt-turns.ts | 7 + ...acp-timeline-background-task-words.test.ts | 97 ++++++++ src/main/acp/acp-timeline-translator.ts | 80 +++--- .../acp/acp-timeline-turn-failures.test.ts | 232 ++++++++++++++++++ src/main/acp/acp-tool-timeline.ts | 33 ++- src/main/acp/acp-turn-failures.ts | 57 +++++ 11 files changed, 547 insertions(+), 69 deletions(-) create mode 100644 src/main/acp/acp-timeline-background-task-words.test.ts create mode 100644 src/main/acp/acp-timeline-turn-failures.test.ts create mode 100644 src/main/acp/acp-turn-failures.ts diff --git a/src/main/acp/acp-background-task-timeline.ts b/src/main/acp/acp-background-task-timeline.ts index 058223ac1520..414e9f6c104b 100644 --- a/src/main/acp/acp-background-task-timeline.ts +++ b/src/main/acp/acp-background-task-timeline.ts @@ -29,13 +29,13 @@ export class AcpBackgroundTaskTimeline { history = false ): ProviderTimelineEvent[] { return updates.map((update) => { - const { fallbackLabel, ...fields } = update + const { fallbackLabel, fallbackKind, ...fields } = update const previous = this.snapshots.get(update.taskId) const block: NativeChatBackgroundTaskBlock = { type: 'background-task', - kind: 'unknown', ...previous, ...fields, + kind: update.kind ?? previous?.kind ?? fallbackKind ?? 'unknown', label: update.label ?? previous?.label ?? fallbackLabel ?? update.taskId, // A historical launch proves no current liveness; explicit outcomes still settle it. state: diff --git a/src/main/acp/acp-context-usage.ts b/src/main/acp/acp-context-usage.ts index f3f260df741c..d36c1d2d7eac 100644 --- a/src/main/acp/acp-context-usage.ts +++ b/src/main/acp/acp-context-usage.ts @@ -5,7 +5,7 @@ import type { ProviderTimelineJoin } from '../native-chat/agent-session-timeline/provider-timeline-event' import type { AcpDialect } from './acp-dialects/acp-dialect' -import type { UsageUpdate } from './generated/acp-protocol.generated' +import type { SessionUpdate, UsageUpdate } from './generated/acp-protocol.generated' export class AcpContextTimeline { private window?: { tokens: number; capturedAt: number } @@ -30,6 +30,20 @@ export class AcpContextTimeline { } ] } + + /** History that is not adopted is dropped except what it says about the context window. */ + history( + update: SessionUpdate | undefined, + usage: AgentSessionContextUsage | undefined, + at: number, + join: ProviderTimelineJoin + ): ProviderTimelineEvent[] { + const events = usage ? this.update(usage, join) : [] + if (update?.sessionUpdate === 'usage_update') { + events.push({ type: 'context.usage', usage: acpWindowUsage(update, at), join }) + } + return events + } } export function acpWindowUsage(update: UsageUpdate, at: number): AgentSessionContextUsage { diff --git a/src/main/acp/acp-dialects/acp-dialect.ts b/src/main/acp/acp-dialects/acp-dialect.ts index 9fed58f93789..5a7facd99a69 100644 --- a/src/main/acp/acp-dialects/acp-dialect.ts +++ b/src/main/acp/acp-dialects/acp-dialect.ts @@ -14,6 +14,8 @@ export type AcpBackgroundTaskUpdate = Pick> & { /** Used only until a provider description is known. */ fallbackLabel?: string + /** Used only until a frame names the task's kind. */ + fallbackKind?: NativeChatBackgroundTaskBlock['kind'] } export type AcpDialectNotification = @@ -24,7 +26,9 @@ export type AcpDialectNotification = replay?: boolean at?: number started?: boolean - end?: { stopReason: string; durationMs?: number } + end?: { stopReason: string; durationMs?: number; failureDetail?: string } + /** The provider's own words for why `turn` failed, sent apart from its end. */ + failureDetail?: string usage?: AgentSessionContextUsage backgroundTasks?: AcpBackgroundTaskUpdate[] } @@ -40,6 +44,8 @@ export type AcpDialect = { notification?(method: string, params: unknown, at: number): AcpDialectNotification | undefined contextWindow?(models: unknown): number | undefined request?(method: string, params: unknown): AcpRequestPresentation | undefined + /** The provider's words in a `session/prompt` error answer, when its message is generic. */ + promptErrorDetail?(error: unknown): string | undefined } export const GENERIC_ACP_DIALECT: AcpDialect = {} diff --git a/src/main/acp/acp-dialects/grok-background-tasks.ts b/src/main/acp/acp-dialects/grok-background-tasks.ts index 3726c8b25327..3f7bd3f32f64 100644 --- a/src/main/acp/acp-dialects/grok-background-tasks.ts +++ b/src/main/acp/acp-dialects/grok-background-tasks.ts @@ -76,30 +76,34 @@ function snapshot( state: NativeChatBackgroundTaskBlock['state'] ): AcpBackgroundTaskUpdate { const kind = task.kind ?? task.task_type - const monitor = kind === 'monitor' || task.monitor_description != null + // Grok's output reads name a monitor only by this command prefix. + const monitor = + kind === 'monitor' || + task.monitor_description != null || + task.command?.startsWith('[monitor') === true const label = task.monitor_description?.trim() || task.description?.trim() const fallbackLabel = task.display_command?.trim() || task.command?.trim() + const settled = isSettledBackgroundTaskState(state) return { taskId: task.task_id, state, ...(monitor ? { kind: 'monitor' as const } - : kind !== undefined || task.command !== undefined + : kind !== undefined ? { kind: kind === 'bash' || kind === 'shell' || task.command !== undefined ? ('command' as const) - : normalizeBackgroundTaskKind(kind ?? 'unknown') + : normalizeBackgroundTaskKind(kind) } - : {}), + : task.command !== undefined + ? { fallbackKind: 'command' as const } + : {}), ...(label ? { label } : fallbackLabel ? { fallbackLabel } : {}), ...(task.output_file === undefined ? {} : { outputFile: task.output_file }), - ...(task.summary === undefined && !isSettledBackgroundTaskState(state) - ? {} - : { summary: task.summary ?? '' }), - ...(task.error === undefined && !isSettledBackgroundTaskState(state) - ? {} - : { error: task.error ?? '' }) + // A running task's summary is Grok's "Background task started", not the task's result. + ...(settled ? { summary: task.summary ?? '' } : {}), + ...(task.error === undefined && !settled ? {} : { error: task.error ?? '' }) } } diff --git a/src/main/acp/acp-dialects/grok-dialect.ts b/src/main/acp/acp-dialects/grok-dialect.ts index 51a7bf7f59cc..87569e9d3ed5 100644 --- a/src/main/acp/acp-dialects/grok-dialect.ts +++ b/src/main/acp/acp-dialects/grok-dialect.ts @@ -14,8 +14,27 @@ const completionSchema = z.looseObject({ sessionUpdate: z.literal('turn_completed'), prompt_id: z.string(), stop_reason: z.string(), + agent_result: z.string().nullish(), elapsed_ms: tokenCount.optional() }) +const retrySchema = z.looseObject({ + sessionUpdate: z.literal('retry_state'), + type: z.string(), + message: z.string().optional() +}) +const promptCompleteSchema = z.looseObject({ + sessionId: z.string(), + promptId: z.string(), + stopReason: z.string(), + agentResult: z.string().nullish() +}) +const promptErrorSchema = z.looseObject({ data: z.looseObject({ message: z.string() }) }) +/** Grok writes its result text as the failure's reason only for these ends. */ +const FAILED_STOP_REASONS = ['error', 'rate_limit'] + +function failureDetail(stopReason: string, result: string | null | undefined): string | undefined { + return FAILED_STOP_REASONS.includes(stopReason) && result?.trim() ? result : undefined +} const responseSchema = z.object({ sessionUpdate: z.literal('response_completed'), usage: z.object({ @@ -50,6 +69,17 @@ function contextWindow(models: unknown): number | undefined { ) } +function completionEnd( + completion: z.infer +): NonNullable['end']> { + const detail = failureDetail(completion.stop_reason, completion.agent_result) + return { + stopReason: completion.stop_reason, + durationMs: completion.elapsed_ms, + ...(detail ? { failureDetail: detail } : {}) + } +} + function notification( method: string, params: unknown, @@ -66,6 +96,16 @@ function notification( ? { disposition: 'ignore' } : { disposition: 'map', usage: { window: { tokens, capturedAt: at } } } } + if (canonical === 'x.ai/session/prompt_complete') { + // A copy of the turn's end; only its failure reason is read, in case the end carried none. + const parsed = promptCompleteSchema.safeParse(params) + const detail = parsed.success + ? failureDetail(parsed.data.stopReason, parsed.data.agentResult) + : undefined + return parsed.success && detail + ? { disposition: 'map', turn: parsed.data.promptId, failureDetail: detail } + : { disposition: 'ignore' } + } if (canonical === 'x.ai/queue/changed') { const parsed = queueSchema.safeParse(params) return parsed.success && parsed.data.runningPromptId @@ -86,7 +126,13 @@ function notification( const meta = turnMetaSchema.safeParse(envelope._meta) const completion = completionSchema.safeParse(envelope.update) const response = responseSchema.safeParse(envelope.update) - if (method !== 'session/update' && !completion.success && !response.success) { + // Only a retry Grok gave up on is read; it names no turn, so it joins the one running. + const retry = retrySchema.safeParse(envelope.update) + const retryFailure = + retry.success && retry.data.type === 'failed' && retry.data.message?.trim() + ? retry.data.message + : undefined + if (method !== 'session/update' && !completion.success && !response.success && !retryFailure) { return { disposition: 'ignore' } } const turn = completion.success @@ -99,11 +145,8 @@ function notification( ...(turn === undefined ? {} : { turn }), ...(meta.success && meta.data.turnStartMs !== undefined ? { at: meta.data.turnStartMs } : {}), replay: envelope._meta?.isReplay === true, - ...(completion.success - ? { - end: { stopReason: completion.data.stop_reason, durationMs: completion.data.elapsed_ms } - } - : {}), + ...(completion.success ? { end: completionEnd(completion.data) } : {}), + ...(retryFailure ? { failureDetail: retryFailure } : {}), ...(response.success ? { usage: { @@ -132,5 +175,6 @@ export const GROK_ACP_DIALECT: AcpDialect = { request: grokRequest, toolBackgroundTasks: grokToolBackgroundTasks, notification, - contextWindow + contextWindow, + promptErrorDetail: (error) => promptErrorSchema.safeParse(error).data?.data.message } diff --git a/src/main/acp/acp-prompt-turns.ts b/src/main/acp/acp-prompt-turns.ts index 6dd867688c87..c9fe584dcabe 100644 --- a/src/main/acp/acp-prompt-turns.ts +++ b/src/main/acp/acp-prompt-turns.ts @@ -11,6 +11,8 @@ export type AcpPromptTurn = { /** An injected identity is known before any provider output arrives. */ export class AcpPromptTurns { current?: AcpPromptTurn + /** The last prompt that ended, so its answer can still add the failure reason the end lacked. */ + last?: AcpPromptTurn constructor( private readonly sessionId: string, @@ -26,6 +28,11 @@ export class AcpPromptTurns { return { promptId: turn, events: this.injected ? [] : this.start(turn, at) } } + finish(): void { + this.last = this.current + this.current = undefined + } + start(turn: string, at: number): ProviderTimelineEvent[] { const prompt = this.current if (prompt?.turn !== turn || prompt.opened) { diff --git a/src/main/acp/acp-timeline-background-task-words.test.ts b/src/main/acp/acp-timeline-background-task-words.test.ts new file mode 100644 index 000000000000..72a835a42471 --- /dev/null +++ b/src/main/acp/acp-timeline-background-task-words.test.ts @@ -0,0 +1,97 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { backgroundTaskFallbackText } from '../../shared/native-chat-background-task-row' +import { isBackgroundTaskBlock } from '../../shared/native-chat-types' +import { closeProviderTimelineRigs } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import { openAcpFixtureRig, readAcpFixture } from './acp-timeline-fixture.test-support' + +afterEach(closeProviderTimelineRigs) + +type Fixture = Awaited> + +async function taskBlocks(fixture: Fixture) { + return (await fixture.rig.rows()).flatMap((row) => + row.body.kind === 'message' ? row.body.blocks.filter(isBackgroundTaskBlock) : [] + ) +} + +function notify(fixture: Fixture, method: string, update: Record) { + fixture.apply( + fixture + .lane() + .notification(method, { sessionId: 'session-1', _meta: { promptId: 'turn-1' }, update }, 2000) + ) +} + +describe('what a background task row says', () => { + it('describes a running recorded command by its description, not the start summary', async () => { + const fixture = await openAcpFixtureRig() + const frames = await readAcpFixture('s6-background') + const completion = frames.findIndex((frame) => frame.message.method === '_x.ai/task_completed') + await fixture.feed(frames.slice(0, completion)) + const rows = (await fixture.rig.rows()).filter( + (row) => row.body.kind === 'message' && row.body.blocks.some(isBackgroundTaskBlock) + ) + expect(rows).toHaveLength(1) + expect(rows[0]?.body).toMatchObject({ + blocks: [ + { type: 'text', text: 'Started background command "Sleep then write bg-marker.txt"' }, + expect.objectContaining({ type: 'background-task', state: 'working' }) + ] + }) + const [task] = await taskBlocks(fixture) + expect(task?.summary).toBeUndefined() + expect(JSON.stringify(rows[0]?.body)).not.toContain('Background task') + }) + + it('keeps a monitor a monitor when the agent reads its output', async () => { + const fixture = await openAcpFixtureRig() + notify(fixture, '_x.ai/task_backgrounded', { + sessionUpdate: 'task_backgrounded', + task_id: 'watch-1', + command: 'for i in 1 2 3; do echo tick $i; sleep 8; done', + monitor_description: 'Watch three tick echoes' + }) + notify(fixture, '_x.ai/task_completed', { + sessionUpdate: 'task_completed', + task_snapshot: { task_id: 'watch-1', kind: 'monitor', exit_code: 0 } + }) + for (const [index, command] of [ + '[monitor] Watch three tick echoes', + 'for i in 1 2 3; do echo tick $i; sleep 8; done' + ].entries()) { + notify(fixture, 'session/update', { + sessionUpdate: 'tool_call_update', + toolCallId: `read-${index}`, + status: 'completed', + rawOutput: { + type: 'TaskOutput', + Result: { task_id: 'watch-1', command, status: 'completed', exit_code: 0 } + } + }) + } + const [task] = await taskBlocks(fixture) + expect(task).toMatchObject({ kind: 'monitor', state: 'done' }) + expect(task && backgroundTaskFallbackText(task)).toBe( + 'Background monitor "Watch three tick echoes" finished' + ) + }) + + it('reads a monitor from its output command alone, and a named kind still wins', async () => { + const fixture = await openAcpFixtureRig() + notify(fixture, 'session/update', { + sessionUpdate: 'tool_call_update', + toolCallId: 'read-1', + status: 'completed', + rawOutput: { + type: 'TaskOutput', + Result: { task_id: 'watch-2', command: '[monitor] Tail the log', status: 'running' } + } + }) + expect((await taskBlocks(fixture))[0]?.kind).toBe('monitor') + notify(fixture, '_x.ai/task_completed', { + sessionUpdate: 'task_completed', + task_snapshot: { task_id: 'watch-2', task_type: 'bash', command: 'tail -f log', exit_code: 0 } + }) + expect((await taskBlocks(fixture))[0]?.kind).toBe('command') + }) +}) diff --git a/src/main/acp/acp-timeline-translator.ts b/src/main/acp/acp-timeline-translator.ts index 7fabbb48000f..ac09b6b276c7 100644 --- a/src/main/acp/acp-timeline-translator.ts +++ b/src/main/acp/acp-timeline-translator.ts @@ -1,25 +1,20 @@ import { z } from 'zod' import { BoundedMap } from '../../shared/bounded-map' import type { ProviderTimelineEvent } from '../native-chat/agent-session-timeline/provider-timeline-event' -import { - acpNotificationEnvelopeSchema, - AcpContextTimeline, - acpWindowUsage -} from './acp-context-usage' +import { acpNotificationEnvelopeSchema, AcpContextTimeline } from './acp-context-usage' import { AcpBackgroundTaskTimeline } from './acp-background-task-timeline' import { GENERIC_ACP_DIALECT, type AcpDialect, - type AcpDialectNotification, type AcpRequestPresentation } from './acp-dialects/acp-dialect' import { AcpHistoryAdoption } from './acp-history-adoption' import { acpTurnEnd, AcpPromptTurns } from './acp-prompt-turns' import type { AcpSessionEvent } from './acp-session-runtime' import { translateAcpRequest } from './acp-timeline-requests' -export { acpTurnEnd } from './acp-prompt-turns' import { acpSessionUpdate } from './acp-session-update' import { AcpToolTimeline } from './acp-tool-timeline' +import { AcpTurnFailures, acpPromptErrorDetail, acpStopReasonFailed } from './acp-turn-failures' import { AcpTurnMessages } from './acp-turn-messages' import { SessionNotificationSchema, @@ -27,6 +22,8 @@ import { type SessionNotification } from './generated/acp-protocol.generated' +export { acpTurnEnd } from './acp-prompt-turns' + const requestSessionSchema = z.object({ sessionId: z.string() }) const SUBSTANTIVE_UPDATES = [ 'user_message_chunk', @@ -55,12 +52,14 @@ export class AcpTimelineTranslator { private readonly backgroundTasks: AcpBackgroundTaskTimeline private readonly messages = new AcpTurnMessages() private readonly started = new BoundedMap({ maxEntries: 128 }) + private readonly failures: AcpTurnFailures private load?: { adoption?: AcpHistoryAdoption } private readonly context = new AcpContextTimeline() private activeTurn?: string constructor(private readonly options: AcpTimelineTranslatorOptions) { this.dialect = options.dialect ?? GENERIC_ACP_DIALECT + this.failures = new AcpTurnFailures(options.sessionId) this.backgroundTasks = new AcpBackgroundTaskTimeline((callId) => this.tools.turn(callId)) this.prompts = new AcpPromptTurns( options.sessionId, @@ -87,23 +86,30 @@ export class AcpTimelineTranslator { return this.finishPrompt(clientMessageId, result.stopReason, at) } - promptFailed(clientMessageId: string, _error: unknown, at: number): ProviderTimelineEvent[] { - return this.finishPrompt(clientMessageId, 'error', at) + promptFailed(clientMessageId: string, error: unknown, at: number): ProviderTimelineEvent[] { + const detail = acpPromptErrorDetail(this.dialect, error) + const ended = this.prompts.last + if (this.prompts.current?.clientMessageId !== clientMessageId) { + // The provider already ended this turn; its answer may carry the only copy of the reason. + return ended?.clientMessageId === clientMessageId && this.failures.has(ended.turn) + ? this.failures.row(ended.turn, detail) + : [] + } + return this.finishPrompt(clientMessageId, 'error', at, detail) } private finishPrompt( clientMessageId: string, stopReason: string, - at: number + at: number, + failureDetail?: string ): ProviderTimelineEvent[] { const prompt = this.prompts.current if (prompt?.clientMessageId !== clientMessageId) { return [] } - const events = this.prompts.start(prompt.turn, at) - events.push(acpTurnEnd(prompt.turn, stopReason, at, prompt.durationMs)) - this.end(prompt.turn) - this.prompts.current = undefined + const events = this.start(prompt.turn, at) + events.push(...this.endTurn(prompt.turn, stopReason, at, prompt.durationMs, failureDetail)) return events } @@ -145,11 +151,10 @@ export class AcpTimelineTranslator { if (session.success && session.data.sessionId !== this.options.sessionId) { return [] } - const interpreted = this.dialect.notification?.(method, params, at) - if (interpreted?.disposition === 'ignore') { + const extension = this.dialect.notification?.(method, params, at) + if (extension?.disposition === 'ignore') { return [] } - const extension = interpreted if (method !== 'session/update' && !extension) { return [] } @@ -162,7 +167,7 @@ export class AcpTimelineTranslator { const history = markedReplay || (extension?.replay === undefined && this.load !== undefined) const adoption = history ? this.load?.adoption : undefined if (history && !adoption) { - return this.historyUsage(update, extension, at) + return this.context.history(update, extension?.usage, at, { thread: this.options.sessionId }) } if (adoption && update?.sessionUpdate === 'user_message_chunk') { return this.historyUser(adoption, update, at) @@ -193,18 +198,18 @@ export class AcpTimelineTranslator { if (extension?.usage) { events.push(...this.context.update(extension.usage, join)) } - if (extension?.end && turn) { + if (extension?.failureDetail && turn) { + events.push(...this.failures.row(turn, extension.failureDetail)) + } + const end = extension?.end + if (end && turn) { if ( this.prompts.current?.turn === turn && - ['end_turn', 'cancelled'].includes(extension.end.stopReason) + ['end_turn', 'cancelled'].includes(end.stopReason) ) { - this.prompts.current.durationMs = extension.end.durationMs + this.prompts.current.durationMs = end.durationMs } else { - events.push(acpTurnEnd(turn, extension.end.stopReason, at, extension.end.durationMs)) - this.end(turn) - if (this.prompts.current?.turn === turn) { - this.prompts.current = undefined - } + events.push(...this.endTurn(turn, end.stopReason, at, end.durationMs, end.failureDetail)) } return events } @@ -243,16 +248,19 @@ export class AcpTimelineTranslator { }) } - /** History that is not adopted is dropped except what it says about the context window. */ - private historyUsage( - update: SessionNotification['update'] | undefined, - extension: Extract | undefined, - at: number + /** A failed end writes the reason row before the end, so it joins the turn while open. */ + private endTurn( + turn: string, + stopReason: string, + at: number, + durationMs: number | undefined, + failureDetail: string | undefined ): ProviderTimelineEvent[] { - const join = { thread: this.options.sessionId } - const events = extension?.usage ? this.context.update(extension.usage, join) : [] - if (update?.sessionUpdate === 'usage_update') { - events.push({ type: 'context.usage', usage: acpWindowUsage(update, at), join }) + const events = acpStopReasonFailed(stopReason) ? this.failures.row(turn, failureDetail) : [] + events.push(acpTurnEnd(turn, stopReason, at, durationMs)) + this.end(turn) + if (this.prompts.current?.turn === turn) { + this.prompts.finish() } return events } @@ -298,6 +306,8 @@ export class AcpTimelineTranslator { private start(turn: string, at: number): ProviderTimelineEvent[] { if (turn === this.prompts.current?.turn) { const events = this.prompts.start(turn, at) + // Marked so a late frame for this prompt after its end neither reopens nor retargets it. + this.started.set(turn, true) this.activeTurn = turn return events } diff --git a/src/main/acp/acp-timeline-turn-failures.test.ts b/src/main/acp/acp-timeline-turn-failures.test.ts new file mode 100644 index 000000000000..a164daef67ab --- /dev/null +++ b/src/main/acp/acp-timeline-turn-failures.test.ts @@ -0,0 +1,232 @@ +import { afterEach, describe, expect, it } from 'vitest' +import type { AgentJournalRenderItem } from '../../shared/agent-session-journal-types' +import { + closeProviderTimelineRigs, + messageText, + providerTurnItemId +} from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import { openAcpFixtureRig } from './acp-timeline-fixture.test-support' + +afterEach(closeProviderTimelineRigs) + +// The shape Grok sends for a prompt its API refused, with the reason scrubbed to a placeholder. +const REASON = 'API error (status 400 Bad Request): invalid_request_error: placeholder reason' +const session = { sessionId: 'session-1' } +const queued = (promptId: string) => ({ ...session, entries: [], runningPromptId: promptId }) +const grokUpdate = (update: Record) => ({ + ...session, + update, + _meta: { agentTimestampMs: 1 } +}) +const retryFailed = grokUpdate({ + sessionUpdate: 'retry_state', + type: 'failed', + error_type: 'api', + message: REASON +}) +const ended = (promptId: string, stopReason: string, agentResult?: string) => + grokUpdate({ + sessionUpdate: 'turn_completed', + prompt_id: promptId, + stop_reason: stopReason, + ...(agentResult === undefined ? {} : { agent_result: agentResult }), + elapsed_ms: 729 + }) +const promptComplete = (promptId: string, stopReason: string, agentResult: string | null) => ({ + ...session, + promptId, + stopReason, + agentResult +}) +const rpcError = { code: -32603, message: 'Internal error', data: { message: REASON } } + +function statusRows(rows: AgentJournalRenderItem[]) { + return rows.flatMap((row) => (row.body.kind === 'status' ? [{ row, body: row.body }] : [])) +} + +describe('a failed ACP turn says why', () => { + it('writes the reason once when Grok sends it four times, in the failed turn', async () => { + const f = await openAcpFixtureRig() + const lane = f.lane() + const prompt = lane.openPrompt('c1', 1000) + f.apply(lane.notification('_x.ai/queue/changed', queued(prompt.promptId), 1001)) + f.apply(lane.notification('_x.ai/session_notification', retryFailed, 1002)) + f.apply( + lane.notification('_x.ai/session_notification', ended(prompt.promptId, 'error', REASON), 1003) + ) + expect(statusRows(await f.rig.rows())[0]?.body.failure?.detail?.text).toBe(REASON) + f.apply( + lane.notification( + '_x.ai/session/prompt_complete', + promptComplete(prompt.promptId, 'error', REASON), + 1004 + ) + ) + f.apply(lane.promptFailed('c1', rpcError, 1005)) + const next = lane.openPrompt('c2', 1006) + f.apply(lane.notification('_x.ai/queue/changed', queued(next.promptId), 1007)) + f.apply( + lane.notification( + 'session/update', + { + ...session, + _meta: { promptId: next.promptId }, + update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'ok' } } + }, + 1008 + ) + ) + f.apply(lane.notification('_x.ai/session_notification', ended(next.promptId, 'end_turn'), 1009)) + f.apply(lane.promptResult('c2', { stopReason: 'end_turn' }, 1010)) + const rows = await f.rig.rows() + const failures = statusRows(rows) + expect(failures).toHaveLength(1) + expect(failures[0]?.body).toMatchObject({ + tone: 'error', + text: `The provider did not accept this message: ${REASON}.`, + failure: { kind: 'providerRejected', detail: { text: REASON, audience: 'person' } } + }) + const turns = await f.rig.turns() + expect(turns.map((turn) => [turn.state, turn.outcome])).toEqual([ + ['completed', 'failure'], + ['completed', 'success'] + ]) + expect(failures[0]?.row.turnScope).toEqual({ + kind: 'turn', + turnItemId: providerTurnItemId(prompt.promptId) + }) + expect( + rows.filter((row) => row.body.kind === 'message').map((row) => messageText(row.body)) + ).toEqual(['ok']) + }) + + it('takes the reason from the prompt error answer when no frame carried it', async () => { + const f = await openAcpFixtureRig() + const lane = f.lane() + const prompt = lane.openPrompt('c1', 1000) + f.apply(lane.notification('_x.ai/queue/changed', queued(prompt.promptId), 1001)) + f.apply(lane.notification('_x.ai/session_notification', ended(prompt.promptId, 'error'), 1002)) + expect(statusRows(await f.rig.rows())[0]?.body.text).toBe( + 'The provider did not accept this message.' + ) + f.apply(lane.promptFailed('c1', rpcError, 1003)) + f.apply(lane.promptFailed('c1', rpcError, 1004)) + const failures = statusRows(await f.rig.rows()) + expect(failures).toHaveLength(1) + expect(failures[0]?.body.failure?.detail?.text).toBe(REASON) + }) + + it.each(['retry_state', 'turn_completed', 'prompt_complete', 'prompt error answer'] as const)( + 'reads the reason when only the %s carries it', + async (source) => { + const f = await openAcpFixtureRig() + const lane = f.lane() + const prompt = lane.openPrompt('c1', 1000) + const reason = (carrier: typeof source) => (carrier === source ? REASON : undefined) + f.apply(lane.notification('_x.ai/queue/changed', queued(prompt.promptId), 1001)) + if (source === 'retry_state') { + f.apply(lane.notification('_x.ai/session_notification', retryFailed, 1002)) + } + f.apply( + lane.notification( + '_x.ai/session_notification', + ended(prompt.promptId, 'error', reason('turn_completed')), + 1003 + ) + ) + f.apply( + lane.notification( + '_x.ai/session/prompt_complete', + promptComplete(prompt.promptId, 'error', reason('prompt_complete') ?? null), + 1004 + ) + ) + f.apply( + lane.promptFailed( + 'c1', + source === 'prompt error answer' ? rpcError : { code: -32603, message: '' }, + 1005 + ) + ) + const failures = statusRows(await f.rig.rows()) + expect(failures.map((failure) => failure.body.failure?.detail?.text)).toEqual([REASON]) + } + ) + + it('keeps the reason when a later copy carries none', async () => { + const f = await openAcpFixtureRig() + const lane = f.lane() + const prompt = lane.openPrompt('c1', 1000) + f.apply(lane.notification('_x.ai/queue/changed', queued(prompt.promptId), 1001)) + f.apply(lane.notification('_x.ai/session_notification', retryFailed, 1002)) + f.apply(lane.notification('_x.ai/session_notification', ended(prompt.promptId, 'error'), 1003)) + f.apply(lane.promptFailed('c1', { code: -32603, message: 'Internal error' }, 1004)) + const failures = statusRows(await f.rig.rows()) + expect(failures.map((failure) => failure.body.failure?.detail?.text)).toEqual([REASON]) + }) + + it('records a rate-limited turn in the same words, without inventing a reason', async () => { + const f = await openAcpFixtureRig() + const lane = f.lane() + const prompt = lane.openPrompt('c1', 1000) + f.apply(lane.notification('_x.ai/queue/changed', queued(prompt.promptId), 1001)) + f.apply( + lane.notification('_x.ai/session_notification', ended(prompt.promptId, 'rate_limit'), 1002) + ) + f.apply( + lane.notification( + '_x.ai/session/prompt_complete', + promptComplete(prompt.promptId, 'rate_limit', null), + 1003 + ) + ) + const failures = statusRows(await f.rig.rows()) + expect(failures.map((failure) => failure.body.failure)).toEqual([{ kind: 'providerRejected' }]) + expect((await f.rig.turns())[0]?.outcome).toBe('failure') + }) + + it('writes no failure row for a turn the provider ended on purpose', async () => { + const f = await openAcpFixtureRig() + const lane = f.lane() + const prompt = lane.openPrompt('c1', 1000) + f.apply(lane.notification('_x.ai/queue/changed', queued(prompt.promptId), 1001)) + f.apply( + lane.notification('_x.ai/session_notification', ended(prompt.promptId, 'max_tokens'), 1002) + ) + f.apply(lane.promptResult('c1', { stopReason: 'max_tokens' }, 1003)) + expect(statusRows(await f.rig.rows())).toEqual([]) + }) + + it('does not reopen or retarget an ended prompt for its late frame', async () => { + const f = await openAcpFixtureRig() + const lane = f.lane() + const prompt = lane.openPrompt('c1', 1000) + f.apply(lane.notification('_x.ai/queue/changed', queued(prompt.promptId), 1001)) + f.apply( + lane.notification('_x.ai/session_notification', ended(prompt.promptId, 'end_turn'), 1002) + ) + f.apply(lane.promptResult('c1', { stopReason: 'end_turn' }, 1003)) + const late = lane.notification( + 'session/update', + { + ...session, + _meta: { promptId: prompt.promptId }, + update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'late' } } + }, + 1004 + ) + expect(late.filter((event) => event.type === 'turn.open')).toEqual([]) + const promptless = lane.notification( + 'session/update', + { + ...session, + update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'x' } } + }, + 1005 + ) + expect(promptless.find((event) => event.type === 'text.delta')).toMatchObject({ + join: { thread: 'session-1' } + }) + expect(promptless.find((event) => event.type === 'text.delta')?.join).not.toHaveProperty('turn') + }) +}) diff --git a/src/main/acp/acp-tool-timeline.ts b/src/main/acp/acp-tool-timeline.ts index 1a4675ed4330..28762bc4646d 100644 --- a/src/main/acp/acp-tool-timeline.ts +++ b/src/main/acp/acp-tool-timeline.ts @@ -77,11 +77,13 @@ type ToolSnapshot = { body: AgentJournalToolCallItem; turn?: string } /** Holds the snapshots that fill ACP's partial tool updates, settled ones evicted first. Its bound * is the assembler's open budget, which counts each running tool at no fewer bytes, so a running * tool is evicted only after the assembler refused one past that budget, which ends the session. - * An update for an evicted settled tool is dropped by the assembler as settled. */ + * An update for an evicted settled tool is dropped by the assembler as settled. + * A tool's turn is held once: on its snapshot while that lives, then in `turns`. */ export class AcpToolTimeline { private readonly tools = new Map() private bytes = 0 - /** Outlives the turn, so a later task or request still lands beside the tool that started it. */ + /** Turns of tools whose snapshot is gone, so a later task or request still lands beside the tool + * that started it. */ private readonly turns = new BoundedMap({ maxEntries: MAX_PROVIDER_TIMELINE_OPEN_ENTRIES }) @@ -122,14 +124,9 @@ export class AcpToolTimeline { : {} : { output: boundPayload(output, DEFAULT_JOURNAL_PAYLOAD_LIMITS) }) } - const snapshot: ToolSnapshot = { - body, - ...(join.turn === undefined ? {} : { turn: join.turn }) - } - this.remember(update.toolCallId, snapshot) - if (join.turn !== undefined) { - this.turns.set(update.toolCallId, join.turn) - } + const turn = join.turn ?? this.turn(update.toolCallId) + this.turns.delete(update.toolCallId) + this.remember(update.toolCallId, { body, ...(turn === undefined ? {} : { turn }) }) const events: ProviderTimelineEvent[] = [ { type: state === 'running' ? (previous ? 'item.update' : 'item.open') : 'item.close', @@ -173,11 +170,18 @@ export class AcpToolTimeline { for (const [key, snapshot] of this.tools) { if (snapshot.turn === turn) { this.bytes -= this.size(key, snapshot) - this.tools.delete(key) + this.retire(key, snapshot) } } } + private retire(key: string, snapshot: ToolSnapshot): void { + this.tools.delete(key) + if (snapshot.turn !== undefined) { + this.turns.set(key, snapshot.turn) + } + } + private fits(count: number, bytes: number): boolean { return count <= MAX_PROVIDER_TIMELINE_OPEN_ENTRIES && bytes <= MAX_PROVIDER_TIMELINE_OPEN_BYTES } @@ -191,8 +195,8 @@ export class AcpToolTimeline { if (this.size(key, snapshot) > MAX_PROVIDER_TIMELINE_OPEN_BYTES) { if (previous) { this.bytes -= this.size(key, previous) - this.tools.delete(key) } + this.retire(key, snapshot) return } let bytes = this.bytes - (previous ? this.size(key, previous) : 0) + this.size(key, snapshot) @@ -219,7 +223,10 @@ export class AcpToolTimeline { } } for (const candidate of evicted) { - this.tools.delete(candidate) + const stored = this.tools.get(candidate) + if (stored) { + this.retire(candidate, stored) + } } this.tools.delete(key) this.tools.set(key, snapshot) diff --git a/src/main/acp/acp-turn-failures.ts b/src/main/acp/acp-turn-failures.ts new file mode 100644 index 000000000000..d4e029f652d2 --- /dev/null +++ b/src/main/acp/acp-turn-failures.ts @@ -0,0 +1,57 @@ +import { z } from 'zod' +import { agentSessionFailureFact, providerDiagnostic } from '../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../shared/agent-session-failure-words' +import { BoundedMap } from '../../shared/bounded-map' +import type { ProviderTimelineEvent } from '../native-chat/agent-session-timeline/provider-timeline-event' +import type { AcpDialect } from './acp-dialects/acp-dialect' + +const promptErrorSchema = z.looseObject({ message: z.string() }) +/** Ends the provider failed, rather than ones it chose (a refusal, a token limit). */ +const FAILED_STOP_REASONS = ['error', 'rate_limit'] + +export function acpStopReasonFailed(stopReason: string): boolean { + return FAILED_STOP_REASONS.includes(stopReason) +} + +/** The provider's words in its error answer to `session/prompt`. */ +export function acpPromptErrorDetail(dialect: AcpDialect, error: unknown): string | undefined { + return dialect.promptErrorDetail?.(error) ?? promptErrorSchema.safeParse(error).data?.message +} + +/** One status row per failed turn, in Orca's rejection words with the provider's reason. Providers + * send that reason several times (beside the end, after it, in the prompt's error answer), so a + * later copy only adds a reason the row still lacks. */ +export class AcpTurnFailures { + /** The reason each failed turn's row holds; '' for none yet. */ + private readonly rows = new BoundedMap({ maxEntries: 128 }) + + constructor(private readonly sessionId: string) {} + + has(turn: string): boolean { + return this.rows.has(turn) + } + + row(turn: string, text: string | undefined): ProviderTimelineEvent[] { + const written = this.rows.peek(turn) + const detail = text === undefined ? undefined : providerDiagnostic(text, 'person') + if (written !== undefined && (written !== '' || !detail)) { + return [] + } + this.rows.set(turn, detail?.text ?? '') + return [ + { + type: 'item.update', + item: `turn-failure:${turn}`, + body: { + kind: 'status', + tone: 'error', + ...agentSessionFailureWords( + agentSessionFailureFact('providerRejected', detail ? { detail } : {}), + { surface: 'row' } + ) + }, + join: { thread: this.sessionId, turn } + } + ] + } +} From e88bbf0bb11ea9f87a83d226de255e0b338f11ad Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 01:59:35 -0700 Subject: [PATCH 52/62] Read a monitor from Grok's exact output prefix --- src/main/acp/acp-dialects/grok-background-tasks.ts | 5 +++-- src/main/acp/acp-timeline-background-task-words.test.ts | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/main/acp/acp-dialects/grok-background-tasks.ts b/src/main/acp/acp-dialects/grok-background-tasks.ts index 3f7bd3f32f64..415a765840e4 100644 --- a/src/main/acp/acp-dialects/grok-background-tasks.ts +++ b/src/main/acp/acp-dialects/grok-background-tasks.ts @@ -43,6 +43,8 @@ const resultOutputSchema = z.object({ MultiResult: z.object({ results: z.array(taskResultSchema) }).optional() }) type GrokTask = z.infer +/** Grok's output reads name a monitor only by this command prefix. */ +const MONITOR_COMMAND = /^\[monitor[:\]]/ function completedState(task: GrokTask): NativeChatBackgroundTaskBlock['state'] { return task.explicitly_killed @@ -76,11 +78,10 @@ function snapshot( state: NativeChatBackgroundTaskBlock['state'] ): AcpBackgroundTaskUpdate { const kind = task.kind ?? task.task_type - // Grok's output reads name a monitor only by this command prefix. const monitor = kind === 'monitor' || task.monitor_description != null || - task.command?.startsWith('[monitor') === true + MONITOR_COMMAND.test(task.command ?? '') const label = task.monitor_description?.trim() || task.description?.trim() const fallbackLabel = task.display_command?.trim() || task.command?.trim() const settled = isSettledBackgroundTaskState(state) diff --git a/src/main/acp/acp-timeline-background-task-words.test.ts b/src/main/acp/acp-timeline-background-task-words.test.ts index 72a835a42471..c167d5c35927 100644 --- a/src/main/acp/acp-timeline-background-task-words.test.ts +++ b/src/main/acp/acp-timeline-background-task-words.test.ts @@ -84,7 +84,7 @@ describe('what a background task row says', () => { status: 'completed', rawOutput: { type: 'TaskOutput', - Result: { task_id: 'watch-2', command: '[monitor] Tail the log', status: 'running' } + Result: { task_id: 'watch-2', command: '[monitor:tail] Tail the log', status: 'running' } } }) expect((await taskBlocks(fixture))[0]?.kind).toBe('monitor') From ad7678cc9724ab40c3adf69dcbca63609a651c37 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 02:15:27 -0700 Subject: [PATCH 53/62] Word a failed Grok turn in Grok's own text, not as a refused message A turn that started and then failed was told "The provider did not accept this message", Orca's sentence for a message refused before its turn. The row now reads as a Codex turn-ending error does: an error status row with the provider's own words. With no words, the dialect names the failure ("Grok ended this turn with an error." / "Grok usage limit reached."), else the agent's display name does. --- src/main/acp/acp-dialects/acp-dialect.ts | 2 ++ src/main/acp/acp-dialects/grok-dialect.ts | 6 +++- src/main/acp/acp-timeline-generic.test.ts | 28 +++++++++++++++- src/main/acp/acp-timeline-translator.ts | 8 +++-- .../acp/acp-timeline-turn-failures.test.ts | 29 ++++++++-------- src/main/acp/acp-turn-failures.ts | 33 ++++++++++++------- 6 files changed, 75 insertions(+), 31 deletions(-) diff --git a/src/main/acp/acp-dialects/acp-dialect.ts b/src/main/acp/acp-dialects/acp-dialect.ts index 5a7facd99a69..4697189a20ee 100644 --- a/src/main/acp/acp-dialects/acp-dialect.ts +++ b/src/main/acp/acp-dialects/acp-dialect.ts @@ -46,6 +46,8 @@ export type AcpDialect = { request?(method: string, params: unknown): AcpRequestPresentation | undefined /** The provider's words in a `session/prompt` error answer, when its message is generic. */ promptErrorDetail?(error: unknown): string | undefined + /** The row for a failed turn the provider gave no words for. */ + failedTurnText?(stopReason: string): string } export const GENERIC_ACP_DIALECT: AcpDialect = {} diff --git a/src/main/acp/acp-dialects/grok-dialect.ts b/src/main/acp/acp-dialects/grok-dialect.ts index 87569e9d3ed5..b47f899f30ee 100644 --- a/src/main/acp/acp-dialects/grok-dialect.ts +++ b/src/main/acp/acp-dialects/grok-dialect.ts @@ -176,5 +176,9 @@ export const GROK_ACP_DIALECT: AcpDialect = { toolBackgroundTasks: grokToolBackgroundTasks, notification, contextWindow, - promptErrorDetail: (error) => promptErrorSchema.safeParse(error).data?.data.message + promptErrorDetail: (error) => promptErrorSchema.safeParse(error).data?.data.message, + failedTurnText: (stopReason) => + stopReason === 'rate_limit' + ? 'Grok usage limit reached.' + : 'Grok ended this turn with an error.' } diff --git a/src/main/acp/acp-timeline-generic.test.ts b/src/main/acp/acp-timeline-generic.test.ts index a18c67b03b04..4a041b3ad3ae 100644 --- a/src/main/acp/acp-timeline-generic.test.ts +++ b/src/main/acp/acp-timeline-generic.test.ts @@ -11,7 +11,7 @@ import { AcpTimelineTranslator, acpTurnEnd } from './acp-timeline-translator' afterEach(closeProviderTimelineRigs) -async function genericRig(options: { adopt?: boolean } = {}) { +async function genericRig(options: { adopt?: boolean; agentName?: string } = {}) { const rig = await openProviderTimelineRig() const translator = new AcpTimelineTranslator({ sessionId: 'provider-1', ...options }) const apply = (events: ProviderTimelineEvent[]) => { @@ -167,6 +167,32 @@ describe('generic ACP translation', () => { } ) + it.each([ + [undefined, 'The agent ended this turn with an error.'], + ['Agent X', 'Agent X ended this turn with an error.'] + ])( + "writes a failed turn row in the agent's words, or names the agent (%s) without any", + async (agentName, fallback) => { + const { rig, translator, apply } = await genericRig(agentName ? { agentName } : {}) + apply(translator.openPrompt('send-1', 1000).events) + apply(translator.promptResult('send-1', { stopReason: 'end_turn' }, 1100)) + apply(translator.openPrompt('send-2', 1200).events) + apply(translator.promptFailed('send-2', { code: -32603, message: 'Upstream failed' }, 1300)) + apply(translator.openPrompt('send-3', 1400).events) + apply(translator.promptFailed('send-3', { code: -32603 }, 1500)) + const rows = (await rig.rows()).filter((row) => row.body.kind === 'status') + expect(rows.map((row) => row.body)).toEqual([ + { kind: 'status', tone: 'error', text: 'Upstream failed' }, + { kind: 'status', tone: 'error', text: fallback } + ]) + expect((await rig.turns()).map((turn) => turn.outcome)).toEqual([ + 'success', + 'failure', + 'failure' + ]) + } + ) + it('keeps unknown verdicts unknown and preserves unknown frames without opening a turn', async () => { const { rig, translator, apply } = await genericRig() expect(acpTurnEnd('turn', 'future_stop', 1200)).not.toHaveProperty('outcome') diff --git a/src/main/acp/acp-timeline-translator.ts b/src/main/acp/acp-timeline-translator.ts index ac09b6b276c7..2610d1de533f 100644 --- a/src/main/acp/acp-timeline-translator.ts +++ b/src/main/acp/acp-timeline-translator.ts @@ -14,7 +14,7 @@ import type { AcpSessionEvent } from './acp-session-runtime' import { translateAcpRequest } from './acp-timeline-requests' import { acpSessionUpdate } from './acp-session-update' import { AcpToolTimeline } from './acp-tool-timeline' -import { AcpTurnFailures, acpPromptErrorDetail, acpStopReasonFailed } from './acp-turn-failures' +import { AcpTurnFailures, acpPromptErrorDetail } from './acp-turn-failures' import { AcpTurnMessages } from './acp-turn-messages' import { SessionNotificationSchema, @@ -41,6 +41,8 @@ export type AcpTimelineTranslatorOptions = { * because the journal is empty (an adopted session). Otherwise history stays out of the * timeline and only its context usage reads on. */ adopt?: boolean + /** The agent's display name, for a failed turn the provider gave no words for. */ + agentName?: string } /** Consumes each frame once; the host retries the returned grammar events. Lives exactly as long @@ -59,7 +61,7 @@ export class AcpTimelineTranslator { constructor(private readonly options: AcpTimelineTranslatorOptions) { this.dialect = options.dialect ?? GENERIC_ACP_DIALECT - this.failures = new AcpTurnFailures(options.sessionId) + this.failures = new AcpTurnFailures(options.sessionId, this.dialect, options.agentName) this.backgroundTasks = new AcpBackgroundTaskTimeline((callId) => this.tools.turn(callId)) this.prompts = new AcpPromptTurns( options.sessionId, @@ -256,7 +258,7 @@ export class AcpTimelineTranslator { durationMs: number | undefined, failureDetail: string | undefined ): ProviderTimelineEvent[] { - const events = acpStopReasonFailed(stopReason) ? this.failures.row(turn, failureDetail) : [] + const events = this.failures.ended(turn, stopReason, failureDetail) events.push(acpTurnEnd(turn, stopReason, at, durationMs)) this.end(turn) if (this.prompts.current?.turn === turn) { diff --git a/src/main/acp/acp-timeline-turn-failures.test.ts b/src/main/acp/acp-timeline-turn-failures.test.ts index a164daef67ab..54e50405582c 100644 --- a/src/main/acp/acp-timeline-turn-failures.test.ts +++ b/src/main/acp/acp-timeline-turn-failures.test.ts @@ -54,7 +54,7 @@ describe('a failed ACP turn says why', () => { f.apply( lane.notification('_x.ai/session_notification', ended(prompt.promptId, 'error', REASON), 1003) ) - expect(statusRows(await f.rig.rows())[0]?.body.failure?.detail?.text).toBe(REASON) + expect(statusRows(await f.rig.rows())[0]?.body.text).toBe(REASON) f.apply( lane.notification( '_x.ai/session/prompt_complete', @@ -81,11 +81,8 @@ describe('a failed ACP turn says why', () => { const rows = await f.rig.rows() const failures = statusRows(rows) expect(failures).toHaveLength(1) - expect(failures[0]?.body).toMatchObject({ - tone: 'error', - text: `The provider did not accept this message: ${REASON}.`, - failure: { kind: 'providerRejected', detail: { text: REASON, audience: 'person' } } - }) + // The turn ran, so the row reads as Codex's turn-ending error does: no refusal sentence or fact. + expect(failures[0]?.body).toEqual({ kind: 'status', tone: 'error', text: REASON }) const turns = await f.rig.turns() expect(turns.map((turn) => [turn.state, turn.outcome])).toEqual([ ['completed', 'failure'], @@ -106,14 +103,16 @@ describe('a failed ACP turn says why', () => { const prompt = lane.openPrompt('c1', 1000) f.apply(lane.notification('_x.ai/queue/changed', queued(prompt.promptId), 1001)) f.apply(lane.notification('_x.ai/session_notification', ended(prompt.promptId, 'error'), 1002)) - expect(statusRows(await f.rig.rows())[0]?.body.text).toBe( - 'The provider did not accept this message.' - ) + expect(statusRows(await f.rig.rows())[0]?.body).toEqual({ + kind: 'status', + tone: 'error', + text: 'Grok ended this turn with an error.' + }) f.apply(lane.promptFailed('c1', rpcError, 1003)) f.apply(lane.promptFailed('c1', rpcError, 1004)) const failures = statusRows(await f.rig.rows()) expect(failures).toHaveLength(1) - expect(failures[0]?.body.failure?.detail?.text).toBe(REASON) + expect(failures[0]?.body).toEqual({ kind: 'status', tone: 'error', text: REASON }) }) it.each(['retry_state', 'turn_completed', 'prompt_complete', 'prompt error answer'] as const)( @@ -149,7 +148,7 @@ describe('a failed ACP turn says why', () => { ) ) const failures = statusRows(await f.rig.rows()) - expect(failures.map((failure) => failure.body.failure?.detail?.text)).toEqual([REASON]) + expect(failures.map((failure) => failure.body.text)).toEqual([REASON]) } ) @@ -162,10 +161,10 @@ describe('a failed ACP turn says why', () => { f.apply(lane.notification('_x.ai/session_notification', ended(prompt.promptId, 'error'), 1003)) f.apply(lane.promptFailed('c1', { code: -32603, message: 'Internal error' }, 1004)) const failures = statusRows(await f.rig.rows()) - expect(failures.map((failure) => failure.body.failure?.detail?.text)).toEqual([REASON]) + expect(failures.map((failure) => failure.body.text)).toEqual([REASON]) }) - it('records a rate-limited turn in the same words, without inventing a reason', async () => { + it('names a rate-limited turn the provider gave no words for, without inventing a reason', async () => { const f = await openAcpFixtureRig() const lane = f.lane() const prompt = lane.openPrompt('c1', 1000) @@ -181,7 +180,9 @@ describe('a failed ACP turn says why', () => { ) ) const failures = statusRows(await f.rig.rows()) - expect(failures.map((failure) => failure.body.failure)).toEqual([{ kind: 'providerRejected' }]) + expect(failures.map((failure) => failure.body)).toEqual([ + { kind: 'status', tone: 'error', text: 'Grok usage limit reached.' } + ]) expect((await f.rig.turns())[0]?.outcome).toBe('failure') }) diff --git a/src/main/acp/acp-turn-failures.ts b/src/main/acp/acp-turn-failures.ts index d4e029f652d2..cc2998945a1d 100644 --- a/src/main/acp/acp-turn-failures.ts +++ b/src/main/acp/acp-turn-failures.ts @@ -1,6 +1,5 @@ import { z } from 'zod' -import { agentSessionFailureFact, providerDiagnostic } from '../../shared/agent-session-failure' -import { agentSessionFailureWords } from '../../shared/agent-session-failure-words' +import { providerDiagnostic } from '../../shared/agent-session-failure' import { BoundedMap } from '../../shared/bounded-map' import type { ProviderTimelineEvent } from '../native-chat/agent-session-timeline/provider-timeline-event' import type { AcpDialect } from './acp-dialects/acp-dialect' @@ -9,7 +8,7 @@ const promptErrorSchema = z.looseObject({ message: z.string() }) /** Ends the provider failed, rather than ones it chose (a refusal, a token limit). */ const FAILED_STOP_REASONS = ['error', 'rate_limit'] -export function acpStopReasonFailed(stopReason: string): boolean { +function acpStopReasonFailed(stopReason: string): boolean { return FAILED_STOP_REASONS.includes(stopReason) } @@ -18,20 +17,30 @@ export function acpPromptErrorDetail(dialect: AcpDialect, error: unknown): strin return dialect.promptErrorDetail?.(error) ?? promptErrorSchema.safeParse(error).data?.message } -/** One status row per failed turn, in Orca's rejection words with the provider's reason. Providers - * send that reason several times (beside the end, after it, in the prompt's error answer), so a - * later copy only adds a reason the row still lacks. */ +/** One error row per failed turn, in the provider's own words, as a Codex turn-ending error reads: + * the message was accepted and the turn ran, so it is no refusal. Providers send that reason several + * times (beside the end, after it, in the prompt's error answer), so a later copy only adds a + * reason the row still lacks. */ export class AcpTurnFailures { /** The reason each failed turn's row holds; '' for none yet. */ private readonly rows = new BoundedMap({ maxEntries: 128 }) - constructor(private readonly sessionId: string) {} + constructor( + private readonly sessionId: string, + private readonly dialect: AcpDialect, + private readonly agentName: string | undefined + ) {} has(turn: string): boolean { return this.rows.has(turn) } - row(turn: string, text: string | undefined): ProviderTimelineEvent[] { + /** The row an end writes, if it failed. */ + ended(turn: string, stopReason: string, text: string | undefined): ProviderTimelineEvent[] { + return acpStopReasonFailed(stopReason) ? this.row(turn, text, stopReason) : [] + } + + row(turn: string, text: string | undefined, stopReason = 'error'): ProviderTimelineEvent[] { const written = this.rows.peek(turn) const detail = text === undefined ? undefined : providerDiagnostic(text, 'person') if (written !== undefined && (written !== '' || !detail)) { @@ -45,10 +54,10 @@ export class AcpTurnFailures { body: { kind: 'status', tone: 'error', - ...agentSessionFailureWords( - agentSessionFailureFact('providerRejected', detail ? { detail } : {}), - { surface: 'row' } - ) + text: + detail?.text ?? + this.dialect.failedTurnText?.(stopReason) ?? + `${this.agentName ?? 'The agent'} ended this turn with an error.` }, join: { thread: this.sessionId, turn } } From 7aa40a99e5e0d0e2a03508767b22bd6071c817c3 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 02:26:45 -0700 Subject: [PATCH 54/62] Register the ACP schema verify step in the PR preflight phase test --- config/scripts/pr-preflight-gates.test.mjs | 1 + 1 file changed, 1 insertion(+) diff --git a/config/scripts/pr-preflight-gates.test.mjs b/config/scripts/pr-preflight-gates.test.mjs index 4be1aa2b740c..7ba8a881d8f3 100644 --- a/config/scripts/pr-preflight-gates.test.mjs +++ b/config/scripts/pr-preflight-gates.test.mjs @@ -200,6 +200,7 @@ it('pins every foreground and background step to its selected phase', () => { ['Check Node runtime pin', staticPhase], ['Boot orcad and round-trip a terminal', staticPhase], ['Verify the generated RPC params catalog', staticPhase], + ['Verify the generated ACP protocol schema', staticPhase], ['Verify bundled skill guides', staticPhase], ['Verify skill freshness manifest', staticPhase], ['Verify localization coverage', staticPhase], From c4655b41254679eb50dfe6cdf779c9fda22fc4b5 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 03:12:32 -0700 Subject: [PATCH 55/62] Read ACP permissions, session events and prompt errors through the protocol client's own types The translator now reads a permission request with the client's lenient reader, a session update with its session-event reader, and takes only the agent's own error answer as a failed prompt's reason, so an Orca-side error never reads as the provider's words. Tests cover protocol values newer than this build. --- src/main/acp/acp-dialects/acp-dialect.ts | 3 +- src/main/acp/acp-dialects/grok-dialect.ts | 4 +- src/main/acp/acp-timeline-generic.test.ts | 5 +- src/main/acp/acp-timeline-open-enums.test.ts | 104 ++++++++++++++++++ src/main/acp/acp-timeline-requests.ts | 9 +- src/main/acp/acp-timeline-routing.test.ts | 5 +- src/main/acp/acp-timeline-translator.ts | 25 ++--- .../acp/acp-timeline-turn-failures.test.ts | 7 +- src/main/acp/acp-turn-failures.ts | 7 +- 9 files changed, 138 insertions(+), 31 deletions(-) create mode 100644 src/main/acp/acp-timeline-open-enums.test.ts diff --git a/src/main/acp/acp-dialects/acp-dialect.ts b/src/main/acp/acp-dialects/acp-dialect.ts index 4697189a20ee..3b38b9cc1bdd 100644 --- a/src/main/acp/acp-dialects/acp-dialect.ts +++ b/src/main/acp/acp-dialects/acp-dialect.ts @@ -3,6 +3,7 @@ import type { AgentJournalToolCallItem } from '../../../shared/agent-session-jou import type { AgentSessionPromptResponse } from '../../../shared/agent-session-question-answer' import type { NativeChatBackgroundTaskBlock } from '../../../shared/native-chat-types' import type { ProviderTimelineRequestBody } from '../../native-chat/agent-session-timeline/provider-timeline-event' +import type { AcpAgentError } from '../acp-errors' import type { ToolCallUpdate } from '../generated/acp-protocol.generated' export type AcpRequestPresentation = { @@ -45,7 +46,7 @@ export type AcpDialect = { contextWindow?(models: unknown): number | undefined request?(method: string, params: unknown): AcpRequestPresentation | undefined /** The provider's words in a `session/prompt` error answer, when its message is generic. */ - promptErrorDetail?(error: unknown): string | undefined + promptErrorDetail?(error: AcpAgentError): string | undefined /** The row for a failed turn the provider gave no words for. */ failedTurnText?(stopReason: string): string } diff --git a/src/main/acp/acp-dialects/grok-dialect.ts b/src/main/acp/acp-dialects/grok-dialect.ts index b47f899f30ee..09213c973894 100644 --- a/src/main/acp/acp-dialects/grok-dialect.ts +++ b/src/main/acp/acp-dialects/grok-dialect.ts @@ -28,7 +28,7 @@ const promptCompleteSchema = z.looseObject({ stopReason: z.string(), agentResult: z.string().nullish() }) -const promptErrorSchema = z.looseObject({ data: z.looseObject({ message: z.string() }) }) +const promptErrorDataSchema = z.looseObject({ message: z.string() }) /** Grok writes its result text as the failure's reason only for these ends. */ const FAILED_STOP_REASONS = ['error', 'rate_limit'] @@ -176,7 +176,7 @@ export const GROK_ACP_DIALECT: AcpDialect = { toolBackgroundTasks: grokToolBackgroundTasks, notification, contextWindow, - promptErrorDetail: (error) => promptErrorSchema.safeParse(error).data?.data.message, + promptErrorDetail: (error) => promptErrorDataSchema.safeParse(error.data).data?.message, failedTurnText: (stopReason) => stopReason === 'rate_limit' ? 'Grok usage limit reached.' diff --git a/src/main/acp/acp-timeline-generic.test.ts b/src/main/acp/acp-timeline-generic.test.ts index 4a041b3ad3ae..9d08552577fe 100644 --- a/src/main/acp/acp-timeline-generic.test.ts +++ b/src/main/acp/acp-timeline-generic.test.ts @@ -7,6 +7,7 @@ import { openProviderTimelineRig } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' import type { ProviderTimelineEvent } from '../native-chat/agent-session-timeline/provider-timeline-event' +import { AcpAgentError } from './acp-errors' import { AcpTimelineTranslator, acpTurnEnd } from './acp-timeline-translator' afterEach(closeProviderTimelineRigs) @@ -177,9 +178,9 @@ describe('generic ACP translation', () => { apply(translator.openPrompt('send-1', 1000).events) apply(translator.promptResult('send-1', { stopReason: 'end_turn' }, 1100)) apply(translator.openPrompt('send-2', 1200).events) - apply(translator.promptFailed('send-2', { code: -32603, message: 'Upstream failed' }, 1300)) + apply(translator.promptFailed('send-2', new AcpAgentError(-32603, 'Upstream failed'), 1300)) apply(translator.openPrompt('send-3', 1400).events) - apply(translator.promptFailed('send-3', { code: -32603 }, 1500)) + apply(translator.promptFailed('send-3', new AcpAgentError(-32603, ''), 1500)) const rows = (await rig.rows()).filter((row) => row.body.kind === 'status') expect(rows.map((row) => row.body)).toEqual([ { kind: 'status', tone: 'error', text: 'Upstream failed' }, diff --git a/src/main/acp/acp-timeline-open-enums.test.ts b/src/main/acp/acp-timeline-open-enums.test.ts new file mode 100644 index 000000000000..08907152afa0 --- /dev/null +++ b/src/main/acp/acp-timeline-open-enums.test.ts @@ -0,0 +1,104 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { + closeProviderTimelineRigs, + openProviderTimelineRig +} from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import type { ProviderTimelineEvent } from '../native-chat/agent-session-timeline/provider-timeline-event' +import { AcpTimelineTranslator } from './acp-timeline-translator' + +afterEach(closeProviderTimelineRigs) + +async function genericRig() { + const rig = await openProviderTimelineRig() + const translator = new AcpTimelineTranslator({ sessionId: 'provider-1' }) + const apply = (events: ProviderTimelineEvent[]) => { + for (const event of events) { + expect(rig.assembler.apply(event).admission.accepted).toBe(true) + } + } + const update = (update: unknown) => + apply(translator.notification('session/update', { sessionId: 'provider-1', update }, 1100)) + return { rig, translator, apply, update } +} + +// The protocol's enums are open: a value newer than this build reads as known and falls back. +describe('ACP values newer than this build', () => { + it('keeps a tool with a newer kind or status as a tool row in its last known state', async () => { + const { rig, translator, apply, update } = await genericRig() + apply(translator.openPrompt('send-1', 1000).events) + update({ + sessionUpdate: 'tool_call', + toolCallId: 't1', + title: 'Open page', + kind: 'browse', + status: 'pending' + }) + update({ sessionUpdate: 'tool_call_update', toolCallId: 't1', status: 'paused' }) + const running = (await rig.rows()).find((row) => row.body.kind === 'tool-call')?.body + expect(running).toMatchObject({ name: 'Open page', state: 'running' }) + update({ sessionUpdate: 'tool_call_update', toolCallId: 't1', status: 'completed' }) + apply(translator.promptResult('send-1', { stopReason: 'end_turn' }, 1200)) + const rows = (await rig.rows()).filter((row) => row.body.kind !== 'turn') + expect(rows.map((row) => row.body)).toEqual([ + expect.objectContaining({ kind: 'tool-call', state: 'completed' }) + ]) + }) + + it('reads a plan entry with a newer status or priority as not done', async () => { + const { rig, translator, apply, update } = await genericRig() + apply(translator.openPrompt('send-1', 1000).events) + update({ + sessionUpdate: 'plan', + entries: [{ content: 'Wait', status: 'blocked', priority: 'urgent' }] + }) + apply(translator.promptResult('send-1', { stopReason: 'end_turn' }, 1200)) + expect( + (await rig.rows()).filter((row) => row.body.kind !== 'turn').map((row) => row.body) + ).toEqual([expect.objectContaining({ presentation: 'plan-document', text: '- [ ] Wait' })]) + }) + + it('ends a turn with a newer stop reason without inventing an outcome', async () => { + const { rig, translator, apply } = await genericRig() + apply(translator.openPrompt('send-1', 1000).events) + apply(translator.promptResult('send-1', { stopReason: 'paused_for_review' }, 1200)) + const [turn] = await rig.turns() + expect(turn).toMatchObject({ state: 'completed' }) + expect(turn).not.toHaveProperty('outcome') + expect((await rig.rows()).filter((row) => row.body.kind === 'status')).toEqual([]) + }) + + it('shows a permission the runtime could only partly read, with what it could read', async () => { + const { rig, translator, apply } = await genericRig() + apply(translator.openPrompt('send-1', 1000).events) + const request = translator.request( + 'session/request_permission', + { + sessionId: 'provider-1', + toolCall: { toolCallId: 't1', title: 42 }, + options: [{ optionId: 'allow', kind: 'allow_for_project' }, { name: 'no id' }] + }, + 7 + ) + apply(request.events) + expect(request.presentation?.body).toMatchObject({ + kind: 'approval', + title: 'Permission requested', + options: [{ id: 'allow', label: 'allow' }] + }) + expect(request.presentation?.reply({ kind: 'option', optionId: 'allow' })).toEqual({ + outcome: { outcome: 'selected', optionId: 'allow' } + }) + expect((await rig.rows()).map((row) => row.body.kind)).toEqual(['turn', 'approval']) + expect(() => + translator.request( + 'session/request_permission', + { + sessionId: 'provider-1', + toolCall: {}, + options: [{ optionId: 'allow', kind: 'allow_once' }] + }, + 8 + ) + ).toThrow('Invalid ACP permission request') + }) +}) diff --git a/src/main/acp/acp-timeline-requests.ts b/src/main/acp/acp-timeline-requests.ts index b60e860d2cda..aa7e396c1368 100644 --- a/src/main/acp/acp-timeline-requests.ts +++ b/src/main/acp/acp-timeline-requests.ts @@ -3,7 +3,7 @@ import type { ProviderTimelineEvent } from '../native-chat/agent-session-timelin import type { AcpDialect, AcpRequestPresentation } from './acp-dialects/acp-dialect' import type { AcpToolTimeline } from './acp-tool-timeline' import { AcpRpcError } from './acp-errors' -import { RequestPermissionRequestSchema } from './generated/acp-protocol.generated' +import { readAcpPermissionRequest } from './acp-permission-requests' export const pendingAcpResolution = { state: 'pending', @@ -13,11 +13,12 @@ export const pendingAcpResolution = { } as const export function acpPermissionPresentation(params: unknown): AcpRequestPresentation { - const parsed = RequestPermissionRequestSchema.safeParse(params) - if (!parsed.success) { + // The runtime already read this request and reported any field it dropped. + const request = readAcpPermissionRequest(params, () => {}) + if (!request) { throw new AcpRpcError(-32602, 'Invalid ACP permission request') } - const { toolCall, options } = parsed.data + const { toolCall, options } = request return { body: { kind: 'approval', diff --git a/src/main/acp/acp-timeline-routing.test.ts b/src/main/acp/acp-timeline-routing.test.ts index d5c12540c7bc..4ea6c30df29d 100644 --- a/src/main/acp/acp-timeline-routing.test.ts +++ b/src/main/acp/acp-timeline-routing.test.ts @@ -4,6 +4,7 @@ import { messageText } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' import { openAcpFixtureRig } from './acp-timeline-fixture.test-support' +import { AcpAgentError } from './acp-errors' const chunk = (promptId: string, text: string) => ({ sessionId: 'session-1', @@ -101,7 +102,7 @@ describe('ACP exact prompt identity', () => { f.apply( lane.notification('_x.ai/session_notification', done(prompt.promptId, stopReason), 1002) ) - f.apply(lane.promptFailed('c1', new Error('Provider rejected prompt'), 1003)) + f.apply(lane.promptFailed('c1', new AcpAgentError(-32603, 'Provider rejected prompt'), 1003)) const next = lane.openPrompt('c2', 1004) f.apply(next.events) f.apply(lane.notification('session/update', chunk(next.promptId, 'next'), 1005)) @@ -115,7 +116,7 @@ describe('ACP exact prompt identity', () => { const f = await openAcpFixtureRig() const lane = f.lane() lane.openPrompt('c1', 1000) - f.apply(lane.promptFailed('c1', { code: -32603, message: 'Rejected' }, 1001)) + f.apply(lane.promptFailed('c1', new AcpAgentError(-32603, 'Rejected'), 1001)) expect((await f.rig.turns())[0]).toMatchObject({ state: 'completed', outcome: 'failure' }) expect(() => lane.openPrompt('c2', 1002)).not.toThrow() }) diff --git a/src/main/acp/acp-timeline-translator.ts b/src/main/acp/acp-timeline-translator.ts index 2610d1de533f..3794b0410460 100644 --- a/src/main/acp/acp-timeline-translator.ts +++ b/src/main/acp/acp-timeline-translator.ts @@ -8,19 +8,16 @@ import { type AcpDialect, type AcpRequestPresentation } from './acp-dialects/acp-dialect' +import type { AcpAgentError } from './acp-errors' import { AcpHistoryAdoption } from './acp-history-adoption' import { acpTurnEnd, AcpPromptTurns } from './acp-prompt-turns' -import type { AcpSessionEvent } from './acp-session-runtime' +import { readAcpSessionEvent, type AcpSessionEvent } from './acp-session-events' import { translateAcpRequest } from './acp-timeline-requests' import { acpSessionUpdate } from './acp-session-update' import { AcpToolTimeline } from './acp-tool-timeline' import { AcpTurnFailures, acpPromptErrorDetail } from './acp-turn-failures' import { AcpTurnMessages } from './acp-turn-messages' -import { - SessionNotificationSchema, - type PromptResponse, - type SessionNotification -} from './generated/acp-protocol.generated' +import type { PromptResponse, SessionNotification } from './generated/acp-protocol.generated' export { acpTurnEnd } from './acp-prompt-turns' @@ -88,7 +85,9 @@ export class AcpTimelineTranslator { return this.finishPrompt(clientMessageId, result.stopReason, at) } - promptFailed(clientMessageId: string, error: unknown, at: number): ProviderTimelineEvent[] { + /** The agent's own error answer to the prompt; Orca's errors about it (a timeout, an unreadable + * answer, a closed connection) are no provider words and never reach here. */ + promptFailed(clientMessageId: string, error: AcpAgentError, at: number): ProviderTimelineEvent[] { const detail = acpPromptErrorDetail(this.dialect, error) const ended = this.prompts.last if (this.prompts.current?.clientMessageId !== clientMessageId) { @@ -161,9 +160,9 @@ export class AcpTimelineTranslator { return [] } const envelope = acpNotificationEnvelopeSchema.safeParse(params) - const standard = - method === 'session/update' ? SessionNotificationSchema.safeParse(params) : undefined - const update = standard?.success ? standard.data.update : undefined + const read = method === 'session/update' ? readAcpSessionEvent(params) : null + const standard = read?.kind === 'known' ? read.notification : undefined + const update = standard?.update const markedReplay = (envelope.success && envelope.data._meta?.isReplay === true) || extension?.replay === true const history = markedReplay || (extension?.replay === undefined && this.load !== undefined) @@ -215,15 +214,15 @@ export class AcpTimelineTranslator { } return events } - if (standard?.success) { + if (standard) { // History text is keyed by its position so a re-run of the adoption lands on the same rows. const messageKey = turn && (this.dialect.injectedPromptIdentity || history) - ? this.messages.key(turn, standard.data.update) + ? this.messages.key(turn, standard.update) : undefined return [ ...events, - ...acpSessionUpdate(standard.data, turn, at, { + ...acpSessionUpdate(standard, turn, at, { history, tools: this.tools, dialect: this.dialect, diff --git a/src/main/acp/acp-timeline-turn-failures.test.ts b/src/main/acp/acp-timeline-turn-failures.test.ts index 54e50405582c..801b85fd9fb8 100644 --- a/src/main/acp/acp-timeline-turn-failures.test.ts +++ b/src/main/acp/acp-timeline-turn-failures.test.ts @@ -6,6 +6,7 @@ import { providerTurnItemId } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' import { openAcpFixtureRig } from './acp-timeline-fixture.test-support' +import { AcpAgentError } from './acp-errors' afterEach(closeProviderTimelineRigs) @@ -38,7 +39,7 @@ const promptComplete = (promptId: string, stopReason: string, agentResult: strin stopReason, agentResult }) -const rpcError = { code: -32603, message: 'Internal error', data: { message: REASON } } +const rpcError = new AcpAgentError(-32603, 'Internal error', { message: REASON }) function statusRows(rows: AgentJournalRenderItem[]) { return rows.flatMap((row) => (row.body.kind === 'status' ? [{ row, body: row.body }] : [])) @@ -143,7 +144,7 @@ describe('a failed ACP turn says why', () => { f.apply( lane.promptFailed( 'c1', - source === 'prompt error answer' ? rpcError : { code: -32603, message: '' }, + source === 'prompt error answer' ? rpcError : new AcpAgentError(-32603, ''), 1005 ) ) @@ -159,7 +160,7 @@ describe('a failed ACP turn says why', () => { f.apply(lane.notification('_x.ai/queue/changed', queued(prompt.promptId), 1001)) f.apply(lane.notification('_x.ai/session_notification', retryFailed, 1002)) f.apply(lane.notification('_x.ai/session_notification', ended(prompt.promptId, 'error'), 1003)) - f.apply(lane.promptFailed('c1', { code: -32603, message: 'Internal error' }, 1004)) + f.apply(lane.promptFailed('c1', new AcpAgentError(-32603, 'Internal error'), 1004)) const failures = statusRows(await f.rig.rows()) expect(failures.map((failure) => failure.body.text)).toEqual([REASON]) }) diff --git a/src/main/acp/acp-turn-failures.ts b/src/main/acp/acp-turn-failures.ts index cc2998945a1d..f4266b1704b4 100644 --- a/src/main/acp/acp-turn-failures.ts +++ b/src/main/acp/acp-turn-failures.ts @@ -1,10 +1,9 @@ -import { z } from 'zod' import { providerDiagnostic } from '../../shared/agent-session-failure' import { BoundedMap } from '../../shared/bounded-map' import type { ProviderTimelineEvent } from '../native-chat/agent-session-timeline/provider-timeline-event' import type { AcpDialect } from './acp-dialects/acp-dialect' +import type { AcpAgentError } from './acp-errors' -const promptErrorSchema = z.looseObject({ message: z.string() }) /** Ends the provider failed, rather than ones it chose (a refusal, a token limit). */ const FAILED_STOP_REASONS = ['error', 'rate_limit'] @@ -13,8 +12,8 @@ function acpStopReasonFailed(stopReason: string): boolean { } /** The provider's words in its error answer to `session/prompt`. */ -export function acpPromptErrorDetail(dialect: AcpDialect, error: unknown): string | undefined { - return dialect.promptErrorDetail?.(error) ?? promptErrorSchema.safeParse(error).data?.message +export function acpPromptErrorDetail(dialect: AcpDialect, error: AcpAgentError): string { + return dialect.promptErrorDetail?.(error) ?? error.message } /** One error row per failed turn, in the provider's own words, as a Codex turn-ending error reads: From 879f74a2c948e37632c3d99f3d4cd9d9bdaa31ab Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 11:01:11 -0700 Subject: [PATCH 56/62] refactor(native-chat): drop saved-history adoption from the timeline assembler The common pattern discards the history a provider replays while loading a session, so the assembler has no use for an input.history event. --- ...rovider-timeline-assembler-restart.test.ts | 126 +----------------- .../provider-timeline-assembler.ts | 1 - .../provider-timeline-decision.ts | 3 - .../provider-timeline-event.ts | 14 -- .../provider-timeline-turn-decisions.ts | 26 ---- 5 files changed, 1 insertion(+), 169 deletions(-) diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-restart.test.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-restart.test.ts index a61ae858b26f..536381911be4 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-restart.test.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-restart.test.ts @@ -3,144 +3,20 @@ // decides from the journal the sweep left. import { afterEach, describe, expect, it } from 'vitest' -import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' import { settleStaleStructuredAgentSessionState } from '../agent-session-wire/structured-agent-session-dead-generation-settlement' import { closeProviderTimelineRigs, - messageText, openProviderTimelineRig, openUnboundProviderTimelineAssembler, pendingApproval, providerItemId, providerTurnItemId, runningTool, - SESSION, - type ProviderTimelineRig + SESSION } from './provider-timeline-assembler-test-support' -import type { ProviderTimelineEvent } from './provider-timeline-event' afterEach(closeProviderTimelineRigs) -const userText = (text: string): AgentJournalMessageItem => ({ - kind: 'message', - role: 'user', - blocks: [{ type: 'text', text }] -}) - -/** A provider's saved history of one prompt, as an adapter adopting the session replays it. */ -const history: ProviderTimelineEvent[] = [ - { type: 'input.history', item: 'u1', body: userText('Fix it'), join: { turn: 'p1' } }, - { type: 'turn.open', turn: 'p1', at: 1_000 }, - { - type: 'text.delta', - item: { id: 'a1' }, - channel: 'assistant', - text: 'Looking', - join: { turn: 'p1' } - }, - { type: 'text.close', item: { id: 'a1' }, join: { turn: 'p1' } }, - { type: 'item.open', item: 'call-1', body: runningTool('read'), join: { turn: 'p1' } }, - { - type: 'item.close', - item: 'call-1', - body: { ...runningTool('read'), state: 'completed' }, - join: { turn: 'p1' } - }, - { type: 'turn.end', turn: 'p1', at: 2_000, state: 'completed', outcome: 'success' } -] - -async function revisions(rig: ProviderTimelineRig): Promise<[string, number][]> { - return (await rig.rows()).map((row) => [row.itemId, row.revision]) -} - -describe('an adopted session', () => { - it('lands the provider history as rows keyed by its own ids, opened by its saved message', async () => { - const rig = await openProviderTimelineRig() - history.forEach((event) => rig.assembler.apply(event)) - rig.assembler.flush() - - const user = await rig.row(providerItemId('item', 'u1')) - expect(user).toMatchObject({ - body: userText('Fix it'), - turnScope: { kind: 'turn', turnItemId: providerTurnItemId('p1') } - }) - expect(await rig.turn('p1')).toMatchObject({ - state: 'completed', - outcome: 'success', - userItemId: user?.itemId - }) - expect(messageText((await rig.row(providerItemId('item', 'a1')))?.body)).toBe('Looking') - expect((await rig.row(providerItemId('item', 'call-1')))?.body).toMatchObject({ - state: 'completed' - }) - - // Adoption run again by the next child (its first try was interrupted): nothing new lands. - const before = await revisions(rig) - const again = await rig.restart() - history.forEach((event) => again.apply(event)) - again.flush() - expect(await revisions(rig)).toEqual(before) - }) - - it('re-runs an adoption a crash cut midway without a second copy or a regressed row', async () => { - const rig = await openProviderTimelineRig() - history.slice(0, 5).forEach((event) => rig.assembler.apply(event)) - rig.assembler.flush() - await rig.rows() - // The child died with the turn and its tool running; the next acquisition sweeps them. - await rig.restart() - const swept = await rig.turn('p1') - expect(swept?.state).not.toBe('running') - const before = (await rig.rows()).length - - // The new child replays the whole history before its sink binds: only the writes can see it. - const { assembler, bind } = openUnboundProviderTimelineAssembler(rig.journal, { - generation: 'gen-3' - }) - history.forEach((event) => expect(assembler.apply(event).dropped).toBeUndefined()) - assembler.flush() - await bind() - - expect(await rig.rows()).toHaveLength(before) - expect(await rig.turn('p1')).toEqual(swept) - expect((await rig.row(providerItemId('item', 'call-1')))?.body).toMatchObject({ - state: 'failed' - }) - expect(messageText((await rig.row(providerItemId('item', 'a1')))?.body)).toBe('Looking') - }) - - // Temporary limit, for the adoption work to lift: the sweep settled the cut turn as the dead - // child's work, so the re-run cannot finish it. - it('leaves the turn a crash cut as the sweep settled it when the adoption re-runs', async () => { - const rig = await openProviderTimelineRig() - const reply: ProviderTimelineEvent[] = [ - { - type: 'text.delta', - item: { id: 'a2' }, - channel: 'assistant', - text: 'Done', - join: { turn: 'p1' } - }, - { type: 'text.close', item: { id: 'a2' }, join: { turn: 'p1' } } - ] - const full = [...history.slice(0, -1), ...reply, ...history.slice(-1)] - full.slice(0, 5).forEach((event) => rig.assembler.apply(event)) - await rig.restart() - const { assembler, bind } = openUnboundProviderTimelineAssembler(rig.journal, { - generation: 'gen-3' - }) - full.forEach((event) => assembler.apply(event)) - assembler.flush() - await bind() - - expect((await rig.turn('p1'))?.state).toBe('unverifiable') - expect((await rig.row(providerItemId('item', 'call-1')))?.body).toMatchObject({ - state: 'failed' - }) - expect(await rig.row(providerItemId('item', 'a2'))).toBeUndefined() - }) -}) - describe('a resumed session', () => { it('decides events admitted before bind against the journal the sweep left', async () => { const rig = await openProviderTimelineRig() diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler.ts index f1b6cb0a68b0..fdd10ab61041 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler.ts @@ -176,7 +176,6 @@ export function createProviderTimelineAssembler( return { admission: ADMITTED } } case 'input.accepted': - case 'input.history': case 'turn.open': case 'turn.end': case 'item.open': diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-decision.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-decision.ts index e5c985aaece1..61fdfe4cd496 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-decision.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-decision.ts @@ -26,7 +26,6 @@ import type { ProviderTimelineTurnRef } from './provider-timeline-rows' import type { ProviderTimelineState } from './provider-timeline-state' import { decideContextUsage, - decideHistory, decideInput, decideSessionEnd, decideTurnEnd, @@ -105,8 +104,6 @@ export function decideProviderTimelineEvent( switch (event.type) { case 'input.accepted': return decideInput(input, event) - case 'input.history': - return decideHistory(input, event) case 'turn.open': return decideTurnOpen(input, event) case 'turn.end': diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-event.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-event.ts index dd2f7d1497fb..4cd8b8650ffe 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-event.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-event.ts @@ -23,11 +23,6 @@ // or the next `turn.open`. Its running tool calls are still the provider's: a close reported // after the Stop lands as reported, and whatever is still running settles at the provider's // `turn.end` for it (or the next `turn.open`, or `session.ended`). -// - Saved history the provider replays goes only into an empty journal (an adopted session), as -// ordinary events with the provider's own ids, so re-running an interrupted adoption writes the -// same rows again. Limit, until the adoption work lifts it: a crash that cut the first run -// inside a turn leaves that turn as the restart's sweep settled it (`unverifiable`, its running -// tools `failed`), and the re-run writes nothing more into it. // - An event the sink refused changed nothing. Re-apply the same event to retry it (after // `backpressure`); `failed` and `closed` are final. // - A provider item is (thread, id): the same id on another thread is another item. Text and @@ -38,7 +33,6 @@ import type { AgentSessionContextUsage } from '../../../shared/agent-session-con import type { AgentJournalApprovalItem, AgentJournalItemBody, - AgentJournalMessageItem, AgentJournalProducerLinkage, AgentJournalQuestionItem, AgentJournalTurnOutcome @@ -84,14 +78,6 @@ export type ProviderTimelineEvent = requestedAt: number join?: ProviderTimelineJoin } - /** A user message from the provider's saved history (an adopted session has no Orca send): its - * own row, keyed by `item`, and the message that opened `join.turn`. */ - | { - type: 'input.history' - item: string - body: AgentJournalMessageItem - join: ProviderTimelineJoin & { turn: string } - } /** A turn began. `turn` is the provider's turn id when it has one; the assembler mints one otherwise. */ | { type: 'turn.open'; turn?: string; at: number } /** The provider ended a turn. Absent `turn` means the open turn, else the one a person stopped diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-turn-decisions.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-turn-decisions.ts index e9c4471f6076..fd1399c4afc8 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-turn-decisions.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-turn-decisions.ts @@ -10,13 +10,11 @@ import { agentJournalTurnBody, readAgentJournalTurn } from '../../../shared/agent-session-turn-record' -import { estimateStructuredAgentSessionItemBytes } from '../agent-session-wire/structured-agent-session-event-sink-estimate' import type { StructuredAgentSessionTransitionJournal } from '../agent-session-wire/structured-agent-session-transition' import { agentJournalTurnRowReservedBytes, resolveAgentJournalTurnRowWrite } from './agent-journal-turn-row-revision' -import { providerTimelinePlacement } from './provider-timeline-context' import type { ProviderTimelineDecidedEvent, ProviderTimelineDecision, @@ -187,30 +185,6 @@ export function decideInput( ) } -/** A saved user message of an adopted session: its own row, only where none is, and the opener of - * the turn it names. */ -export function decideHistory( - input: ProviderTimelineDecisionInput, - event: Extract -): ProviderTimelineDecision { - const { context, state } = input - const row = context.rows.item('item', providerKey(event.item), event.join.thread ?? null) - const write: ProviderTimelineResolvedWrite = { identity: row.identity, body: event.body } - const opener = decideOpener(input, { userItemId: row.itemId }, event.join.turn) - return { - ...opener, - writes: [ - { - reservedBytes: estimateStructuredAgentSessionItemBytes(row.identity, event.body), - lifecycle: false, - options: { turnScope: providerTimelinePlacement(context, state, event.join) }, - resolve: (at) => (at.itemBody(row.itemId) === null ? write : null) - }, - ...(opener.writes ?? []) - ] - } -} - /** A user message names the turn it opened: the one it names once that one opens, else the open * turn while that still names no message of its own, else the next to open. */ function decideOpener( From 0f4be81a83c1640c800b91d9257fae2672009701 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 11:06:04 -0700 Subject: [PATCH 57/62] refactor(acp): drop session/load history adoption from the translator The common pattern discards the history an agent replays during session/load, keeping only what it says about the context window. Remove the adoption path (acp-history-adoption.ts, the adopt option, and the historical background-task liveness rewrite it fed) so load replay is always dropped except usage. --- src/main/acp/acp-background-task-timeline.ts | 9 +- src/main/acp/acp-context-usage.ts | 2 +- src/main/acp/acp-history-adoption.ts | 60 ------------ src/main/acp/acp-session-update.ts | 6 +- ...-timeline-background-task-evidence.test.ts | 46 +-------- ...p-timeline-background-task-results.test.ts | 55 +---------- src/main/acp/acp-timeline-dialect.test.ts | 2 +- .../acp/acp-timeline-fixture.test-support.ts | 17 +--- src/main/acp/acp-timeline-generic.test.ts | 52 +--------- src/main/acp/acp-timeline-recordings.test.ts | 61 +----------- src/main/acp/acp-timeline-translator.ts | 98 +++---------------- src/main/acp/acp-turn-messages.ts | 2 +- 12 files changed, 36 insertions(+), 374 deletions(-) delete mode 100644 src/main/acp/acp-history-adoption.ts diff --git a/src/main/acp/acp-background-task-timeline.ts b/src/main/acp/acp-background-task-timeline.ts index 414e9f6c104b..c3aa3f51f006 100644 --- a/src/main/acp/acp-background-task-timeline.ts +++ b/src/main/acp/acp-background-task-timeline.ts @@ -25,8 +25,7 @@ export class AcpBackgroundTaskTimeline { translate( updates: AcpBackgroundTaskUpdate[], - join: ProviderTimelineJoin, - history = false + join: ProviderTimelineJoin ): ProviderTimelineEvent[] { return updates.map((update) => { const { fallbackLabel, fallbackKind, ...fields } = update @@ -37,11 +36,7 @@ export class AcpBackgroundTaskTimeline { ...fields, kind: update.kind ?? previous?.kind ?? fallbackKind ?? 'unknown', label: update.label ?? previous?.label ?? fallbackLabel ?? update.taskId, - // A historical launch proves no current liveness; explicit outcomes still settle it. - state: - history && ['working', 'monitoring', 'waiting'].includes(update.state) - ? 'unverifiable' - : update.state + state: update.state } for (const key of ['label', 'summary', 'error', 'outputFile'] as const) { const text = block[key] diff --git a/src/main/acp/acp-context-usage.ts b/src/main/acp/acp-context-usage.ts index d36c1d2d7eac..007e56413abd 100644 --- a/src/main/acp/acp-context-usage.ts +++ b/src/main/acp/acp-context-usage.ts @@ -31,7 +31,7 @@ export class AcpContextTimeline { ] } - /** History that is not adopted is dropped except what it says about the context window. */ + /** Replayed history is dropped except what it says about the context window. */ history( update: SessionUpdate | undefined, usage: AgentSessionContextUsage | undefined, diff --git a/src/main/acp/acp-history-adoption.ts b/src/main/acp/acp-history-adoption.ts deleted file mode 100644 index 46c5181c5050..000000000000 --- a/src/main/acp/acp-history-adoption.ts +++ /dev/null @@ -1,60 +0,0 @@ -import type { ProviderTimelineEvent } from '../native-chat/agent-session-timeline/provider-timeline-event' -import { - boundInlineText, - DEFAULT_JOURNAL_PAYLOAD_LIMITS -} from '../native-chat/agent-session-journal/journal-payload-bounds' -import type { SessionUpdate } from './generated/acp-protocol.generated' - -type UserMessageChunk = Extract - -/** Saved history replayed into an empty journal (an adopted session). Ids come from the provider - * or from the replay's own order, so re-running an interrupted adoption lands the same rows. */ -export class AcpHistoryAdoption { - /** The history turn later frames that name none belong to. */ - turn?: string - private serial = 0 - private user?: { messageId: string | null; text: string } - - constructor(private readonly thread: string) {} - - turnFor(providerTurn: string | undefined): string { - return providerTurn ?? this.turn ?? `history:${this.serial++}` - } - - /** A chunk of the saved user message already buffered. */ - continuesUser(update: UserMessageChunk): boolean { - return this.user !== undefined && this.user.messageId === (update.messageId ?? null) - } - - /** Buffers a saved user message: `input.history` takes its whole body once. */ - observeUser(update: UserMessageChunk): void { - const messageId = update.messageId ?? null - const previous = this.continuesUser(update) ? (this.user?.text ?? '') : '' - const text = update.content.type === 'text' ? update.content.text : '' - this.user = { - messageId, - text: boundInlineText(previous + text, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text - } - } - - /** The buffered user message, as the opener of `turn`. */ - takeUser(turn: string): ProviderTimelineEvent[] { - const user = this.user - this.user = undefined - if (!user?.text) { - return [] - } - return [ - { - type: 'input.history', - item: `history-user:${JSON.stringify([turn, user.messageId])}`, - body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: user.text }] }, - join: { thread: this.thread, turn } - } - ] - } - - get holdsUser(): boolean { - return this.user !== undefined - } -} diff --git a/src/main/acp/acp-session-update.ts b/src/main/acp/acp-session-update.ts index 74df1762fa10..1f39a4d0ed6c 100644 --- a/src/main/acp/acp-session-update.ts +++ b/src/main/acp/acp-session-update.ts @@ -14,8 +14,6 @@ export function acpSessionUpdate( turn: string | undefined, at: number, context: { - /** Adopted history: background-task starts in it prove no current liveness. */ - history: boolean tools: AcpToolTimeline dialect: AcpDialect backgroundTasks: AcpBackgroundTaskTimeline @@ -43,7 +41,7 @@ export function acpSessionUpdate( ] : [{ type: 'provider.frame', frameKind: update.sessionUpdate, payload: update, ...join }] case 'user_message_chunk': - // A live echo of the send is the send's own row; adopted history comes as `input.history`. + // A live echo of the send is the send's own row. return [] case 'tool_call': case 'tool_call_update': { @@ -54,7 +52,7 @@ export function acpSessionUpdate( tool && 'body' in tool && tool.body?.kind === 'tool-call' ? (dialect.toolBackgroundTasks?.(update, tool.body) ?? []) : [] - return [...events, ...backgroundTasks.translate(tasks, join.join, context.history)] + return [...events, ...backgroundTasks.translate(tasks, join.join)] } case 'plan': return [ diff --git a/src/main/acp/acp-timeline-background-task-evidence.test.ts b/src/main/acp/acp-timeline-background-task-evidence.test.ts index 5abadae51958..6e5725c8283d 100644 --- a/src/main/acp/acp-timeline-background-task-evidence.test.ts +++ b/src/main/acp/acp-timeline-background-task-evidence.test.ts @@ -196,9 +196,9 @@ describe('background task outcome evidence', () => { ) it.each(['launch', 'notification'] as const)( - 'SF3 treats replayed %s evidence as unverifiable and accepts a later completion', + 'SF3 drops replayed %s evidence during load and accepts a later completion', async (source) => { - const fixture = await openAcpFixtureRig({ adopt: true }) + const fixture = await openAcpFixtureRig() fixture.lane().beginLoad() if (source === 'launch') { tool( @@ -220,49 +220,9 @@ describe('background task outcome evidence', () => { taskNotice(fixture, taskId, true) } fixture.finishLoad() - expect((await taskRows(fixture))[0]?.block.state).toBe('unverifiable') + expect(await taskRows(fixture)).toEqual([]) taskNotice(fixture, taskId, false, true) expect((await taskRows(fixture))[0]?.block.state).toBe('done') } ) - - it.each(['notification', 'kill'] as const)( - 'SF3 preserves the explicit %s outcome included in adopted history', - async (completion) => { - const fixture = await openAcpFixtureRig({ adopt: true }) - fixture.lane().beginLoad() - tool( - fixture, - { - toolCallId: 'historic-tool', - status: 'completed', - rawOutput: { - type: 'BackgroundTaskStarted', - task_id: taskId, - task_type: 'bash', - command: 'sleep 99' - } - }, - true - ) - if (completion === 'notification') { - taskNotice(fixture, taskId, true, true) - } else { - tool( - fixture, - { - toolCallId: 'historic-kill', - name: 'kill_command_or_subagent', - status: 'completed', - rawInput: { task_id: taskId } - }, - true - ) - } - fixture.finishLoad() - expect((await taskRows(fixture))[0]?.block.state).toBe( - completion === 'notification' ? 'done' : 'idle' - ) - } - ) }) diff --git a/src/main/acp/acp-timeline-background-task-results.test.ts b/src/main/acp/acp-timeline-background-task-results.test.ts index 5a863b45cdfd..48baeaba557a 100644 --- a/src/main/acp/acp-timeline-background-task-results.test.ts +++ b/src/main/acp/acp-timeline-background-task-results.test.ts @@ -5,60 +5,7 @@ import { openAcpFixtureRig } from './acp-timeline-fixture.test-support' afterEach(closeProviderTimelineRigs) -describe('background task results in adopted history', () => { - it.each([ - ['running', undefined, 'unverifiable'], - ['completed', 0, 'done'], - ['completed', undefined, 'done'], - ['failed', 1, 'blocked'], - ['stopped', undefined, 'idle'], - ['unknown', undefined, 'unverifiable'] - ] as const)( - 'settles replay from a TaskOutput result with status %s and exit code %s', - async (status, exitCode, state) => { - const fixture = await openAcpFixtureRig({ adopt: true }) - const lane = fixture.lane() - lane.beginLoad() - for (const [index, rawOutput] of [ - { type: 'BackgroundTaskStarted', task_id: 'historic-task', command: 'npm test' }, - { - type: 'TaskOutput', - Result: { - task_id: 'historic-task', - command: 'npm test', - status, - exit_code: exitCode, - output: 'PASS a.test.js\nPASS b.test.js\n' - } - } - ].entries()) { - fixture.apply( - lane.notification( - 'session/update', - { - sessionId: 'session-1', - _meta: { isReplay: true, promptId: 'historic-turn' }, - update: { - sessionUpdate: 'tool_call_update', - toolCallId: `tool-${index}`, - status: 'completed', - rawOutput - } - }, - 1000 + index - ) - ) - } - fixture.finishLoad() - const tasks = (await fixture.rig.rows()).flatMap((row) => - row.body.kind === 'message' ? row.body.blocks.filter(isBackgroundTaskBlock) : [] - ) - expect(tasks).toHaveLength(1) - expect(tasks[0]).toMatchObject({ state, label: 'npm test' }) - expect(tasks[0]?.summary ?? '').not.toContain('PASS') - } - ) - +describe('background task results', () => { it('uses each structured kill result rather than inferring a stop for unsuccessful targets', async () => { const fixture = await openAcpFixtureRig() const lane = fixture.lane() diff --git a/src/main/acp/acp-timeline-dialect.test.ts b/src/main/acp/acp-timeline-dialect.test.ts index f58ce2618d8c..195ab2d0a91a 100644 --- a/src/main/acp/acp-timeline-dialect.test.ts +++ b/src/main/acp/acp-timeline-dialect.test.ts @@ -206,7 +206,7 @@ describe('ACP dialect request and turn boundaries', () => { stop_reason: 'end_turn' } }) - apply(translator.finishLoad(1300)) + translator.finishLoad() expect( (await rig.rows()).flatMap((row) => row.body.kind === 'message' ? [messageText(row.body)] : [] diff --git a/src/main/acp/acp-timeline-fixture.test-support.ts b/src/main/acp/acp-timeline-fixture.test-support.ts index 24e94f926a3a..ae9eac456e47 100644 --- a/src/main/acp/acp-timeline-fixture.test-support.ts +++ b/src/main/acp/acp-timeline-fixture.test-support.ts @@ -65,18 +65,12 @@ function fixtureAnswer( } } -/** Captured frames go through translation, assembly and the real serialized journal. `adopt` is - * the creator's decision that `session/load` history goes into an empty journal. */ -export async function openAcpFixtureRig(options: { adopt?: boolean } = {}) { +/** Captured frames go through translation, assembly and the real serialized journal. */ +export async function openAcpFixtureRig() { const rig = await openProviderTimelineRig() let providerSessionId = 'session-1' - let adopt = options.adopt ?? false const translator = () => - new AcpTimelineTranslator({ - sessionId: providerSessionId, - dialect: GROK_ACP_DIALECT, - adopt - }) + new AcpTimelineTranslator({ sessionId: providerSessionId, dialect: GROK_ACP_DIALECT }) let lane = translator() const requests = new Map< string | number, @@ -165,11 +159,10 @@ export async function openAcpFixtureRig(options: { adopt?: boolean } = {}) { feed, lane: () => lane, /** A new provider child: the sweep, a new generation's assembler and a new translator. */ - restart: async (next: { adopt?: boolean } = {}) => { + restart: async () => { await rig.restart() - adopt = next.adopt ?? false lane = translator() }, - finishLoad: () => apply(lane.finishLoad(2000)) + finishLoad: () => lane.finishLoad() } } diff --git a/src/main/acp/acp-timeline-generic.test.ts b/src/main/acp/acp-timeline-generic.test.ts index 9d08552577fe..b7f6fc8a666a 100644 --- a/src/main/acp/acp-timeline-generic.test.ts +++ b/src/main/acp/acp-timeline-generic.test.ts @@ -12,7 +12,7 @@ import { AcpTimelineTranslator, acpTurnEnd } from './acp-timeline-translator' afterEach(closeProviderTimelineRigs) -async function genericRig(options: { adopt?: boolean; agentName?: string } = {}) { +async function genericRig(options: { agentName?: string } = {}) { const rig = await openProviderTimelineRig() const translator = new AcpTimelineTranslator({ sessionId: 'provider-1', ...options }) const apply = (events: ProviderTimelineEvent[]) => { @@ -243,20 +243,7 @@ describe('generic ACP translation', () => { }) }) - it('adopts generic unmarked load history and never invents a success verdict', async () => { - const { rig, translator, apply, update } = await genericRig({ adopt: true }) - translator.beginLoad() - update({ sessionUpdate: 'user_message_chunk', content: { type: 'text', text: 'User' } }) - update({ sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'History' } }) - apply(translator.finishLoad(1200)) - expect((await rig.turns())[0]).toMatchObject({ state: 'completed' }) - expect((await rig.turns())[0]).not.toHaveProperty('outcome') - expect( - (await rig.rows()).flatMap((row) => (row.body.kind === 'message' ? [row.body.role] : [])) - ).toEqual(['user', 'assistant']) - }) - - it('drops unmarked load history its creator does not adopt, except context usage', () => { + it('drops unmarked load history, except context usage', () => { const translator = new AcpTimelineTranslator({ sessionId: 'provider-1' }) translator.beginLoad() const events = [ @@ -270,10 +257,9 @@ describe('generic ACP translation', () => { expect(events).toEqual([ expect.objectContaining({ type: 'context.usage', join: { thread: 'provider-1' } }) ]) - expect(translator.finishLoad(1200)).toEqual([]) }) - it('requires an explicit journal decision for marked replay and uses typed standard events', async () => { + it('drops marked replay outside a load and uses typed standard events', async () => { const { rig, translator, apply } = await genericRig() expect( translator.notification( @@ -308,38 +294,6 @@ describe('generic ACP translation', () => { apply(translator.promptResult('send-1', { stopReason: 'end_turn' }, 1200)) expect((await rig.rows()).some((row) => messageText(row.body) === 'typed')).toBe(true) }) - it('adopts streamed user history across multiple turns without replacing earlier users', async () => { - const { rig, translator, apply, update } = await genericRig({ adopt: true }) - translator.beginLoad() - update({ - sessionUpdate: 'user_message_chunk', - messageId: 'user-1', - content: { type: 'text', text: 'First ' } - }) - update({ - sessionUpdate: 'user_message_chunk', - messageId: 'user-1', - content: { type: 'text', text: 'question' } - }) - update({ - sessionUpdate: 'agent_message_chunk', - content: { type: 'text', text: 'First answer' } - }) - update({ sessionUpdate: 'user_message_chunk', content: { type: 'text', text: 'Second ' } }) - update({ sessionUpdate: 'user_message_chunk', content: { type: 'text', text: 'question' } }) - update({ - sessionUpdate: 'agent_message_chunk', - content: { type: 'text', text: 'Second answer' } - }) - apply(translator.finishLoad(1200)) - expect( - (await rig.rows()).flatMap((row) => - row.body.kind === 'message' ? [messageText(row.body)] : [] - ) - ).toEqual(['First question', 'First answer', 'Second question', 'Second answer']) - expect(await rig.turns()).toHaveLength(2) - }) - it('evicts completed snapshots during long turns while preserving active tool input', async () => { const { rig, translator, apply, update } = await genericRig() apply(translator.openPrompt('send-1', 1000).events) diff --git a/src/main/acp/acp-timeline-recordings.test.ts b/src/main/acp/acp-timeline-recordings.test.ts index 5bcb22d73cb4..e4402b37e864 100644 --- a/src/main/acp/acp-timeline-recordings.test.ts +++ b/src/main/acp/acp-timeline-recordings.test.ts @@ -2,8 +2,7 @@ import { afterEach, describe, expect, it } from 'vitest' import { contextTokensFromUsage } from '../../shared/agent-session-context-usage' import { closeProviderTimelineRigs, - messageText, - providerItemId + messageText } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' import { openAcpFixtureRig, readAcpFixture } from './acp-timeline-fixture.test-support' @@ -73,12 +72,12 @@ describe('recorded ACP traffic through the journal', () => { expect(rows.some((row) => messageText(row.body)?.includes('ok'))).toBe(true) }) - it('drops load history a journal already holds, except its context usage', async () => { + it('drops load history, except its context usage', async () => { const fixture = await openAcpFixtureRig() const frames = await readAcpFixture('s4-resume') await fixture.feed(frames.filter((frame) => frame.process === 'A')) const before = await fixture.rig.rows() - await fixture.restart({ adopt: false }) + await fixture.restart() const loading = frames.filter((frame) => frame.process === 'B') const nextPrompt = loading.findIndex((frame) => frame.message.method === 'session/prompt') await fixture.feed(loading.slice(0, nextPrompt)) @@ -107,60 +106,6 @@ describe('recorded ACP traffic through the journal', () => { expect(await fixture.rig.turns()).toHaveLength(2) }) - it('adopts coalesced load replay into an empty journal, including user and completed tools', async () => { - const fixture = await openAcpFixtureRig({ adopt: true }) - const frames = (await readAcpFixture('s4-resume')).filter((frame) => frame.process === 'B') - const nextPrompt = frames.findIndex((frame) => frame.message.method === 'session/prompt') - await fixture.feed(frames.slice(0, nextPrompt)) - fixture.finishLoad() - const rows = await fixture.rig.rows() - expect(rows.flatMap((row) => (row.body.kind === 'tool-call' ? [row.body] : []))).toHaveLength(2) - expect( - rows.filter((row) => row.body.kind === 'message' && row.body.role === 'user') - ).toHaveLength(1) - expect(rows.some((row) => messageText(row.body)?.includes('Read notes.txt'))).toBe(true) - expect((await fixture.rig.turns())[0]).toMatchObject({ state: 'completed', outcome: 'success' }) - expect(rows.every((row) => row.body.kind === 'turn' || row.turnScope?.kind === 'turn')).toBe( - true - ) - await fixture.feed(frames.slice(nextPrompt)) - expect(await fixture.rig.turns()).toHaveLength(2) - }) - - it('adopts history as rows with the provider ids, and a re-run of the adoption adds no copy', async () => { - const fixture = await openAcpFixtureRig({ adopt: true }) - const frames = (await readAcpFixture('s4-resume')).filter((frame) => frame.process === 'B') - const load = frames.slice( - 0, - frames.findIndex((frame) => frame.message.method === 'session/prompt') - ) - await fixture.feed(load) - fixture.finishLoad() - const adopted = await fixture.rig.rows() - const user = adopted.find((row) => row.body.kind === 'message' && row.body.role === 'user') - expect(user?.itemId).toBe( - providerItemId('item', `history-user:${JSON.stringify(['prompt:A:3', null])}`, { - thread: 'session-1' - }) - ) - expect(await fixture.rig.turn('prompt:A:3')).toMatchObject({ - userItemId: user?.itemId, - state: 'completed' - }) - expect( - adopted.flatMap((row) => (row.body.kind === 'tool-call' ? [row.itemId] : [])).sort() - ).toEqual( - ['tool:call-1', 'tool:call-2'] - .map((key) => providerItemId('item', key, { thread: 'session-1' })) - .sort() - ) - - await fixture.restart({ adopt: true }) - await fixture.feed(load) - fixture.finishLoad() - expect(await fixture.rig.rows()).toEqual(adopted) - }) - it('answers questions and plan approval with the recorded exact reply shapes', async () => { const fixture = await openAcpFixtureRig() const rows = await fixture.feed(await readAcpFixture('s5-plan-approved')) diff --git a/src/main/acp/acp-timeline-translator.ts b/src/main/acp/acp-timeline-translator.ts index 3794b0410460..9f927bceed0f 100644 --- a/src/main/acp/acp-timeline-translator.ts +++ b/src/main/acp/acp-timeline-translator.ts @@ -9,7 +9,6 @@ import { type AcpRequestPresentation } from './acp-dialects/acp-dialect' import type { AcpAgentError } from './acp-errors' -import { AcpHistoryAdoption } from './acp-history-adoption' import { acpTurnEnd, AcpPromptTurns } from './acp-prompt-turns' import { readAcpSessionEvent, type AcpSessionEvent } from './acp-session-events' import { translateAcpRequest } from './acp-timeline-requests' @@ -17,7 +16,7 @@ import { acpSessionUpdate } from './acp-session-update' import { AcpToolTimeline } from './acp-tool-timeline' import { AcpTurnFailures, acpPromptErrorDetail } from './acp-turn-failures' import { AcpTurnMessages } from './acp-turn-messages' -import type { PromptResponse, SessionNotification } from './generated/acp-protocol.generated' +import type { PromptResponse } from './generated/acp-protocol.generated' export { acpTurnEnd } from './acp-prompt-turns' @@ -34,10 +33,6 @@ const SUBSTANTIVE_UPDATES = [ export type AcpTimelineTranslatorOptions = { sessionId: string dialect?: AcpDialect - /** The creator's decision, made from the journal before spawn: translate `session/load` history - * because the journal is empty (an adopted session). Otherwise history stays out of the - * timeline and only its context usage reads on. */ - adopt?: boolean /** The agent's display name, for a failed turn the provider gave no words for. */ agentName?: string } @@ -52,7 +47,7 @@ export class AcpTimelineTranslator { private readonly messages = new AcpTurnMessages() private readonly started = new BoundedMap({ maxEntries: 128 }) private readonly failures: AcpTurnFailures - private load?: { adoption?: AcpHistoryAdoption } + private loading = false private readonly context = new AcpContextTimeline() private activeTurn?: string @@ -71,7 +66,7 @@ export class AcpTimelineTranslator { clientMessageId: string, at: number ): { promptId: string; events: ProviderTimelineEvent[] } { - if (this.load) { + if (this.loading) { throw new Error('ACP prompt overlaps a prompt or load') } return this.prompts.open(clientMessageId, at) @@ -118,25 +113,16 @@ export class AcpTimelineTranslator { return this.context.models(models, at, this.dialect, { thread: this.options.sessionId }) } + /** The history `session/load` replays is dropped, except what it says about the context window. */ beginLoad(): void { - if (this.prompts.current || this.load) { + if (this.prompts.current || this.loading) { throw new Error('ACP load overlaps a prompt or load') } - this.load = this.options.adopt - ? { adoption: new AcpHistoryAdoption(this.options.sessionId) } - : {} + this.loading = true } - finishLoad(at: number): ProviderTimelineEvent[] { - const adoption = this.load?.adoption - this.load = undefined - if (!adoption) { - return [] - } - const events = adoption.holdsUser - ? this.openHistory(adoption, adoption.turnFor(undefined), at) - : [] - return [...events, ...this.endHistory(adoption, at)] + finishLoad(): void { + this.loading = false } sessionEvent(event: AcpSessionEvent, at: number): ProviderTimelineEvent[] { @@ -165,36 +151,23 @@ export class AcpTimelineTranslator { const update = standard?.update const markedReplay = (envelope.success && envelope.data._meta?.isReplay === true) || extension?.replay === true - const history = markedReplay || (extension?.replay === undefined && this.load !== undefined) - const adoption = history ? this.load?.adoption : undefined - if (history && !adoption) { + if (markedReplay || (extension?.replay === undefined && this.loading)) { return this.context.history(update, extension?.usage, at, { thread: this.options.sessionId }) } - if (adoption && update?.sessionUpdate === 'user_message_chunk') { - return this.historyUser(adoption, update, at) - } const providerTurn = extension?.turn const opens = (update !== undefined && SUBSTANTIVE_UPDATES.includes(update.sessionUpdate)) || extension?.end !== undefined || extension?.started === true - const turn = adoption - ? opens - ? adoption.turnFor(providerTurn) - : (providerTurn ?? adoption.turn) - : (providerTurn ?? - (this.prompts.current?.opened ? this.prompts.current.turn : this.activeTurn)) + const turn = + providerTurn ?? (this.prompts.current?.opened ? this.prompts.current.turn : this.activeTurn) const events: ProviderTimelineEvent[] = [] if (turn && opens) { - events.push( - ...(adoption - ? this.openHistory(adoption, turn, extension?.at ?? at) - : this.start(turn, extension?.at ?? at)) - ) + events.push(...this.start(turn, extension?.at ?? at)) } const join = { thread: this.options.sessionId, ...(turn === undefined ? {} : { turn }) } if (extension?.backgroundTasks) { - events.push(...this.backgroundTasks.translate(extension.backgroundTasks, join, history)) + events.push(...this.backgroundTasks.translate(extension.backgroundTasks, join)) } if (extension?.usage) { events.push(...this.context.update(extension.usage, join)) @@ -215,15 +188,13 @@ export class AcpTimelineTranslator { return events } if (standard) { - // History text is keyed by its position so a re-run of the adoption lands on the same rows. const messageKey = - turn && (this.dialect.injectedPromptIdentity || history) + turn && this.dialect.injectedPromptIdentity ? this.messages.key(turn, standard.update) : undefined return [ ...events, ...acpSessionUpdate(standard, turn, at, { - history, tools: this.tools, dialect: this.dialect, backgroundTasks: this.backgroundTasks, @@ -266,44 +237,6 @@ export class AcpTimelineTranslator { return events } - /** A saved user message opens a new history turn, unless the provider names its turn. */ - private historyUser( - adoption: AcpHistoryAdoption, - update: Extract, - at: number - ): ProviderTimelineEvent[] { - if (adoption.continuesUser(update)) { - adoption.observeUser(update) - return [] - } - const events = adoption.holdsUser - ? this.openHistory(adoption, adoption.turnFor(undefined), at) - : [] - events.push(...this.endHistory(adoption, at)) - adoption.observeUser(update) - return events - } - - private openHistory( - adoption: AcpHistoryAdoption, - turn: string, - at: number - ): ProviderTimelineEvent[] { - adoption.turn = turn - return [...this.start(turn, at), ...adoption.takeUser(turn)] - } - - /** History carries no verdict for a turn it does not end itself. */ - private endHistory(adoption: AcpHistoryAdoption, at: number): ProviderTimelineEvent[] { - const turn = adoption.turn - if (!turn) { - return [] - } - this.end(turn) - adoption.turn = undefined - return [{ type: 'turn.end', turn, at, state: 'completed' }] - } - private start(turn: string, at: number): ProviderTimelineEvent[] { if (turn === this.prompts.current?.turn) { const events = this.prompts.start(turn, at) @@ -326,8 +259,5 @@ export class AcpTimelineTranslator { if (this.activeTurn === turn) { this.activeTurn = undefined } - if (this.load?.adoption?.turn === turn) { - this.load.adoption.turn = undefined - } } } diff --git a/src/main/acp/acp-turn-messages.ts b/src/main/acp/acp-turn-messages.ts index e7b8a7e49bc9..0cf39c5834c3 100644 --- a/src/main/acp/acp-turn-messages.ts +++ b/src/main/acp/acp-turn-messages.ts @@ -3,7 +3,7 @@ import type { SessionUpdate } from './generated/acp-protocol.generated' type MessagePosition = { ordinal: number; channel?: string } -/** Stable turn-relative message positions also identify coalesced load snapshots. */ +/** Stable turn-relative message positions. */ export class AcpTurnMessages { private readonly turns = new BoundedMap({ maxEntries: 128 }) From 58c3dd548167f287371e4cef3d686419f32247e3 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 11:10:00 -0700 Subject: [PATCH 58/62] refactor(native-chat): a pending input is only Orca's send now Review follow-up to the adoption removal: drop the comment naming the provider's saved message, and make requestedAt required since every pending input comes from input.accepted. --- .../agent-session-timeline/provider-timeline-state.ts | 4 ++-- .../provider-timeline-turn-decisions.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-state.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-state.ts index aa8b0a1bc82c..991dea1ce264 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-state.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-state.ts @@ -28,10 +28,10 @@ export type ProviderTimelineOpenTurn = ProviderTimelineTurnRef & { running: AgentJournalTurnLifecycle } -/** A user message waiting for the turn it opens: Orca's send, or the provider's saved one. */ +/** Orca's send, waiting for the turn it opens. */ export type ProviderTimelinePendingInput = { userItemId: string - requestedAt?: number + requestedAt: number /** The turn row the provider said it opens; absent: the next turn to open. */ turnItemId?: string } diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-turn-decisions.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-turn-decisions.ts index fd1399c4afc8..7b1a5e4756e1 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-turn-decisions.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-turn-decisions.ts @@ -68,7 +68,7 @@ export function decideTurnOpen( state: 'running', userItemId: pending?.userItemId ?? turn.itemId, startedAt: event.at, - ...(pending?.requestedAt === undefined ? {} : { requestedAt: pending.requestedAt }) + ...(pending ? { requestedAt: pending.requestedAt } : {}) } const write: ProviderTimelineResolvedWrite = { identity: turn.identity, From 280246008141f60575793aa3a8511bc7b6124ff4 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 11:12:18 -0700 Subject: [PATCH 59/62] test(acp): keep the task-result status table on live frames Review follow-up to the adoption removal: the result-status mapping was only tested through adopted history, so run the same table on live frames, and cover an unmarked task notice during a load being dropped. --- ...-timeline-background-task-evidence.test.ts | 3 +- ...p-timeline-background-task-results.test.ts | 51 +++++++++++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/src/main/acp/acp-timeline-background-task-evidence.test.ts b/src/main/acp/acp-timeline-background-task-evidence.test.ts index 6e5725c8283d..d2e8bb4d5870 100644 --- a/src/main/acp/acp-timeline-background-task-evidence.test.ts +++ b/src/main/acp/acp-timeline-background-task-evidence.test.ts @@ -217,7 +217,8 @@ describe('background task outcome evidence', () => { true ) } else { - taskNotice(fixture, taskId, true) + // Unmarked: a task notice during a load is history too. + taskNotice(fixture, taskId) } fixture.finishLoad() expect(await taskRows(fixture)).toEqual([]) diff --git a/src/main/acp/acp-timeline-background-task-results.test.ts b/src/main/acp/acp-timeline-background-task-results.test.ts index 48baeaba557a..32becd1ffc5a 100644 --- a/src/main/acp/acp-timeline-background-task-results.test.ts +++ b/src/main/acp/acp-timeline-background-task-results.test.ts @@ -6,6 +6,57 @@ import { openAcpFixtureRig } from './acp-timeline-fixture.test-support' afterEach(closeProviderTimelineRigs) describe('background task results', () => { + it.each([ + ['running', undefined, 'working'], + ['completed', 0, 'done'], + ['completed', undefined, 'done'], + ['failed', 1, 'blocked'], + ['stopped', undefined, 'idle'], + ['unknown', undefined, 'working'] + ] as const)( + 'settles a task from a TaskOutput result with status %s and exit code %s', + async (status, exitCode, state) => { + const fixture = await openAcpFixtureRig() + const lane = fixture.lane() + for (const [index, rawOutput] of [ + { type: 'BackgroundTaskStarted', task_id: 'task-1', command: 'npm test' }, + { + type: 'TaskOutput', + Result: { + task_id: 'task-1', + command: 'npm test', + status, + exit_code: exitCode, + output: 'PASS a.test.js\nPASS b.test.js\n' + } + } + ].entries()) { + fixture.apply( + lane.notification( + 'session/update', + { + sessionId: 'session-1', + _meta: { promptId: 'task-turn' }, + update: { + sessionUpdate: 'tool_call_update', + toolCallId: `tool-${index}`, + status: 'completed', + rawOutput + } + }, + 1000 + index + ) + ) + } + const tasks = (await fixture.rig.rows()).flatMap((row) => + row.body.kind === 'message' ? row.body.blocks.filter(isBackgroundTaskBlock) : [] + ) + expect(tasks).toHaveLength(1) + expect(tasks[0]).toMatchObject({ state, label: 'npm test' }) + expect(tasks[0]?.summary ?? '').not.toContain('PASS') + } + ) + it('uses each structured kill result rather than inferring a stop for unsuccessful targets', async () => { const fixture = await openAcpFixtureRig() const lane = fixture.lane() From eba27a3c784ea7e4c0a4eb3d6d6020d7962faf47 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 18:26:46 -0700 Subject: [PATCH 60/62] Use current provider handles in transition tests --- .../structured-agent-session-transition.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-transition.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-transition.test.ts index 6698b4cef5ec..d9a152d0e6ed 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-transition.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-transition.test.ts @@ -73,7 +73,7 @@ async function rig(watermarks: Partial = { workspaceId: 'workspace-1', hostId: 'local', agent: 'grok', - providerHandle: { kind: 'opaque', agent: 'grok', value: 'provider-session-1' } + providerHandle: { transport: 'acp', agent: 'grok', nativeId: 'provider-session-1' } }, stateDirectory: root, now: () => 1_000 @@ -99,7 +99,7 @@ async function owedRig() { workspaceId: 'workspace-1', hostId: 'local', agent: 'grok', - providerHandle: { kind: 'opaque', agent: 'grok', value: 'provider-session-1' } + providerHandle: { transport: 'acp', agent: 'grok', nativeId: 'provider-session-1' } }, now: () => 1_000 }) From a7d8bcbb64fb6e60cd0cb1effe121cb1fadc67fc Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 18:27:40 -0700 Subject: [PATCH 61/62] Use current provider handles in timeline fixtures --- .../provider-timeline-assembler-test-support.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-test-support.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-test-support.ts index b0e5eb833a22..6946d455314b 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-test-support.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-test-support.ts @@ -228,7 +228,7 @@ export async function openProviderTimelineRig( workspaceId: 'workspace-1', hostId: 'local', agent: AGENT, - providerHandle: { kind: 'opaque', agent: AGENT, value: 'provider-session-1' } + providerHandle: { transport: 'acp', agent: AGENT, nativeId: 'provider-session-1' } }, stateDirectory: root, now: () => 1_000 From 503ebe4480d67d1d87b984512693fc3dfc11dbc9 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 18:58:23 -0700 Subject: [PATCH 62/62] Require the ACP directory in the runtime import check --- config/scripts/check-runtime-electron-ratchet.mjs | 3 +-- config/scripts/check-runtime-electron-ratchet.test.mjs | 5 ++--- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/config/scripts/check-runtime-electron-ratchet.mjs b/config/scripts/check-runtime-electron-ratchet.mjs index 3e04b8c46f25..5c6dce96b255 100644 --- a/config/scripts/check-runtime-electron-ratchet.mjs +++ b/config/scripts/check-runtime-electron-ratchet.mjs @@ -49,8 +49,7 @@ export const STRUCTURED_CHAT_LANES = [ { directory: ['src', 'shared'] }, { directory: ['src', 'main', 'runtime'], basename: /^(?:structured-|agent-session-)/ }, { directory: ['src', 'main', 'provider-process'] }, - // Allowed absent until it lands; every other lane throws if missing, so a rename can't empty it. - { directory: ['src', 'main', 'acp'], mayBeAbsent: true } + { directory: ['src', 'main', 'acp'] } ] export function collectStructuredChatEntryPoints(root = ROOT) { diff --git a/config/scripts/check-runtime-electron-ratchet.test.mjs b/config/scripts/check-runtime-electron-ratchet.test.mjs index a8985f0c014f..f759ec1d4345 100644 --- a/config/scripts/check-runtime-electron-ratchet.test.mjs +++ b/config/scripts/check-runtime-electron-ratchet.test.mjs @@ -32,13 +32,13 @@ describe('structured chat coverage', () => { return root } - // Every lane that must exist; acp/ may be absent until it lands. const requiredLanes = { 'src/main/native-chat/reader.ts': 'export {}', 'src/main/claude/claude-session.ts': 'export {}', 'src/main/codex/codex-session.ts': 'export {}', 'src/main/runtime/structured-agent-session-host.ts': 'export {}', 'src/main/provider-process/provider-process-teardown.ts': 'export {}', + 'src/main/acp/acp-session-runtime.ts': 'export {}', 'src/shared/agent-session-record.ts': 'export {}' } @@ -90,7 +90,7 @@ describe('structured chat coverage', () => { Object.entries(requiredLanes).filter(([file]) => !file.startsWith(`${lane}/`)) ) expect(() => collectStructuredChatEntryPoints(fixture(without))).toThrow(`${lane} is missing`) - expect(collectStructuredChatEntryPoints(fixture(requiredLanes))).toHaveLength(6) + expect(collectStructuredChatEntryPoints(fixture(requiredLanes))).toHaveLength(7) } ) @@ -129,7 +129,6 @@ describe('the default entry points', () => { } }) - // Retires the temporary flag: the PR that adds acp/ must make it required. it('lets only directories that have not landed yet be absent', () => { for (const lane of STRUCTURED_CHAT_LANES.filter((candidate) => candidate.mayBeAbsent)) { expect(