From 6c3617b6e9cf76882e6fcd4121f06d7e23744a12 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 8 Oct 2026 00:49:50 -0700 Subject: [PATCH 1/5] feat(acp): compact Grok, OpenCode and OMP chats --- src/main/acp/acp-compaction-turn.ts | 90 ++++++ src/main/acp/acp-dialects/acp-dialect.ts | 5 + src/main/acp/acp-dialects/omp-dialect.ts | 19 +- src/main/acp/acp-launch-specs.ts | 11 +- src/main/acp/acp-prompt-turns.ts | 17 ++ .../acp/acp-structured-agent-definitions.ts | 4 +- .../acp/acp-structured-compaction.test.ts | 257 ++++++++++++++++++ src/main/acp/acp-structured-lane.ts | 13 + .../acp/acp-structured-session-adapter.ts | 10 +- src/main/acp/acp-structured-turns.ts | 36 ++- src/main/acp/acp-timeline-translator.ts | 63 ++++- 11 files changed, 511 insertions(+), 14 deletions(-) create mode 100644 src/main/acp/acp-compaction-turn.ts create mode 100644 src/main/acp/acp-structured-compaction.test.ts diff --git a/src/main/acp/acp-compaction-turn.ts b/src/main/acp/acp-compaction-turn.ts new file mode 100644 index 000000000000..70a7db4f3f8f --- /dev/null +++ b/src/main/acp/acp-compaction-turn.ts @@ -0,0 +1,90 @@ +// A `/compact` Orca sends as a prompt, run as the command turn the host opened. What the agent +// writes meanwhile is the command's output, read for how it went and never drawn; its answer ends +// the turn with one result row, as Claude's and Codex's compactions end. + +import { providerDiagnostic } from '../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../shared/agent-session-failure-words' +import type { ProviderTimelineEvent } from '../native-chat/agent-session-timeline/provider-timeline-event' +import { structuredCompactionOutcome } from '../native-chat/agent-session-wire/structured-conversation-command-outcome' +import type { AcpDialect } from './acp-dialects/acp-dialect' +import type { ContentBlock, SessionUpdate } from './generated/acp-protocol.generated' + +/** The prompt every compacting ACP agent takes as its own compaction command. */ +export const ACP_COMPACT_PROMPT: readonly ContentBlock[] = [{ type: 'text', text: '/compact' }] + +/** What a running compaction has said so far. */ +export type AcpCompaction = { reply: string; failureDetail?: string } + +/** Reads the running compaction's reply text; true for an update that is its output. */ +export function readAcpCompactionUpdate(compaction: AcpCompaction, update: SessionUpdate): boolean { + if (update.sessionUpdate === 'agent_message_chunk') { + if (update.content.type === 'text') { + compaction.reply += update.content.text + } + return true + } + return update.sessionUpdate === 'agent_thought_chunk' +} + +/** The compaction's result row and its turn's end, from the agent's answer. */ +export function acpCompactionEnd(input: { + compaction: AcpCompaction + turn: string + thread: string + stopReason: string + at: number + durationMs?: number + /** Words the answer itself carried, for one that failed. */ + failureDetail?: string + dialect: AcpDialect + agentName?: string +}): ProviderTimelineEvent[] { + const { compaction, turn, stopReason } = input + // The host's command turn is the assembler's open turn, which no provider key names. + const join = { thread: input.thread } + const end = (outcome: 'success' | 'failure' | 'cancellation'): ProviderTimelineEvent => ({ + type: 'turn.end', + at: input.at, + state: outcome === 'cancellation' ? 'interrupted' : 'completed', + outcome, + ...(input.durationMs === undefined ? {} : { durationMs: input.durationMs }) + }) + const reply = + stopReason === 'end_turn' ? input.dialect.compactionReply?.(compaction.reply) : undefined + if (reply?.outcome === 'skipped') { + // Nothing to compact is no failure: the turn succeeds and says why nothing changed. + const text = providerDiagnostic(reply.detail, 'person')?.text ?? 'Nothing to compact' + return [ + { + type: 'item.close', + item: `compaction:${turn}`, + body: { kind: 'status', tone: 'warning', text }, + join + }, + end('success') + ] + } + const words = reply?.detail ?? input.failureDetail ?? compaction.failureDetail + const detail = words === undefined ? undefined : providerDiagnostic(words, 'person') + const verdict = structuredCompactionOutcome({ + // An agent that ends `/compact` normally compacted, unless its reply says otherwise. + compacted: stopReason === 'end_turn' && reply === undefined, + interruptRequested: stopReason === 'cancelled', + failed: detail ? { detail } : {} + }) + if (verdict.outcome === 'cancellation') { + return [end('cancellation')] + } + const body = + verdict.outcome === 'success' || !verdict.failure + ? { kind: 'status' as const, text: 'Context compacted', presentation: 'compaction' as const } + : { + kind: 'status' as const, + ...agentSessionFailureWords(verdict.failure, { + surface: 'row', + ...(input.agentName === undefined ? {} : { agentName: input.agentName }) + }), + tone: 'error' as const + } + return [{ type: 'item.close', item: `compaction:${turn}`, body, join }, end(verdict.outcome)] +} diff --git a/src/main/acp/acp-dialects/acp-dialect.ts b/src/main/acp/acp-dialects/acp-dialect.ts index 1defc82f7644..8fedb927ca28 100644 --- a/src/main/acp/acp-dialects/acp-dialect.ts +++ b/src/main/acp/acp-dialects/acp-dialect.ts @@ -38,6 +38,9 @@ export type AcpDialectNotification = backgroundTasks?: AcpBackgroundTaskUpdate[] } +/** How a `/compact` the agent ended normally went, read from its reply; absent compacted. */ +export type AcpCompactionReply = { outcome: 'skipped' | 'failed'; detail: string } + /** Hooks interpret extensions; lifecycle and row identity stay shared. */ export type AcpDialect = { injectedPromptIdentity?: true @@ -57,6 +60,8 @@ export type AcpDialect = { promptErrorDetail?(error: AcpAgentError): string | undefined /** The row for a failed turn the provider gave no words for. */ failedTurnText?(stopReason: string): string + /** An agent that ends a `/compact` it did not do as a normal turn says so in its reply. */ + compactionReply?(text: string): AcpCompactionReply | undefined } export const GENERIC_ACP_DIALECT: AcpDialect = {} diff --git a/src/main/acp/acp-dialects/omp-dialect.ts b/src/main/acp/acp-dialects/omp-dialect.ts index cb7e321d8228..e430e3f7a439 100644 --- a/src/main/acp/acp-dialects/omp-dialect.ts +++ b/src/main/acp/acp-dialects/omp-dialect.ts @@ -1,6 +1,6 @@ import { z } from 'zod' import type { ToolCallContent, ToolCallUpdate } from '../generated/acp-protocol.generated' -import type { AcpDialect } from './acp-dialect' +import type { AcpCompactionReply, AcpDialect } from './acp-dialect' // OMP sends a tool's result as `rawOutput: {content: [{type: 'text', text}], details}`, with a // command's non-zero exit at `details.exitCode`, and repeats the text as content behind a @@ -63,4 +63,19 @@ function normalizeToolUpdate(update: ToolCallUpdate): ToolCallUpdate { } } -export const OMP_ACP_DIALECT: AcpDialect = { normalizeToolUpdate } +// OMP ends a `/compact` it could not do as a normal turn whose reply says `Compaction failed`; +// "Nothing to compact" and "Already compacted" are its no-ops (its RPC errors say the same). +const COMPACTION_FAILED = /\bcompaction failed\b/i +const COMPACTION_NOOP = /\b(?:nothing to compact|already compacted)\b/i + +function compactionReply(text: string): AcpCompactionReply | undefined { + const reply = text.trim() + if (!COMPACTION_FAILED.test(reply)) { + return undefined + } + return COMPACTION_NOOP.test(reply) + ? { outcome: 'skipped', detail: reply.replace(/^compaction failed:\s*/i, '') } + : { outcome: 'failed', detail: reply } +} + +export const OMP_ACP_DIALECT: AcpDialect = { normalizeToolUpdate, compactionReply } diff --git a/src/main/acp/acp-launch-specs.ts b/src/main/acp/acp-launch-specs.ts index 0cc9feae43e0..37aa404cfa3d 100644 --- a/src/main/acp/acp-launch-specs.ts +++ b/src/main/acp/acp-launch-specs.ts @@ -48,6 +48,8 @@ export type AcpLaunchSpec = { installDirectories(input: { env: Readonly>; homePath: string }): string[] /** Images go to the agent when it also advertises them; off sends text prompts only. */ imagePrompts?: true + /** The agent compacts its conversation when sent `/compact` as a prompt; off hides `/compact`. */ + compaction?: true /** The agent's own store of a session's user messages, read for restart recovery only. */ readStoredUserMessages?: AcpStoredUserMessagesReader } @@ -68,7 +70,8 @@ const GROK_LAUNCH_SPEC: AcpLaunchSpec = { ? 'cached_token' : undefined, account: directoryAccountBinding('GROK_HOME', (homePath) => join(homePath, '.grok')), - installDirectories: ({ env }) => (env.GROK_HOME ? [join(env.GROK_HOME, 'bin')] : []) + installDirectories: ({ env }) => (env.GROK_HOME ? [join(env.GROK_HOME, 'bin')] : []), + compaction: true } // `opencode acp` on 1.x serves in-process; on 2.x it starts a private `opencode serve --stdio` child @@ -96,7 +99,8 @@ const OPENCODE_LAUNCH_SPEC: AcpLaunchSpec = { supportsVersion: (version) => OPENCODE_ACP_RELEASE_LINES.some((line) => isStableCliVersionOnLine(version, line)), imagePrompts: true, - readStoredUserMessages: openCodeStoredUserMessagesReader() + readStoredUserMessages: openCodeStoredUserMessagesReader(), + compaction: true } // OMP serves ACP through `omp acp`; its environment reaches it as the user set it. @@ -116,7 +120,8 @@ const OMP_LAUNCH_SPEC: AcpLaunchSpec = { // OMP's installers use directories the shared resolver already searches after PATH. installDirectories: () => [], // Stable releases from 17.0.5, the release verified to serve `omp acp`. - supportsVersion: (version) => isStableCliVersionFrom(version, '17.0.5') + supportsVersion: (version) => isStableCliVersionFrom(version, '17.0.5'), + compaction: true } export const ACP_LAUNCH_SPECS: readonly AcpLaunchSpec[] = [ diff --git a/src/main/acp/acp-prompt-turns.ts b/src/main/acp/acp-prompt-turns.ts index 257ffc68523d..4b9c7f9deeb8 100644 --- a/src/main/acp/acp-prompt-turns.ts +++ b/src/main/acp/acp-prompt-turns.ts @@ -1,4 +1,5 @@ import type { ProviderTimelineEvent } from '../native-chat/agent-session-timeline/provider-timeline-event' +import type { AcpCompaction } from './acp-compaction-turn' export type AcpPromptTurn = { clientMessageId: string @@ -6,6 +7,8 @@ export type AcpPromptTurn = { requestedAt: number opened: boolean durationMs?: number + /** A `/compact` running as the turn the host opened for it. */ + compaction?: AcpCompaction } /** An injected identity is known before any provider output arrives. */ @@ -28,6 +31,20 @@ export class AcpPromptTurns { return { promptId: turn, events: this.injected ? [] : this.start(turn, at) } } + /** A `/compact` whose turn the host already opened, under the command's own turn id. */ + openCompaction(clientMessageId: string, turn: string, at: number): void { + if (this.current) { + throw new Error('ACP prompt overlaps a prompt or load') + } + this.current = { + clientMessageId, + turn, + requestedAt: at, + opened: true, + compaction: { reply: '' } + } + } + finish(): void { this.last = this.current this.current = undefined diff --git a/src/main/acp/acp-structured-agent-definitions.ts b/src/main/acp/acp-structured-agent-definitions.ts index c82d5483ad88..fa8c93cd847e 100644 --- a/src/main/acp/acp-structured-agent-definitions.ts +++ b/src/main/acp/acp-structured-agent-definitions.ts @@ -18,9 +18,9 @@ export function acpStructuredAgentDefinition(spec: AcpLaunchSpec): StructuredAge handleTransport: ACP_HANDLE_TRANSPORT, ...spec.account.pin, capabilities: { - // ACP has no stable rewind, compact or goal method; a command arrives through `/` instead. + // ACP has no stable rewind or goal method; a command arrives through `/` instead. rewind: false, - compact: false, + compact: spec.compaction === true, threadGoal: false, contextUsage: true, // An agent that does not also advertise images at its start has a message with one refused. diff --git a/src/main/acp/acp-structured-compaction.test.ts b/src/main/acp/acp-structured-compaction.test.ts new file mode 100644 index 000000000000..af70cdbeb136 --- /dev/null +++ b/src/main/acp/acp-structured-compaction.test.ts @@ -0,0 +1,257 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import { agentJournalTurnBody, readAgentJournalTurn } from '../../shared/agent-session-turn-record' +import { + closeProviderTimelineRigs, + SESSION +} from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import { structuredAgentSessionCommandTurn } from '../native-chat/agent-session-wire/structured-agent-session-command-turn' +import type { StructuredAgentSessionCommandRun } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import { StructuredAgentRegistry } from '../native-chat/agent-session-wire/structured-agent-registry' +import { ACP_LAUNCH_SPECS, acpLaunchSpecFor, type AcpLaunchSpec } from './acp-launch-specs' +import { acpStructuredAgentDefinition } from './acp-structured-agent-definitions' +import { + GROK, + openAcpAdapterRig, + PROVIDER_SESSION, + waitFor, + type AcpAdapterRig +} from './acp-structured-adapter.test-support' + +afterEach(async () => { + await closeProviderTimelineRigs() +}) + +const OPENCODE = acpLaunchSpecFor('opencode')! +const OMP = acpLaunchSpecFor('omp')! + +function withoutCompaction(spec: AcpLaunchSpec): AcpLaunchSpec { + const { compaction: _compaction, ...rest } = spec + return rest +} + +/** The command turn the host writes before it hands `/compact` over. */ +async function commandRun(rig: AcpAdapterRig): Promise { + const turn = structuredAgentSessionCommandTurn('compact-1') + const running = agentJournalTurnBody({ turnId: turn.turnId, state: 'running', startedAt: 4_000 }) + await rig.rig.journal.appendItem(turn.identity, running, { + fence: 1, + turnScope: { kind: 'thread' } + }) + return { clientMessageId: 'compact-1', ...turn, running } +} + +/** Starts the agent, sends `/compact`, and returns its prompt frame. */ +async function compact(spec: AcpLaunchSpec) { + const rig = await openAcpAdapterRig({ spec }) + await rig.acquire() + const command = await commandRun(rig) + expect(await rig.adapter.compact({ sessionId: SESSION, fence: 1, command })).toEqual({ + state: 'accepted', + providerIdentity: null + }) + return { rig, command, prompt: await rig.frame('session/prompt') } +} + +function replyText(text: string, promptId?: string) { + return { + sessionId: PROVIDER_SESSION, + update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text } }, + ...(promptId ? { _meta: { promptId } } : {}) + } +} + +/** The command's turn once the agent's answer ended it, and the rows inside it. */ +async function ended(rig: AcpAdapterRig, command: StructuredAgentSessionCommandRun) { + return waitFor(async () => { + const rows = await rig.rig.rows() + const turn = readAgentJournalTurn( + rows.find((row) => row.itemId === agentJournalItemKey(command.identity))?.body + ) + expect(turn?.state).not.toBe('running') + const inside = rows.filter( + (row) => + row.turnScope?.kind === 'turn' && + row.turnScope.turnItemId === agentJournalItemKey(command.identity) + ) + return { turn, inside: inside.map((row) => row.body) } + }) +} + +describe('ACP compaction capability', () => { + it('offers /compact for Grok, OpenCode and OMP, and for no ACP agent without it', () => { + expect( + Object.fromEntries( + ACP_LAUNCH_SPECS.map((spec) => [ + spec.agent, + acpStructuredAgentDefinition(spec).capabilities.compact + ]) + ) + ).toEqual({ grok: true, opencode: true, omp: true }) + expect(acpStructuredAgentDefinition(withoutCompaction(GROK)).capabilities.compact).toBe(false) + }) + + it('registers each compacting agent with the adapter method its capability needs', async () => { + const rig = await openAcpAdapterRig() + expect( + () => + new StructuredAgentRegistry( + ACP_LAUNCH_SPECS.map((spec) => ({ + definition: acpStructuredAgentDefinition(spec), + adapter: rig.adapter + })) + ) + ).not.toThrow() + }) +}) + +describe('ACP compaction request', () => { + it("sends Grok `/compact` as a prompt under the command turn's id", async () => { + const { command, prompt } = await compact(GROK) + expect(prompt.params).toEqual({ + sessionId: PROVIDER_SESSION, + prompt: [{ type: 'text', text: '/compact' }], + _meta: { promptId: command.turnId, requestId: command.turnId } + }) + }) + + it.each([ + ['OpenCode', OPENCODE], + ['OMP', OMP] + ])('sends %s `/compact` as a plain prompt', async (_name, spec) => { + const { prompt } = await compact(spec) + expect(prompt.params).toEqual({ + sessionId: PROVIDER_SESSION, + prompt: [{ type: 'text', text: '/compact' }] + }) + }) +}) + +describe('ACP compaction settlement', () => { + it('ends a compaction the agent finished with one compacted row, drawing none of its words', async () => { + const { rig, command, prompt } = await compact(GROK) + const { agent } = rig.child() + agent.notify('session/update', replyText('Summarised 40 messages.', command.turnId)) + agent.reply(prompt, { stopReason: 'end_turn' }) + const { turn, inside } = await ended(rig, command) + expect(turn).toMatchObject({ turnId: command.turnId, state: 'completed', outcome: 'success' }) + expect(inside).toEqual([ + { kind: 'status', text: 'Context compacted', presentation: 'compaction' } + ]) + expect((await rig.rig.rows()).some((row) => row.body.kind === 'message')).toBe(false) + }) + + it.each([ + [ + 'Compaction failed: Nothing to compact (session too small)', + 'Nothing to compact (session too small)' + ], + ['Compaction failed: Already compacted', 'Already compacted'] + ])('reads OMP %j as skipped, not failed', async (reply, shown) => { + const { rig, command, prompt } = await compact(OMP) + const { agent } = rig.child() + agent.notify('session/update', replyText(reply)) + agent.reply(prompt, { stopReason: 'end_turn' }) + const { turn, inside } = await ended(rig, command) + expect(turn).toMatchObject({ state: 'completed', outcome: 'success' }) + expect(inside).toEqual([{ kind: 'status', tone: 'warning', text: shown }]) + }) + + it('reads any other OMP compaction failure as failed, in its own words', async () => { + const { rig, command, prompt } = await compact(OMP) + const { agent } = rig.child() + agent.notify( + 'session/update', + replyText('Compaction failed: summary model rejected the request') + ) + agent.reply(prompt, { stopReason: 'end_turn' }) + const { turn, inside } = await ended(rig, command) + expect(turn).toMatchObject({ state: 'completed', outcome: 'failure' }) + expect(inside).toEqual([ + expect.objectContaining({ + kind: 'status', + tone: 'error', + failure: expect.objectContaining({ + kind: 'compactionFailed', + detail: { + text: 'Compaction failed: summary model rejected the request', + audience: 'person' + } + }) + }) + ]) + }) + + it('reads OMP failure words only from OMP', async () => { + const { rig, command, prompt } = await compact(OPENCODE) + const { agent } = rig.child() + agent.notify('session/update', replyText('Compaction failed: Nothing to compact')) + agent.reply(prompt, { stopReason: 'end_turn' }) + const { turn, inside } = await ended(rig, command) + expect(turn).toMatchObject({ outcome: 'success' }) + expect(inside).toEqual([ + { kind: 'status', text: 'Context compacted', presentation: 'compaction' } + ]) + }) + + it("fails a compaction the agent answered with an error, in the agent's words", async () => { + const { rig, command, prompt } = await compact(OPENCODE) + rig.child().agent.fail(prompt, -32000, 'Summarization failed: 500') + const { turn, inside } = await ended(rig, command) + expect(turn).toMatchObject({ state: 'completed', outcome: 'failure' }) + expect(inside).toEqual([ + expect.objectContaining({ + tone: 'error', + failure: expect.objectContaining({ + kind: 'compactionFailed', + detail: { text: 'Summarization failed: 500', audience: 'person' } + }) + }) + ]) + }) + + it('fails a compaction the agent ended with any other stop reason', async () => { + const { rig, command, prompt } = await compact(GROK) + rig.child().agent.reply(prompt, { stopReason: 'refusal' }) + const { turn, inside } = await ended(rig, command) + expect(turn).toMatchObject({ state: 'completed', outcome: 'failure' }) + expect(inside).toEqual([ + expect.objectContaining({ failure: expect.objectContaining({ kind: 'compactionFailed' }) }) + ]) + }) + + it('ends a stopped compaction as cancelled, with no result row', async () => { + const { rig, command, prompt } = await compact(GROK) + const { agent } = rig.child() + agent.on('session/cancel', () => agent.reply(prompt, { stopReason: 'cancelled' })) + await expect( + rig.adapter.cancelTurn({ + sessionId: SESSION, + fence: 1, + turnId: command.turnId, + resolveLiveTurnId: () => command.turnId + }) + ).resolves.toEqual({ cancelled: true }) + const { turn, inside } = await ended(rig, command) + expect(turn).toMatchObject({ state: 'interrupted', outcome: 'cancellation' }) + expect(inside).toEqual([]) + }) + + it('takes the next message as an ordinary turn once the compaction ended', async () => { + const { rig, command, prompt } = await compact(GROK) + rig.child().agent.reply(prompt, { stopReason: 'end_turn' }) + await ended(rig, command) + expect( + await rig.adapter.dispatch({ + sessionId: SESSION, + clientMessageId: 'send-2', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'next' }] }, + fence: 1 + }) + ).toEqual({ state: 'admitted' }) + expect((await rig.frame('session/prompt', 1)).params).toMatchObject({ + prompt: [{ type: 'text', text: 'next' }], + _meta: { promptId: 'prompt:send-2' } + }) + }) +}) diff --git a/src/main/acp/acp-structured-lane.ts b/src/main/acp/acp-structured-lane.ts index 958d20dc47ef..b0f8cf5326d8 100644 --- a/src/main/acp/acp-structured-lane.ts +++ b/src/main/acp/acp-structured-lane.ts @@ -10,6 +10,7 @@ import { import type { ProviderTimelineEvent } from '../native-chat/agent-session-timeline/provider-timeline-event' import { createLegacyProviderTimelineIdentityScheme } from '../native-chat/agent-session-timeline/provider-timeline-identity' import type { ProviderTimelineSink } from '../native-chat/agent-session-timeline/provider-timeline-plan' +import type { StructuredAgentSessionCommandRun } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import type { AcpDialect } from './acp-dialects/acp-dialect' import { AcpTimelineTranslator } from './acp-timeline-translator' @@ -64,6 +65,18 @@ export class AcpStructuredLane { return this.assembler.openTurnId } + /** The host already opened this command's turn; the agent's frames and end join that turn. */ + beginCommand(command: StructuredAgentSessionCommandRun): void { + if (this.failed || this.disposed || this.backlog.length > 0) { + throw new Error('ACP timeline has not drained for this command') + } + this.assembler.beginCommand(command) + } + + forgetCommand(turnId: string): void { + this.assembler.forgetCommand(turnId) + } + apply(events: readonly ProviderTimelineEvent[]): void { if (this.failed || this.disposed) { return diff --git a/src/main/acp/acp-structured-session-adapter.ts b/src/main/acp/acp-structured-session-adapter.ts index 664580848706..f4a763b782f3 100644 --- a/src/main/acp/acp-structured-session-adapter.ts +++ b/src/main/acp/acp-structured-session-adapter.ts @@ -1,6 +1,6 @@ // A structured chat over the Agent Client Protocol: one adapter per registered ACP agent, which -// the router drives like the Claude and Codex lanes. Rewind, compaction and goals are absent, so -// the chat hides them; everything else maps onto ACP methods. +// the router drives like the Claude and Codex lanes. Rewind and goals are absent, so the chat hides +// them; compaction is the agent's own `/compact` prompt, for an agent whose launch spec offers it. import { randomUUID } from 'node:crypto' import { agentSessionFailureFact } from '../../shared/agent-session-failure' @@ -170,6 +170,12 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte return { state: 'admitted' } } + /** The prompt's write is the receipt; the agent's answer ends the command's turn. */ + compact: NonNullable = async (input) => { + this.live(input.sessionId).turns.compact(input.command) + return { state: 'accepted', providerIdentity: null } + } + cancelTurn: StructuredAgentSessionAdapter['cancelTurn'] = async (input) => { const session = this.live(input.sessionId) // The Stop ends the child unless it is declined here. Claude's rule: a Stop naming an ended turn diff --git a/src/main/acp/acp-structured-turns.ts b/src/main/acp/acp-structured-turns.ts index 4bbf773ea51f..861dcd3b3769 100644 --- a/src/main/acp/acp-structured-turns.ts +++ b/src/main/acp/acp-structured-turns.ts @@ -18,6 +18,8 @@ import { type AgentJournalDispatchRejection } from '../../shared/agent-session-failure-words' import type { AgentJournalItemIdentity } from '../../shared/agent-session-journal-types' +import type { StructuredAgentSessionCommandRun } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import { ACP_COMPACT_PROMPT } from './acp-compaction-turn' import { AcpAgentError, AcpConnectionClosedError } from './acp-errors' import type { AcpStructuredConnection } from './acp-structured-connection' import type { AcpStructuredLane } from './acp-structured-lane' @@ -109,6 +111,33 @@ export class AcpStructuredTurns { this.start(send) } + /** A `/compact` in the command turn the host opened. The host settles its message, so nothing + * here does; the answer ends the turn, or the child's end does. */ + compact(command: StructuredAgentSessionCommandRun): void { + if (this.active || this.ended) { + throw new Error(`${this.deps.agentName} is still working`) + } + const { lane } = this.deps + const send: Send = { + clientMessageId: command.clientMessageId, + prompt: [...ACP_COMPACT_PROMPT], + requestedAt: this.deps.now() + } + lane.beginCommand(command) + try { + const opened = lane.translator.openCompaction( + send.clientMessageId, + command.turnId, + send.requestedAt + ) + this.active = send + this.run(send, opened.promptId) + } catch (error) { + lane.forgetCommand(command.turnId) + throw error + } + } + /** The agent took the send: its turn's first event, or its answer, arrived. */ accept(clientMessageId: string): void { if (this.unsettled.delete(clientMessageId)) { @@ -147,10 +176,15 @@ export class AcpStructuredTurns { this.active = send const opened = lane.translator.openPrompt(send.clientMessageId, send.requestedAt) lane.apply(opened.events) + this.run(send, opened.promptId) + } + + private run(send: Send, promptId: string): void { + const { lane } = this.deps // An agent whose dialect echoes this id on every event of the turn gets it, so its rows join the // turn Orca opened; no other agent is sent the extension. const meta = lane.translator.injectsPromptIdentity - ? { promptId: opened.promptId, requestId: opened.promptId } + ? { promptId, requestId: promptId } : undefined const answered = this.deps.connection.prompt(send.prompt, meta) if (this.steers.length > 0) { diff --git a/src/main/acp/acp-timeline-translator.ts b/src/main/acp/acp-timeline-translator.ts index 30af2bf56444..53a5dc22d502 100644 --- a/src/main/acp/acp-timeline-translator.ts +++ b/src/main/acp/acp-timeline-translator.ts @@ -2,10 +2,11 @@ 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 } from './acp-context-usage' +import { acpCompactionEnd, readAcpCompactionUpdate } from './acp-compaction-turn' import { AcpBackgroundTaskTimeline } from './acp-background-task-timeline' import { GENERIC_ACP_DIALECT, type AcpDialect } from './acp-dialects/acp-dialect' import { AcpAgentError } from './acp-errors' -import { acpTurnEnd, AcpPromptTurns } from './acp-prompt-turns' +import { acpTurnEnd, AcpPromptTurns, type AcpPromptTurn } from './acp-prompt-turns' import { readAcpSessionEvent, type AcpSessionEvent } from './acp-session-events' import { translateAcpRequest } from './acp-timeline-requests' import { acpSessionUpdate } from './acp-session-update' @@ -73,6 +74,19 @@ export class AcpTimelineTranslator { return this.prompts.open(clientMessageId, at) } + /** A `/compact` run as the command turn `turn` the host opened: frames join it, and it ends from + * the prompt's answer. Returns the identity to inject, as `openPrompt` does. */ + openCompaction(clientMessageId: string, turn: string, at: number): { promptId: string } { + if (this.loading) { + throw new Error('ACP prompt overlaps a prompt or load') + } + this.prompts.openCompaction(clientMessageId, turn, at) + // Marked so a late frame for it neither reopens nor retargets it. + this.started.set(turn, true) + this.activeTurn = turn + return { promptId: turn } + } + promptResult( clientMessageId: string, result: PromptResponse, @@ -113,6 +127,22 @@ export class AcpTimelineTranslator { if (prompt?.clientMessageId !== clientMessageId) { return [] } + if (prompt.compaction) { + const events = acpCompactionEnd({ + compaction: prompt.compaction, + turn: prompt.turn, + thread: this.options.sessionId, + stopReason, + at, + ...(prompt.durationMs === undefined ? {} : { durationMs: prompt.durationMs }), + ...(failureDetail === undefined ? {} : { failureDetail }), + dialect: this.dialect, + ...(this.options.agentName === undefined ? {} : { agentName: this.options.agentName }) + }) + this.end(prompt.turn) + this.prompts.finish() + return events + } const events = this.start(prompt.turn, at) events.push(...this.endTurn(prompt.turn, stopReason, at, prompt.durationMs, failureDetail)) return events @@ -174,17 +204,34 @@ export class AcpTimelineTranslator { if (turn && opens) { events.push(...this.start(turn, extension?.at ?? at)) } - const join = { thread: this.options.sessionId, ...(turn === undefined ? {} : { turn }) } + // A running compaction's words and end are read for its result, which its answer writes. Its + // turn is the host's, open in the assembler under no provider key, so its rows name none. + const compacting = turn === undefined ? undefined : this.compactingPrompt(turn) + const compaction = compacting?.compaction + const rowTurn = compacting ? undefined : turn + const join = { + thread: this.options.sessionId, + ...(rowTurn === undefined ? {} : { turn: rowTurn }) + } if (extension?.backgroundTasks) { events.push(...this.backgroundTasks.translate(extension.backgroundTasks, join)) } if (extension?.usage) { events.push(...this.context.update(extension.usage, join)) } - if (extension?.failureDetail && turn) { + if (extension?.failureDetail && compaction) { + compaction.failureDetail = extension.failureDetail + } else if (extension?.failureDetail && turn) { events.push(...this.failures.row(turn, extension.failureDetail)) } const end = extension?.end + if (end && compacting && compaction) { + compacting.durationMs = end.durationMs + if (end.failureDetail) { + compaction.failureDetail = end.failureDetail + } + return events + } if (end && turn) { if ( this.prompts.current?.turn === turn && @@ -196,6 +243,9 @@ export class AcpTimelineTranslator { } return events } + if (standard && compaction && readAcpCompactionUpdate(compaction, standard.update)) { + return events + } if (standard) { const messageKey = turn && this.dialect.injectedPromptIdentity @@ -203,7 +253,7 @@ export class AcpTimelineTranslator { : undefined return [ ...events, - ...acpSessionUpdate(standard, turn, at, { + ...acpSessionUpdate(standard, rowTurn, at, { tools: this.tools, dialect: this.dialect, backgroundTasks: this.backgroundTasks, @@ -246,6 +296,11 @@ export class AcpTimelineTranslator { return events } + private compactingPrompt(turn: string): AcpPromptTurn | undefined { + const prompt = this.prompts.current + return prompt?.turn === turn && prompt.compaction ? prompt : undefined + } + private start(turn: string, at: number): ProviderTimelineEvent[] { if (turn === this.prompts.current?.turn) { const events = this.prompts.start(turn, at) From 537a7b45003fbd486f9524e629782d79157763b2 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 8 Oct 2026 02:02:18 -0700 Subject: [PATCH 2/5] test(acp): expect Grok compaction capability --- .../runtime/structured-agent-runtime-registrations-acp.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/runtime/structured-agent-runtime-registrations-acp.test.ts b/src/main/runtime/structured-agent-runtime-registrations-acp.test.ts index 43b11bdee1cf..e73a2232ad01 100644 --- a/src/main/runtime/structured-agent-runtime-registrations-acp.test.ts +++ b/src/main/runtime/structured-agent-runtime-registrations-acp.test.ts @@ -16,7 +16,7 @@ describe('ACP agents in the runtime registrations', () => { accountHomeVariable: 'GROK_HOME', capabilities: { rewind: false, - compact: false, + compact: true, threadGoal: false, contextUsage: true, imagePrompts: false, From cb0556199314e2f9586d54cb81f8d6187cd28366 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 8 Oct 2026 08:46:49 -0700 Subject: [PATCH 3/5] fix(chat): keep skipped compaction outcomes visible across history pages --- ...native-chat-compaction-pagination.test.mjs | 172 ++++++++++++++++++ .../acp-compaction-journal-fixture.test.ts | 33 ++++ src/main/acp/acp-compaction-turn.ts | 8 +- .../acp/acp-structured-compaction.test.ts | 65 ++++++- .../omp-v17-compact-skip.journal.jsonl | 6 + .../acp/fixtures/omp-v17-compact-skip.jsonl | 3 + .../omp-v17-compact-skip.render.jsonl | 1 + .../fixtures/omp-v17-compact-success.jsonl | 3 + .../fixtures/omp-v17-compact-tiny-skip.jsonl | 3 + .../acp/fixtures/omp-v18-compact-skip.jsonl | 3 + .../fixtures/omp-v18-compact-success.jsonl | 3 + .../fixtures/omp-v18-compact-tiny-skip.jsonl | 3 + .../provider-timeline-identity.ts | 11 +- .../native-chat/NativeChatNoticeRow.test.tsx | 11 ++ .../native-chat-compaction-visibility.test.ts | 146 +++++++++++++++ .../native-chat-transcript-slots.ts | 2 + src/shared/agent-session-compaction.ts | 15 ++ .../agent-session-journal-schemas.test.ts | 1 + src/shared/provider-timeline-item-key.ts | 9 + ...tructured-agent-session-projection.test.ts | 1 + .../structured-agent-session-projection.ts | 5 +- .../structured-agent-session-status-block.ts | 14 +- 22 files changed, 502 insertions(+), 16 deletions(-) create mode 100644 config/scripts/native-chat-compaction-pagination.test.mjs create mode 100644 src/main/acp/acp-compaction-journal-fixture.test.ts create mode 100644 src/main/acp/fixtures/omp-v17-compact-skip.journal.jsonl create mode 100644 src/main/acp/fixtures/omp-v17-compact-skip.jsonl create mode 100644 src/main/acp/fixtures/omp-v17-compact-skip.render.jsonl create mode 100644 src/main/acp/fixtures/omp-v17-compact-success.jsonl create mode 100644 src/main/acp/fixtures/omp-v17-compact-tiny-skip.jsonl create mode 100644 src/main/acp/fixtures/omp-v18-compact-skip.jsonl create mode 100644 src/main/acp/fixtures/omp-v18-compact-success.jsonl create mode 100644 src/main/acp/fixtures/omp-v18-compact-tiny-skip.jsonl create mode 100644 src/renderer/src/components/native-chat/native-chat-compaction-visibility.test.ts create mode 100644 src/shared/agent-session-compaction.ts create mode 100644 src/shared/provider-timeline-item-key.ts diff --git a/config/scripts/native-chat-compaction-pagination.test.mjs b/config/scripts/native-chat-compaction-pagination.test.mjs new file mode 100644 index 000000000000..0dd584138125 --- /dev/null +++ b/config/scripts/native-chat-compaction-pagination.test.mjs @@ -0,0 +1,172 @@ +import { readFile } from 'node:fs/promises' +import { expect, it } from 'vitest' +import { readAgentSessionHistory } from '../../src/main/native-chat/agent-session-wire/agent-session-history-page.ts' +import { projectStructuredAgentSessionMessages } from '../../src/renderer/src/components/native-chat/structured-agent-session-message-projection.ts' +import { nativeChatTurnMembership } from '../../src/shared/native-chat-turn-membership.ts' +import { nativeChatRowsInDrawOrder } from '../../src/shared/native-chat-turn-grouping.ts' +import { selectNativeChatTurnStatuses } from '../../src/shared/native-chat-turn-status.ts' +import { selectStructuredAgentSettledTurns } from '../../src/shared/structured-agent-session-turn-timing.ts' +import { buildNativeChatTranscriptSlots } from '../../src/renderer/src/components/native-chat/native-chat-transcript-slots.ts' + +async function snapshotWithLaterHistory() { + const captured = JSON.parse( + await readFile( + new URL('../../src/main/acp/fixtures/omp-v17-compact-skip.render.jsonl', import.meta.url), + 'utf8' + ) + ) + const items = [...captured.items] + // Later turns are constructed test history; the skipped outcome is captured. + for (let n = 0; n < 66; n++) { + const sequence = 24 + n * 3 + const startedAt = 1791468228000 + n * 1000 + const userItemId = `later-user-${n}` + const turnItemId = `later-turn-${n}` + items.push( + { + itemId: userItemId, + revision: 1, + sequence, + observedAt: startedAt, + turnScope: { kind: 'thread' }, + body: { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: `later request ${n}` }] + } + }, + { + itemId: turnItemId, + revision: 2, + sequence: sequence + 1, + observedAt: startedAt, + turnScope: { kind: 'thread' }, + body: { + kind: 'turn', + turnId: turnItemId, + state: 'completed', + outcome: 'success', + userItemId, + requestedAt: startedAt, + startedAt, + completedAt: startedAt + 7 + } + }, + { + itemId: `later-answer-${n}`, + revision: 1, + sequence: sequence + 2, + observedAt: startedAt + 7, + turnScope: { kind: 'turn', turnItemId }, + body: { + kind: 'message', + role: 'assistant', + blocks: [{ type: 'text', text: `later answer ${n}` }] + } + } + ) + } + return { + sessionId: 'session-1', + cursor: { epoch: 'epoch-1', sequence: 221 }, + items, + submissions: captured.submissions + } +} + +function slotsFor(page) { + const messages = projectStructuredAgentSessionMessages(page.items, [], page.submissions) + const membership = nativeChatTurnMembership(messages, page) + const turnStatuses = selectNativeChatTurnStatuses( + {}, + { + activeTurnKey: membership.liveTurnKey ?? '', + isWorking: false, + thinking: false, + settledByTurn: selectStructuredAgentSettledTurns(page.items, page.submissions) + } + ) + return buildNativeChatTranscriptSlots({ + messages: nativeChatRowsInDrawOrder(messages, membership.drawOrder), + turnKeys: nativeChatRowsInDrawOrder(membership.turnKeys, membership.drawOrder), + liveTurnKey: membership.liveTurnKey, + receipts: new Map(), + turnStatuses, + turnDiffs: new Map(), + expandedTurnKeys: new Set(), + isWorking: false, + lifecycleWorking: false + }) +} + +it('keeps the captured skip visible when the actual 200-item history tail omits only its opener', async () => { + const snapshot = await snapshotWithLaterHistory() + const result = readAgentSessionHistory( + { snapshot: () => snapshot }, + { sessionId: 'session-1', direction: 'tail', limit: 200 }, + snapshot + ) + expect(result.ok).toBe(true) + const page = result.page + expect(page.items).toHaveLength(200) + expect(page.items[0].itemId).toBe('orca:command-turn%3Acompact-1') + expect(page.items[1].body.text).toBe('Nothing to compact (session too small)') + expect(page.submissions).toEqual([]) + const slot = slotsFor(page).find( + (slot) => + slot.kind === 'message' && + slot.message.blocks.some( + (block) => block.type === 'text' && block.text === 'Nothing to compact (session too small)' + ) + ) + expect(slot).toMatchObject({ drawsMessage: true, folded: false }) +}) + +it('restores the captured skip after the real older-page read loads its opener', async () => { + const snapshot = await snapshotWithLaterHistory() + const tail = readAgentSessionHistory( + { snapshot: () => snapshot }, + { sessionId: 'session-1', direction: 'tail', limit: 200 }, + snapshot + ).page + const older = readAgentSessionHistory( + { snapshot: () => snapshot }, + { sessionId: 'session-1', direction: 'before', cursor: tail.window.nextCursor, limit: 200 }, + snapshot + ).page + expect(older.items.map((item) => item.itemId)).toEqual(['orca:compact-1']) + const slots = slotsFor({ + ...tail, + items: [...older.items, ...tail.items], + submissions: older.submissions + }) + const slot = slots.find( + (slot) => + slot.kind === 'message' && + slot.message.blocks.some( + (block) => block.type === 'text' && block.text === 'Nothing to compact (session too small)' + ) + ) + expect(slot).toMatchObject({ drawsMessage: true, folded: false }) +}) + +it('continues folding an ordinary warning at the same real history boundary', async () => { + const snapshot = await snapshotWithLaterHistory() + snapshot.items = snapshot.items.map((item) => + item.body.kind === 'status' ? { ...item, itemId: 'orca:ordinary-warning' } : item + ) + const result = readAgentSessionHistory( + { snapshot: () => snapshot }, + { sessionId: 'session-1', direction: 'tail', limit: 200 }, + snapshot + ) + expect(result.ok).toBe(true) + const slot = slotsFor(result.page).find( + (slot) => + slot.kind === 'message' && + slot.message.blocks.some( + (block) => block.type === 'text' && block.text === 'Nothing to compact (session too small)' + ) + ) + expect(slot).toMatchObject({ drawsMessage: false, folded: true }) +}) diff --git a/src/main/acp/acp-compaction-journal-fixture.test.ts b/src/main/acp/acp-compaction-journal-fixture.test.ts new file mode 100644 index 000000000000..21f7896afdd3 --- /dev/null +++ b/src/main/acp/acp-compaction-journal-fixture.test.ts @@ -0,0 +1,33 @@ +import { readFile } from 'node:fs/promises' +import { expect, it } from 'vitest' +import { + applyJournalRow, + createJournalReducerState, + renderJournalState +} from '../native-chat/agent-session-journal/journal-reducer' +import { parseJournalRow } from '../native-chat/agent-session-journal/journal-row-schema' + +async function capturedCompaction() { + const text = await readFile( + new URL('./fixtures/omp-v17-compact-skip.journal.jsonl', import.meta.url), + 'utf8' + ) + const state = createJournalReducerState('session-1', 'epoch-1') + for (const line of text.trim().split('\n')) { + const parsed = parseJournalRow(line) + expect(parsed.ok).toBe(true) + if (!parsed.ok) { + throw new Error('Unreadable captured journal row') + } + applyJournalRow(state, parsed.row) + } + return renderJournalState(state) +} + +it('replays the captured OMP skip into the renderer fixture without changing its outcome', async () => { + const journal = await capturedCompaction() + const expected: unknown = JSON.parse( + await readFile(new URL('./fixtures/omp-v17-compact-skip.render.jsonl', import.meta.url), 'utf8') + ) + expect({ items: journal.items, submissions: journal.submissions }).toEqual(expected) +}) diff --git a/src/main/acp/acp-compaction-turn.ts b/src/main/acp/acp-compaction-turn.ts index 70a7db4f3f8f..4c7f1d246dd0 100644 --- a/src/main/acp/acp-compaction-turn.ts +++ b/src/main/acp/acp-compaction-turn.ts @@ -4,6 +4,7 @@ import { providerDiagnostic } from '../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../shared/agent-session-failure-words' +import { AGENT_SESSION_COMPACTION_SKIPPED_PRESENTATION } from '../../shared/agent-session-compaction' import type { ProviderTimelineEvent } from '../native-chat/agent-session-timeline/provider-timeline-event' import { structuredCompactionOutcome } from '../native-chat/agent-session-wire/structured-conversation-command-outcome' import type { AcpDialect } from './acp-dialects/acp-dialect' @@ -58,7 +59,12 @@ export function acpCompactionEnd(input: { { type: 'item.close', item: `compaction:${turn}`, - body: { kind: 'status', tone: 'warning', text }, + body: { + kind: 'status', + tone: 'warning', + text, + presentation: AGENT_SESSION_COMPACTION_SKIPPED_PRESENTATION + }, join }, end('success') diff --git a/src/main/acp/acp-structured-compaction.test.ts b/src/main/acp/acp-structured-compaction.test.ts index af70cdbeb136..c2beea5e4c5b 100644 --- a/src/main/acp/acp-structured-compaction.test.ts +++ b/src/main/acp/acp-structured-compaction.test.ts @@ -10,6 +10,8 @@ import type { StructuredAgentSessionCommandRun } from '../native-chat/agent-sess import { StructuredAgentRegistry } from '../native-chat/agent-session-wire/structured-agent-registry' import { ACP_LAUNCH_SPECS, acpLaunchSpecFor, type AcpLaunchSpec } from './acp-launch-specs' import { acpStructuredAgentDefinition } from './acp-structured-agent-definitions' +import { GrokFixtureReplay } from './acp-structured-fixture-replay.test-support' +import { readAcpFixture, type AcpFixtureFrame } from './acp-timeline-fixture.test-support' import { GROK, openAcpAdapterRig, @@ -42,9 +44,12 @@ async function commandRun(rig: AcpAdapterRig): Promise { }) describe('ACP compaction settlement', () => { + it.each([ + [ + 'omp-v17-compact-skip', + { + kind: 'status', + tone: 'warning', + text: 'Nothing to compact (session too small)', + presentation: 'compaction-skipped' + } + ], + [ + 'omp-v17-compact-tiny-skip', + { + kind: 'status', + tone: 'warning', + text: 'Nothing to compact (session too small)', + presentation: 'compaction-skipped' + } + ], + [ + 'omp-v17-compact-success', + { kind: 'status', text: 'Context compacted', presentation: 'compaction' } + ], + [ + 'omp-v18-compact-skip', + { + kind: 'status', + tone: 'warning', + text: 'Nothing to compact (session too small)', + presentation: 'compaction-skipped' + } + ], + [ + 'omp-v18-compact-tiny-skip', + { + kind: 'status', + tone: 'warning', + text: 'Nothing to compact (session too small)', + presentation: 'compaction-skipped' + } + ], + [ + 'omp-v18-compact-success', + { kind: 'status', text: 'Context compacted', presentation: 'compaction' } + ] + ])('replays captured %s under the host command turn', async (fixture, body) => { + const frames = await readAcpFixture(fixture) + const { rig, command } = await compact(OMP, frames) + const { turn, inside } = await ended(rig, command) + expect(turn).toMatchObject({ state: 'completed', outcome: 'success' }) + expect(inside).toEqual([body]) + expect((await rig.rig.rows()).some((row) => row.body.kind === 'message')).toBe(false) + }) + it('ends a compaction the agent finished with one compacted row, drawing none of its words', async () => { const { rig, command, prompt } = await compact(GROK) const { agent } = rig.child() @@ -154,7 +213,9 @@ describe('ACP compaction settlement', () => { agent.reply(prompt, { stopReason: 'end_turn' }) const { turn, inside } = await ended(rig, command) expect(turn).toMatchObject({ state: 'completed', outcome: 'success' }) - expect(inside).toEqual([{ kind: 'status', tone: 'warning', text: shown }]) + expect(inside).toEqual([ + { kind: 'status', tone: 'warning', text: shown, presentation: 'compaction-skipped' } + ]) }) it('reads any other OMP compaction failure as failed, in its own words', async () => { diff --git a/src/main/acp/fixtures/omp-v17-compact-skip.journal.jsonl b/src/main/acp/fixtures/omp-v17-compact-skip.journal.jsonl new file mode 100644 index 000000000000..aa938253b767 --- /dev/null +++ b/src/main/acp/fixtures/omp-v17-compact-skip.journal.jsonl @@ -0,0 +1,6 @@ +{"kind":"submission","clientMessageId":"compact-1","payloadFingerprint":"e0d6ad4ed5f5d09f57245e1c1ab64f773c6614954d9cff1b699f26f575b5504b","providerHandle":{"kind":"opaque","agent":"omp","value":"session-1"},"body":{"kind":"message","role":"user","blocks":[{"type":"text","text":"/compact"}],"command":{"name":"compact"}},"v":2,"epoch":"epoch-1","seq":18,"fence":1,"ts":1791468227120,"handoverRecorded":true,"origin":"client","source":{"kind":"user"}} +{"kind":"dispatch","clientMessageId":"compact-1","state":"pending","providerItemId":null,"reason":null,"v":2,"epoch":"epoch-1","seq":19,"fence":1,"ts":1791468227121,"turnScope":{"kind":"thread"}} +{"kind":"item","itemId":"orca:command-turn%3Acompact-1","revision":1,"body":{"kind":"turn","turnId":"compact:compact-1","state":"running","userItemId":"orca:compact-1","requestedAt":1791468227121,"startedAt":1791468227121},"v":3,"epoch":"epoch-1","seq":20,"fence":1,"ts":1791468227121,"turnScope":{"kind":"thread"}} +{"kind":"dispatch","clientMessageId":"compact-1","state":"accepted","providerItemId":null,"reason":null,"v":2,"epoch":"epoch-1","seq":21,"fence":1,"ts":1791468227122} +{"kind":"item","itemId":"legacy:omp:runtime-session-1:item%3Ap%3Asession-1%3Asession-1%2Fcompaction%253Acompact%253Acompact-1","revision":1,"body":{"kind":"status","tone":"warning","text":"Nothing to compact (session too small)"},"v":2,"epoch":"epoch-1","seq":22,"fence":1,"ts":1791468227128,"turnScope":{"kind":"turn","turnItemId":"orca:command-turn%3Acompact-1"}} +{"kind":"lifecycle-batch","settlementId":"provider-timeline:runtime-session-1:b803ea1c-38a0-4510-bc07-ab0012411cb5:5:turn-end","mutations":[{"kind":"item","itemId":"orca:command-turn%3Acompact-1","revision":2,"body":{"kind":"turn","turnId":"compact:compact-1","state":"completed","outcome":"success","userItemId":"orca:compact-1","startedAt":1791468227121,"requestedAt":1791468227121,"completedAt":1791468227128},"turnScope":{"kind":"thread"}}],"v":3,"epoch":"epoch-1","seq":23,"fence":1,"ts":1791468227129} diff --git a/src/main/acp/fixtures/omp-v17-compact-skip.jsonl b/src/main/acp/fixtures/omp-v17-compact-skip.jsonl new file mode 100644 index 000000000000..ece76036f010 --- /dev/null +++ b/src/main/acp/fixtures/omp-v17-compact-skip.jsonl @@ -0,0 +1,3 @@ +{"direction":"out","process":"p1","message":{"jsonrpc":"2.0","id":3,"method":"session/prompt","params":{"sessionId":"session-1","prompt":[{"type":"text","text":"/compact"}]}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"Compaction failed: Nothing to compact (session too small)"},"messageId":"message-1"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}}} diff --git a/src/main/acp/fixtures/omp-v17-compact-skip.render.jsonl b/src/main/acp/fixtures/omp-v17-compact-skip.render.jsonl new file mode 100644 index 000000000000..780364187e59 --- /dev/null +++ b/src/main/acp/fixtures/omp-v17-compact-skip.render.jsonl @@ -0,0 +1 @@ +{"items":[{"itemId":"orca:compact-1","revision":0,"body":{"kind":"message","role":"user","blocks":[{"type":"text","text":"/compact"}],"command":{"name":"compact"}},"sequence":19,"observedAt":1791468227121,"turnScope":{"kind":"thread"}},{"itemId":"orca:command-turn%3Acompact-1","revision":2,"body":{"kind":"turn","turnId":"compact:compact-1","state":"completed","outcome":"success","userItemId":"orca:compact-1","startedAt":1791468227121,"requestedAt":1791468227121,"completedAt":1791468227128},"sequence":20,"observedAt":1791468227121,"turnScope":{"kind":"thread"}},{"itemId":"legacy:omp:runtime-session-1:item%3Ap%3Asession-1%3Asession-1%2Fcompaction%253Acompact%253Acompact-1","revision":1,"body":{"kind":"status","tone":"warning","text":"Nothing to compact (session too small)"},"sequence":22,"observedAt":1791468227128,"turnScope":{"kind":"turn","turnItemId":"orca:command-turn%3Acompact-1"}}],"submissions":[{"clientMessageId":"compact-1","fence":1,"payloadFingerprint":"e0d6ad4ed5f5d09f57245e1c1ab64f773c6614954d9cff1b699f26f575b5504b","dispatchState":"accepted","providerItemId":null,"reason":null,"submittedAt":1791468227120,"resolvedAt":1791468227122,"submittedSequence":18,"handoverRecorded":true,"acceptedSequence":18,"origin":"client","source":{"kind":"user"},"handedOverAt":1791468227121}]} diff --git a/src/main/acp/fixtures/omp-v17-compact-success.jsonl b/src/main/acp/fixtures/omp-v17-compact-success.jsonl new file mode 100644 index 000000000000..76607d8c831f --- /dev/null +++ b/src/main/acp/fixtures/omp-v17-compact-success.jsonl @@ -0,0 +1,3 @@ +{"direction":"out","process":"p1","message":{"jsonrpc":"2.0","id":6,"method":"session/prompt","params":{"sessionId":"session-1","prompt":[{"type":"text","text":"/compact"}]}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"Compaction complete. Tokens: 32814 -> 33362 (saved -548)."},"messageId":"message-1"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","id":6,"result":{"stopReason":"end_turn"}}} diff --git a/src/main/acp/fixtures/omp-v17-compact-tiny-skip.jsonl b/src/main/acp/fixtures/omp-v17-compact-tiny-skip.jsonl new file mode 100644 index 000000000000..91aed2344724 --- /dev/null +++ b/src/main/acp/fixtures/omp-v17-compact-tiny-skip.jsonl @@ -0,0 +1,3 @@ +{"direction":"out","process":"p1","message":{"jsonrpc":"2.0","id":5,"method":"session/prompt","params":{"sessionId":"session-1","prompt":[{"type":"text","text":"/compact"}]}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"Compaction failed: Nothing to compact (session too small)"},"messageId":"message-1"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","id":5,"result":{"stopReason":"end_turn"}}} diff --git a/src/main/acp/fixtures/omp-v18-compact-skip.jsonl b/src/main/acp/fixtures/omp-v18-compact-skip.jsonl new file mode 100644 index 000000000000..ece76036f010 --- /dev/null +++ b/src/main/acp/fixtures/omp-v18-compact-skip.jsonl @@ -0,0 +1,3 @@ +{"direction":"out","process":"p1","message":{"jsonrpc":"2.0","id":3,"method":"session/prompt","params":{"sessionId":"session-1","prompt":[{"type":"text","text":"/compact"}]}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"Compaction failed: Nothing to compact (session too small)"},"messageId":"message-1"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}}} diff --git a/src/main/acp/fixtures/omp-v18-compact-success.jsonl b/src/main/acp/fixtures/omp-v18-compact-success.jsonl new file mode 100644 index 000000000000..23acc2ecc655 --- /dev/null +++ b/src/main/acp/fixtures/omp-v18-compact-success.jsonl @@ -0,0 +1,3 @@ +{"direction":"out","process":"p1","message":{"jsonrpc":"2.0","id":6,"method":"session/prompt","params":{"sessionId":"session-1","prompt":[{"type":"text","text":"/compact"}]}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"Compaction complete. Tokens: 32639 -> 17109 (saved 15530)."},"messageId":"message-1"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","id":6,"result":{"stopReason":"end_turn"}}} diff --git a/src/main/acp/fixtures/omp-v18-compact-tiny-skip.jsonl b/src/main/acp/fixtures/omp-v18-compact-tiny-skip.jsonl new file mode 100644 index 000000000000..91aed2344724 --- /dev/null +++ b/src/main/acp/fixtures/omp-v18-compact-tiny-skip.jsonl @@ -0,0 +1,3 @@ +{"direction":"out","process":"p1","message":{"jsonrpc":"2.0","id":5,"method":"session/prompt","params":{"sessionId":"session-1","prompt":[{"type":"text","text":"/compact"}]}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"Compaction failed: Nothing to compact (session too small)"},"messageId":"message-1"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","id":5,"result":{"stopReason":"end_turn"}}} 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 f1c0038b4d86..4cacb3efe2f1 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 @@ -81,16 +81,7 @@ export function isProviderTimelineTurnInNamespace(turnId: string, namespace: str return turnId.startsWith(`p:${providerTimelineKeyPart(namespace)}:`) } -/** The spelled key of a provider-keyed item's record id, whichever session and thread wrote it; - * null for any other row. Compare it with `providerTimelineKeyPart(key)`. */ -export function spelledProviderTimelineItemKey(recordId: string): string | null { - if (!recordId.startsWith('item:p:')) { - return null - } - // Every part is URI-encoded, so `:` and `/` only ever separate parts. - const scoped = recordId.slice(recordId.lastIndexOf(':') + 1) - return scoped.slice(scoped.lastIndexOf('/') + 1) -} +export { spelledProviderTimelineItemKey } from '../../../shared/provider-timeline-item-key' /** 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. */ diff --git a/src/renderer/src/components/native-chat/NativeChatNoticeRow.test.tsx b/src/renderer/src/components/native-chat/NativeChatNoticeRow.test.tsx index db62bc280a7e..e32e3199c67b 100644 --- a/src/renderer/src/components/native-chat/NativeChatNoticeRow.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatNoticeRow.test.tsx @@ -12,6 +12,17 @@ import { afterEach(cleanup) +it('keeps a skipped compaction warning truthful instead of showing the success separator', () => { + renderStatus({ + kind: 'status', + tone: 'warning', + text: 'Nothing to compact (session too small)', + presentation: 'compaction-skipped' + }) + expect(screen.getByText('Nothing to compact (session too small)')).toBeInTheDocument() + expect(screen.queryByText('Context compacted')).toBeNull() +}) + function orcaStopView( hostLabel: string | null, continueAvailable: boolean diff --git a/src/renderer/src/components/native-chat/native-chat-compaction-visibility.test.ts b/src/renderer/src/components/native-chat/native-chat-compaction-visibility.test.ts new file mode 100644 index 000000000000..14755413cc48 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-compaction-visibility.test.ts @@ -0,0 +1,146 @@ +import { readFile } from 'node:fs/promises' +import { describe, expect, it } from 'vitest' +import { nativeChatTurnMembership } from '../../../../shared/native-chat-turn-membership' +import { selectNativeChatTurnStatuses } from '../../../../shared/native-chat-turn-status' +import { selectStructuredAgentSettledTurns } from '../../../../shared/structured-agent-session-turn-timing' +import { projectStructuredAgentSessionMessages } from './structured-agent-session-message-projection' +import { buildNativeChatTranscriptSlots } from './native-chat-transcript-slots' +import { z } from 'zod' +import type { + AgentJournalRenderItem, + AgentJournalSubmission +} from '../../../../shared/agent-session-journal-types' +import { isAdmissibleAgentJournalRenderItem } from '../../../../shared/agent-session-journal-schemas' +import { isAdmissibleAgentJournalSubmission } from '../../../../shared/agent-session-journal-submission-schema' + +type CapturedJournal = { items: AgentJournalRenderItem[]; submissions: AgentJournalSubmission[] } + +async function capturedCompaction( + transform: (journal: CapturedJournal) => CapturedJournal = (journal) => journal +) { + const text = await readFile( + new URL('../../../../main/acp/fixtures/omp-v17-compact-skip.render.jsonl', import.meta.url), + 'utf8' + ) + const captured = z + .object({ + items: z.array(z.custom(isAdmissibleAgentJournalRenderItem)), + submissions: z.array(z.custom(isAdmissibleAgentJournalSubmission)) + }) + .parse(JSON.parse(text)) + const journal = transform(captured) + const messages = projectStructuredAgentSessionMessages(journal.items, [], journal.submissions) + const membership = nativeChatTurnMembership(messages, journal) + const turnStatuses = selectNativeChatTurnStatuses( + {}, + { + activeTurnKey: membership.liveTurnKey ?? '', + isWorking: false, + thinking: false, + settledByTurn: selectStructuredAgentSettledTurns(journal.items, journal.submissions) + } + ) + return { messages, membership, turnStatuses } +} + +function slotsFor({ + messages, + membership, + turnStatuses +}: Awaited>) { + return buildNativeChatTranscriptSlots({ + messages, + ...membership, + receipts: new Map(), + turnStatuses, + turnDiffs: new Map(), + expandedTurnKeys: new Set(), + isWorking: false, + lifecycleWorking: false + }) +} + +describe('captured OMP compaction visibility', () => { + it('keeps the saved skipped outcome visible after the command settles and reloads', async () => { + const { messages, membership, turnStatuses } = await capturedCompaction() + const command = messages.find((message) => message.command?.name === 'compact') + const warning = messages.find((message) => message.role === 'system') + expect(command).toBeDefined() + expect(warning?.blocks).toEqual([ + { + type: 'text', + tone: 'warning', + text: 'Nothing to compact (session too small)', + presentation: 'compaction-skipped' + } + ]) + expect(membership.turnKeys).toEqual([command?.id, command?.id]) + expect(turnStatuses.completedByTurn[command?.id ?? '']).toMatchObject({ + workedSeconds: 0, + verdict: 'success' + }) + const slots = slotsFor({ messages, membership, turnStatuses }) + expect( + slots.find((slot) => slot.kind === 'message' && slot.message.id === warning?.id) + ).toMatchObject({ drawsMessage: true, folded: false, turnFolds: false }) + }) + + it('keeps the saved warning visible when paging omits its command opener and submission', async () => { + const captured = await capturedCompaction((journal) => ({ + items: journal.items.filter((item) => item.body.kind !== 'message'), + submissions: [] + })) + expect(captured.messages.some((message) => message.command)).toBe(false) + expect(captured.membership.turnKeys).toEqual(['orca:command-turn%3Acompact-1']) + expect(slotsFor(captured)[0]).toMatchObject({ + drawsMessage: true, + folded: false, + statusAbove: true, + turnFolds: false + }) + }) + + it('keeps a newly marked outcome visible without an opener or a legacy identity', async () => { + const captured = await capturedCompaction((journal) => ({ + items: journal.items + .filter((item) => item.body.kind !== 'message') + .map((item) => + item.body.kind === 'status' + ? { + ...item, + itemId: 'orca:future-outcome', + body: { ...item.body, presentation: 'compaction-skipped' } + } + : item + ), + submissions: [] + })) + expect(slotsFor(captured)[0]).toMatchObject({ drawsMessage: true, folded: false }) + }) + + it.each([ + 'orca:ordinary-warning', + 'legacy:grok:runtime-session-1:item%3Ap%3Asession-1%3Acompaction%253Acompact%253Acompact-1' + ])('continues folding an ordinary warning beside a compact command: %s', async (itemId) => { + const captured = await capturedCompaction((journal) => ({ + ...journal, + items: journal.items.map((item) => (item.body.kind === 'status' ? { ...item, itemId } : item)) + })) + const slots = slotsFor(captured) + expect(slots.map((slot) => slot.kind === 'message' && slot.message.role)).toEqual(['user']) + expect(slots[0]).toMatchObject({ turnFolds: true }) + }) + + it('preserves an unknown presentation instead of reclassifying its warning', async () => { + const captured = await capturedCompaction((journal) => ({ + ...journal, + items: journal.items.map((item) => + item.body.kind === 'status' + ? { ...item, body: { ...item.body, presentation: 'future-presentation' } } + : item + ) + })) + expect(captured.messages[1]?.blocks[0]).toMatchObject({ presentation: 'future-presentation' }) + expect(slotsFor(captured)).toHaveLength(1) + }) +}) diff --git a/src/renderer/src/components/native-chat/native-chat-transcript-slots.ts b/src/renderer/src/components/native-chat/native-chat-transcript-slots.ts index ebae5d79e213..2b4a748a79de 100644 --- a/src/renderer/src/components/native-chat/native-chat-transcript-slots.ts +++ b/src/renderer/src/components/native-chat/native-chat-transcript-slots.ts @@ -12,6 +12,7 @@ import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' import { AGENT_SESSION_ORCA_STOP_PRESENTATION } from '../../../../shared/agent-session-orca-stop' +import { AGENT_SESSION_COMPACTION_SKIPPED_PRESENTATION } from '../../../../shared/agent-session-compaction' import { isBackgroundTaskBlock, isSubagentGroupBlock, @@ -163,6 +164,7 @@ export function buildNativeChatTranscriptSlots( (block) => block.type === 'text' && (block.presentation === 'compaction' || + block.presentation === AGENT_SESSION_COMPACTION_SKIPPED_PRESENTATION || block.presentation === AGENT_SESSION_ORCA_STOP_PRESENTATION) ) })) diff --git a/src/shared/agent-session-compaction.ts b/src/shared/agent-session-compaction.ts new file mode 100644 index 000000000000..2a33dd82bda5 --- /dev/null +++ b/src/shared/agent-session-compaction.ts @@ -0,0 +1,15 @@ +import { parseAgentJournalItemKey } from './agent-session-journal-item-key' +import { spelledProviderTimelineItemKey } from './provider-timeline-item-key' + +export const AGENT_SESSION_COMPACTION_SKIPPED_PRESENTATION = 'compaction-skipped' + +/** Older OMP outcomes carried their meaning in the host's reserved item identity. */ +export function isAgentSessionLegacyCompactionResult(itemId: string): boolean { + const identity = parseAgentJournalItemKey(itemId) + return ( + identity?.provider === 'legacy' && + identity.agent === 'omp' && + spelledProviderTimelineItemKey(identity.recordId)?.startsWith('compaction%3Acompact%3A') === + true + ) +} diff --git a/src/shared/agent-session-journal-schemas.test.ts b/src/shared/agent-session-journal-schemas.test.ts index 6ff5e56e30c6..25e289f926e8 100644 --- a/src/shared/agent-session-journal-schemas.test.ts +++ b/src/shared/agent-session-journal-schemas.test.ts @@ -320,6 +320,7 @@ describe('optional notice metadata', () => { it.each([ {}, { presentation: 'compaction' }, + { presentation: 'compaction-skipped', tone: 'warning' }, { presentation: 'plan-document' }, { tone: 'warning' }, { tone: 'error' }, diff --git a/src/shared/provider-timeline-item-key.ts b/src/shared/provider-timeline-item-key.ts new file mode 100644 index 000000000000..677ee9637766 --- /dev/null +++ b/src/shared/provider-timeline-item-key.ts @@ -0,0 +1,9 @@ +/** The encoded key inside a provider-keyed journal record, independent of session and thread. */ +export function spelledProviderTimelineItemKey(recordId: string): string | null { + if (!recordId.startsWith('item:p:')) { + return null + } + // Parts are URI-encoded; only separators retain literal colons and slashes. + const scoped = recordId.slice(recordId.lastIndexOf(':') + 1) + return scoped.slice(scoped.lastIndexOf('/') + 1) +} diff --git a/src/shared/structured-agent-session-projection.test.ts b/src/shared/structured-agent-session-projection.test.ts index 0f56b920b32b..fdf7529ac627 100644 --- a/src/shared/structured-agent-session-projection.test.ts +++ b/src/shared/structured-agent-session-projection.test.ts @@ -555,6 +555,7 @@ describe('structured agent session status projection', () => { describe('notice projection for desktop and mobile consumers', () => { it.each([ { presentation: 'compaction' }, + { presentation: 'compaction-skipped', tone: 'warning' }, { presentation: 'plan-document' }, { tone: 'warning' }, { tone: 'error' }, diff --git a/src/shared/structured-agent-session-projection.ts b/src/shared/structured-agent-session-projection.ts index dd5737d1c54e..8c199bd847ba 100644 --- a/src/shared/structured-agent-session-projection.ts +++ b/src/shared/structured-agent-session-projection.ts @@ -133,7 +133,10 @@ function itemBlocks(item: AgentJournalRenderItem): { if (body.kind !== 'status' || body.turnLifecycle) { return null } - return { role: 'system', blocks: [structuredAgentSessionStatusBlock(body, item.turnScope)] } + return { + role: 'system', + blocks: [structuredAgentSessionStatusBlock(body, item.turnScope, item.itemId)] + } } function isAgentJournalMessageSendMode(value: string): value is AgentJournalMessageSendMode { diff --git a/src/shared/structured-agent-session-status-block.ts b/src/shared/structured-agent-session-status-block.ts index 080f3c5de53b..1a7f76ce3d83 100644 --- a/src/shared/structured-agent-session-status-block.ts +++ b/src/shared/structured-agent-session-status-block.ts @@ -2,18 +2,28 @@ import { readAgentSessionFailureFact } from './agent-session-failure' import type { AgentJournalStatusItem, AgentJournalTurnScope } from './agent-session-journal-types' import { readAgentSessionOrcaStop } from './agent-session-orca-stop' import type { NativeChatTextBlock } from './native-chat-types' +import { + AGENT_SESSION_COMPACTION_SKIPPED_PRESENTATION, + isAgentSessionLegacyCompactionResult +} from './agent-session-compaction' /** A status row as the line a chat paints: named fields only, so a host-only key never leaks. */ export function structuredAgentSessionStatusBlock( body: AgentJournalStatusItem, - turnScope?: AgentJournalTurnScope + turnScope?: AgentJournalTurnScope, + itemId?: string ): NativeChatTextBlock { const failure = readAgentSessionFailureFact(body.failure) const orcaStop = readAgentSessionOrcaStop(body.orcaStop) + const presentation = + body.presentation ?? + (body.tone === 'warning' && itemId !== undefined && isAgentSessionLegacyCompactionResult(itemId) + ? AGENT_SESSION_COMPACTION_SKIPPED_PRESENTATION + : undefined) return { type: 'text', text: body.text, - ...(body.presentation !== undefined ? { presentation: body.presentation } : {}), + ...(presentation !== undefined ? { presentation } : {}), ...(body.tone !== undefined ? { tone: body.tone } : {}), ...(body.providerFrame ? { providerFrame: body.providerFrame } : {}), ...(failure ? { failure } : {}), From 059ccbde00954371726ac5a22edb19de95338c3a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 8 Oct 2026 21:48:55 -0700 Subject: [PATCH 4/5] fix(acp): a signed-out /compact reports not signed in, as a send does A /compact the agent refused for sign-in now records the same notSignedIn fact as an ordinary send, so the chat shows sign-in guidance and the next send starts a new agent that reads the new login. --- src/main/acp/acp-compaction-turn.ts | 13 +++++++-- .../acp/acp-structured-compaction.test.ts | 29 ++++++++++++++++++- src/main/acp/acp-timeline-translator.ts | 1 + 3 files changed, 39 insertions(+), 4 deletions(-) diff --git a/src/main/acp/acp-compaction-turn.ts b/src/main/acp/acp-compaction-turn.ts index 4c7f1d246dd0..90c473058057 100644 --- a/src/main/acp/acp-compaction-turn.ts +++ b/src/main/acp/acp-compaction-turn.ts @@ -2,7 +2,7 @@ // writes meanwhile is the command's output, read for how it went and never drawn; its answer ends // the turn with one result row, as Claude's and Codex's compactions end. -import { providerDiagnostic } from '../../shared/agent-session-failure' +import { agentSessionFailureFact, providerDiagnostic } from '../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../shared/agent-session-failure-words' import { AGENT_SESSION_COMPACTION_SKIPPED_PRESENTATION } from '../../shared/agent-session-compaction' import type { ProviderTimelineEvent } from '../native-chat/agent-session-timeline/provider-timeline-event' @@ -37,6 +37,8 @@ export function acpCompactionEnd(input: { durationMs?: number /** Words the answer itself carried, for one that failed. */ failureDetail?: string + /** The answer said the agent is not signed in. */ + notSignedIn?: boolean dialect: AcpDialect agentName?: string }): ProviderTimelineEvent[] { @@ -81,12 +83,17 @@ export function acpCompactionEnd(input: { if (verdict.outcome === 'cancellation') { return [end('cancellation')] } + // The same fact a signed-out send records, so its guidance shows and the child is replaced. + const failure = + verdict.failure && input.notSignedIn + ? agentSessionFailureFact('notSignedIn', detail ? { detail } : {}) + : verdict.failure const body = - verdict.outcome === 'success' || !verdict.failure + verdict.outcome === 'success' || !failure ? { kind: 'status' as const, text: 'Context compacted', presentation: 'compaction' as const } : { kind: 'status' as const, - ...agentSessionFailureWords(verdict.failure, { + ...agentSessionFailureWords(failure, { surface: 'row', ...(input.agentName === undefined ? {} : { agentName: input.agentName }) }), diff --git a/src/main/acp/acp-structured-compaction.test.ts b/src/main/acp/acp-structured-compaction.test.ts index c2beea5e4c5b..46a1802cdd44 100644 --- a/src/main/acp/acp-structured-compaction.test.ts +++ b/src/main/acp/acp-structured-compaction.test.ts @@ -8,6 +8,7 @@ import { import { structuredAgentSessionCommandTurn } from '../native-chat/agent-session-wire/structured-agent-session-command-turn' import type { StructuredAgentSessionCommandRun } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import { StructuredAgentRegistry } from '../native-chat/agent-session-wire/structured-agent-registry' +import { structuredAgentSessionChildReportedSignedOut } from '../native-chat/agent-session-wire/structured-agent-session-signed-out-child' import { ACP_LAUNCH_SPECS, acpLaunchSpecFor, type AcpLaunchSpec } from './acp-launch-specs' import { acpStructuredAgentDefinition } from './acp-structured-agent-definitions' import { GrokFixtureReplay } from './acp-structured-fixture-replay.test-support' @@ -257,7 +258,8 @@ describe('ACP compaction settlement', () => { it("fails a compaction the agent answered with an error, in the agent's words", async () => { const { rig, command, prompt } = await compact(OPENCODE) - rig.child().agent.fail(prompt, -32000, 'Summarization failed: 500') + // -32000 means signed out to ACP; any other error code is the compaction's own failure. + rig.child().agent.fail(prompt, -32603, 'Summarization failed: 500') const { turn, inside } = await ended(rig, command) expect(turn).toMatchObject({ state: 'completed', outcome: 'failure' }) expect(inside).toEqual([ @@ -271,6 +273,31 @@ describe('ACP compaction settlement', () => { ]) }) + it.each([ + { spec: OPENCODE, code: -32000, data: undefined }, + { spec: OMP, code: -32603, data: { details: 'No model selected.\n\nUse /login to sign in.' } } + ])( + 'says $spec.agent is not signed in, as a send does, so the next one starts a new agent', + async ({ spec, code, data }) => { + const { rig, command, prompt } = await compact(spec) + rig.child().agent.fail(prompt, code, 'Authentication required', data) + const { turn, inside } = await ended(rig, command) + expect(turn).toMatchObject({ state: 'completed', outcome: 'failure' }) + expect(inside).toEqual([ + expect.objectContaining({ + tone: 'error', + failure: expect.objectContaining({ kind: 'notSignedIn' }) + }) + ]) + expect( + structuredAgentSessionChildReportedSignedOut({ + child: { generation: 'generation-1', fence: 1, phase: 'ready' }, + journal: rig.rig.journal + }) + ).toBe(true) + } + ) + it('fails a compaction the agent ended with any other stop reason', async () => { const { rig, command, prompt } = await compact(GROK) rig.child().agent.reply(prompt, { stopReason: 'refusal' }) diff --git a/src/main/acp/acp-timeline-translator.ts b/src/main/acp/acp-timeline-translator.ts index 124c9a6485dc..87941ecb051b 100644 --- a/src/main/acp/acp-timeline-translator.ts +++ b/src/main/acp/acp-timeline-translator.ts @@ -146,6 +146,7 @@ export class AcpTimelineTranslator { at, ...(prompt.durationMs === undefined ? {} : { durationMs: prompt.durationMs }), ...(failureDetail === undefined ? {} : { failureDetail }), + ...(notSignedIn ? { notSignedIn } : {}), dialect: this.dialect, ...(this.options.agentName === undefined ? {} : { agentName: this.options.agentName }) }) From 052f3656f7854a645dcdcb731314f6785b43d61b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 8 Oct 2026 23:31:06 -0700 Subject: [PATCH 5/5] refactor(acp): keep the timeline translator under its line limit after main's growth Main's OMP per-channel message identity grew acp-timeline-translator.ts past max-lines with this PR's compaction handling. The running compaction's frame folding moves beside its ending in acp-compaction-turn.ts, and the compacting-prompt lookup into AcpPromptTurns; behaviour is unchanged. --- src/main/acp/acp-compaction-turn.ts | 27 +++++++++++++ src/main/acp/acp-prompt-turns.ts | 5 +++ src/main/acp/acp-timeline-translator.ts | 54 ++++++++----------------- 3 files changed, 49 insertions(+), 37 deletions(-) diff --git a/src/main/acp/acp-compaction-turn.ts b/src/main/acp/acp-compaction-turn.ts index 90c473058057..3220de9c7bd9 100644 --- a/src/main/acp/acp-compaction-turn.ts +++ b/src/main/acp/acp-compaction-turn.ts @@ -27,6 +27,33 @@ export function readAcpCompactionUpdate(compaction: AcpCompaction, update: Sessi return update.sessionUpdate === 'agent_thought_chunk' } +/** Folds a frame of the running compaction into it: its words and end are read for the result its + * answer writes. True for a frame that is the compaction's own, which draws nothing. */ +export function absorbAcpCompactionFrame( + prompt: { compaction?: AcpCompaction; durationMs?: number }, + extension: + | { failureDetail?: string; end?: { durationMs?: number; failureDetail?: string } } + | undefined, + update: SessionUpdate | undefined +): boolean { + const { compaction } = prompt + if (!compaction) { + return false + } + if (extension?.failureDetail) { + compaction.failureDetail = extension.failureDetail + } + const end = extension?.end + if (end) { + prompt.durationMs = end.durationMs + if (end.failureDetail) { + compaction.failureDetail = end.failureDetail + } + return true + } + return update !== undefined && readAcpCompactionUpdate(compaction, update) +} + /** The compaction's result row and its turn's end, from the agent's answer. */ export function acpCompactionEnd(input: { compaction: AcpCompaction diff --git a/src/main/acp/acp-prompt-turns.ts b/src/main/acp/acp-prompt-turns.ts index 4b9c7f9deeb8..81ef2b347086 100644 --- a/src/main/acp/acp-prompt-turns.ts +++ b/src/main/acp/acp-prompt-turns.ts @@ -45,6 +45,11 @@ export class AcpPromptTurns { } } + /** The running `/compact`, when `turn` is its turn. */ + compacting(turn: string): AcpPromptTurn | undefined { + return this.current?.turn === turn && this.current.compaction ? this.current : undefined + } + finish(): void { this.last = this.current this.current = undefined diff --git a/src/main/acp/acp-timeline-translator.ts b/src/main/acp/acp-timeline-translator.ts index 86a25f95cbdd..0b704a0a1acb 100644 --- a/src/main/acp/acp-timeline-translator.ts +++ b/src/main/acp/acp-timeline-translator.ts @@ -2,11 +2,11 @@ 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 } from './acp-context-usage' -import { acpCompactionEnd, readAcpCompactionUpdate } from './acp-compaction-turn' +import { absorbAcpCompactionFrame, acpCompactionEnd } from './acp-compaction-turn' import { AcpBackgroundTaskTimeline } from './acp-background-task-timeline' import { GENERIC_ACP_DIALECT, type AcpDialect } from './acp-dialects/acp-dialect' import { AcpAgentError } from './acp-errors' -import { acpTurnEnd, AcpPromptTurns, type AcpPromptTurn } from './acp-prompt-turns' +import { acpTurnEnd, AcpPromptTurns } from './acp-prompt-turns' import { readAcpSessionEvent, type AcpSessionEvent } from './acp-session-events' import { translateAcpRequest } from './acp-timeline-requests' import { acpSessionUpdate } from './acp-session-update' @@ -139,21 +139,19 @@ export class AcpTimelineTranslator { return [] } if (prompt.compaction) { - const events = acpCompactionEnd({ + this.end(prompt.turn) + this.prompts.finish() + return acpCompactionEnd({ + ...prompt, compaction: prompt.compaction, - turn: prompt.turn, thread: this.options.sessionId, stopReason, at, - ...(prompt.durationMs === undefined ? {} : { durationMs: prompt.durationMs }), - ...(failureDetail === undefined ? {} : { failureDetail }), - ...(notSignedIn ? { notSignedIn } : {}), + failureDetail, + notSignedIn, dialect: this.dialect, - ...(this.options.agentName === undefined ? {} : { agentName: this.options.agentName }) + agentName: this.options.agentName }) - this.end(prompt.turn) - this.prompts.finish() - return events } const events = this.start(prompt.turn, at) events.push( @@ -238,32 +236,22 @@ export class AcpTimelineTranslator { } // A running compaction's words and end are read for its result, which its answer writes. Its // turn is the host's, open in the assembler under no provider key, so its rows name none. - const compacting = turn === undefined ? undefined : this.compactingPrompt(turn) - const compaction = compacting?.compaction - const rowTurn = compacting ? undefined : turn - const join = { - thread: this.options.sessionId, - ...(rowTurn === undefined ? {} : { turn: rowTurn }) - } + const compacting = turn === undefined ? undefined : this.prompts.compacting(turn) + const keyed = compacting ? undefined : turn + const join = { thread: this.options.sessionId, ...(keyed === undefined ? {} : { turn: keyed }) } if (extension?.backgroundTasks) { events.push(...this.backgroundTasks.translate(extension.backgroundTasks, join)) } if (extension?.usage) { events.push(...this.context.update(extension.usage, join)) } - if (extension?.failureDetail && compaction) { - compaction.failureDetail = extension.failureDetail - } else if (extension?.failureDetail && turn) { + if (compacting && absorbAcpCompactionFrame(compacting, extension, update)) { + return events + } + if (extension?.failureDetail && turn && !compacting) { events.push(...this.failures.row(turn, extension.failureDetail)) } const end = extension?.end - if (end && compacting && compaction) { - compacting.durationMs = end.durationMs - if (end.failureDetail) { - compaction.failureDetail = end.failureDetail - } - return events - } if (end && turn) { if ( this.prompts.current?.turn === turn && @@ -275,9 +263,6 @@ export class AcpTimelineTranslator { } return events } - if (standard && compaction && readAcpCompactionUpdate(compaction, standard.update)) { - return events - } if (standard) { const messageKey = turn && this.dialect.injectedPromptIdentity @@ -285,7 +270,7 @@ export class AcpTimelineTranslator { : undefined return [ ...events, - ...acpSessionUpdate(standard, rowTurn, at, { + ...acpSessionUpdate(standard, keyed, at, { tools: this.tools, dialect: this.dialect, backgroundTasks: this.backgroundTasks, @@ -329,11 +314,6 @@ export class AcpTimelineTranslator { return events } - private compactingPrompt(turn: string): AcpPromptTurn | undefined { - const prompt = this.prompts.current - return prompt?.turn === turn && prompt.compaction ? prompt : undefined - } - private start(turn: string, at: number): ProviderTimelineEvent[] { if (turn === this.prompts.current?.turn) { const events = this.prompts.start(turn, at)