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 new file mode 100644 index 000000000000..00a9db6ec417 --- /dev/null +++ b/src/main/acp/acp-compaction-turn.ts @@ -0,0 +1,134 @@ +// 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 { 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' +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 } + +/** The parts of a dialect's frame a running compaction reads. */ +export type AcpCompactionFrame = { + failureDetail?: string + end?: { durationMs?: number; 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' +} + +/** 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: AcpCompactionFrame | 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 + turn: string + thread: string + stopReason: string + at: number + 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[] { + 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, + presentation: AGENT_SESSION_COMPACTION_SKIPPED_PRESENTATION + }, + 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')] + } + // 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' || !failure + ? { kind: 'status' as const, text: 'Context compacted', presentation: 'compaction' as const } + : { + kind: 'status' as const, + ...agentSessionFailureWords(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 47fba1f0f86d..2657f10d1b08 100644 --- a/src/main/acp/acp-dialects/acp-dialect.ts +++ b/src/main/acp/acp-dialects/acp-dialect.ts @@ -59,6 +59,9 @@ export type AcpDialectNotification = subagents?: AcpSubagentUpdate[] } +/** 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 = { subagentStop?: { @@ -91,6 +94,8 @@ export type AcpDialect = { authenticationRequired?(error: AcpAgentError): boolean /** 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 d09c455a7292..d696ee98f730 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 @@ -94,8 +94,24 @@ function normalizeToolUpdate(update: ToolCallUpdate): ToolCallUpdate { } } +// 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, authenticationRequired: (error) => { const details = promptErrorDataSchema.safeParse(error.data).data?.details return ( diff --git a/src/main/acp/acp-launch-specs.ts b/src/main/acp/acp-launch-specs.ts index 2cceeb216d64..4bab75d6de0f 100644 --- a/src/main/acp/acp-launch-specs.ts +++ b/src/main/acp/acp-launch-specs.ts @@ -85,6 +85,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 modelDiscovery: AcpModelDiscovery @@ -121,7 +123,8 @@ const GROK_LAUNCH_SPEC: AcpLaunchSpec = { read: readGrokModelCatalog, listingNamesConfiguredModel: false }, - visualsSkill: loadGrokVisualsSkill + visualsSkill: loadGrokVisualsSkill, + compaction: true } // `opencode acp` on 1.x serves in-process; on 2.x it starts a private `opencode serve --stdio` child @@ -158,7 +161,8 @@ const OPENCODE_LAUNCH_SPEC: AcpLaunchSpec = { // The listing marks no default; a chat started with no pick names it. listingNamesConfiguredModel: false }, - visualsSkill: loadOpenCodeVisualsSkill + visualsSkill: loadOpenCodeVisualsSkill, + compaction: true } // OMP serves ACP through `omp acp`; its environment reaches it as the user set it. @@ -184,7 +188,8 @@ const OMP_LAUNCH_SPEC: AcpLaunchSpec = { kind: 'unavailable', reason: 'omp has no session-free listing that matches what its chats offer' }, - visualsSkill: loadOmpVisualsSkill + visualsSkill: loadOmpVisualsSkill, + 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..f40ec4eff17a 100644 --- a/src/main/acp/acp-prompt-turns.ts +++ b/src/main/acp/acp-prompt-turns.ts @@ -1,4 +1,12 @@ import type { ProviderTimelineEvent } from '../native-chat/agent-session-timeline/provider-timeline-event' +import { + absorbAcpCompactionFrame, + acpCompactionEnd, + type AcpCompaction, + type AcpCompactionFrame +} from './acp-compaction-turn' +import type { AcpDialect } from './acp-dialects/acp-dialect' +import type { SessionUpdate } from './generated/acp-protocol.generated' export type AcpPromptTurn = { clientMessageId: string @@ -6,6 +14,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. */ @@ -16,16 +26,70 @@ export class AcpPromptTurns { constructor( private readonly sessionId: string, - private readonly injected: boolean + private readonly dialect: AcpDialect, + private readonly agentName?: string ) {} - open(clientMessageId: string, at: number): { promptId: string; events: ProviderTimelineEvent[] } { + /** `compactionTurn` runs a `/compact` as the command turn the host already opened, which its + * frames join and its answer ends. */ + open( + clientMessageId: string, + at: number, + compactionTurn?: string + ): { promptId: string; events: ProviderTimelineEvent[] } { if (this.current) { throw new Error('ACP prompt overlaps a prompt or load') } + if (compactionTurn !== undefined) { + this.current = { + clientMessageId, + turn: compactionTurn, + requestedAt: at, + opened: true, + compaction: { reply: '' } + } + return { promptId: compactionTurn, events: [] } + } const turn = `prompt:${clientMessageId}` this.current = { clientMessageId, turn, requestedAt: at, opened: false } - return { promptId: turn, events: this.injected ? [] : this.start(turn, at) } + const injected = this.dialect.injectedPromptIdentity === true + return { promptId: turn, events: injected ? [] : this.start(turn, at) } + } + + /** The running `/compact`, when `turn` is its turn. */ + compacting(turn: string | undefined): AcpPromptTurn | undefined { + return turn !== undefined && this.current?.turn === turn && this.current.compaction + ? this.current + : undefined + } + + /** True for a frame the running compaction `turn` read as its own. */ + absorbCompaction( + turn: string | undefined, + extension: AcpCompactionFrame | undefined, + update: SessionUpdate | undefined + ): boolean { + const prompt = this.compacting(turn) + return prompt !== undefined && absorbAcpCompactionFrame(prompt, extension, update) + } + + /** The running compaction's result row and end, when `turn` is its turn. */ + endCompaction( + turn: string, + ending: { stopReason: string; at: number; failureDetail?: string; notSignedIn?: boolean } + ): ProviderTimelineEvent[] | undefined { + const prompt = this.compacting(turn) + if (!prompt?.compaction) { + return undefined + } + return acpCompactionEnd({ + ...prompt, + ...ending, + compaction: prompt.compaction, + thread: this.sessionId, + dialect: this.dialect, + agentName: this.agentName + }) } finish(): void { diff --git a/src/main/acp/acp-session-update.ts b/src/main/acp/acp-session-update.ts index 68429ea1356f..b9d45a7245b0 100644 --- a/src/main/acp/acp-session-update.ts +++ b/src/main/acp/acp-session-update.ts @@ -11,6 +11,16 @@ import type { AcpToolTimeline } from './acp-tool-timeline' import type { SessionNotification } from './generated/acp-protocol.generated' import { acpNamedTextKey } from './acp-turn-messages' +/** Updates that open the turn they belong to. */ +export const ACP_SUBSTANTIVE_UPDATES = [ + 'user_message_chunk', + 'agent_message_chunk', + 'agent_thought_chunk', + 'tool_call', + 'tool_call_update', + 'plan' +] + export function acpSessionUpdate( notification: SessionNotification, turn: string | undefined, diff --git a/src/main/acp/acp-structured-agent-definitions.ts b/src/main/acp/acp-structured-agent-definitions.ts index e42604f78a05..1bdf6cb08dd0 100644 --- a/src/main/acp/acp-structured-agent-definitions.ts +++ b/src/main/acp/acp-structured-agent-definitions.ts @@ -24,9 +24,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..46a1802cdd44 --- /dev/null +++ b/src/main/acp/acp-structured-compaction.test.ts @@ -0,0 +1,345 @@ +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 { 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' +import { readAcpFixture, type AcpFixtureFrame } from './acp-timeline-fixture.test-support' +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, frames?: AcpFixtureFrame[]) { + const rig = await openAcpAdapterRig({ spec }) + await rig.acquire() + if (frames) { + new GrokFixtureReplay(frames).attach(rig.child().agent) + } + 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.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() + 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, presentation: 'compaction-skipped' } + ]) + }) + + 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) + // -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([ + expect.objectContaining({ + tone: 'error', + failure: expect.objectContaining({ + kind: 'compactionFailed', + detail: { text: 'Summarization failed: 500', audience: 'person' } + }) + }) + ]) + }) + + 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' }) + 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 0fe2c6295f8b..6dac885d6ea0 100644 --- a/src/main/acp/acp-structured-lane.ts +++ b/src/main/acp/acp-structured-lane.ts @@ -10,6 +10,7 @@ import { import type { AcpTimelineEvent } from './acp-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' import { acpSubagentChildWork, isAcpSubagentChildWorkEvent } from './acp-subagent-child-work' @@ -80,6 +81,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 AcpTimelineEvent[]): 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 a352c924e2cc..5decdb16c7d4 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' @@ -31,8 +31,7 @@ import { ProviderAcquisitionStarts, type ProviderStartAttempt } from '../provider-process/provider-acquisition-starts' -import type { AcpStructuredConnection } from './acp-structured-connection' -import { waitForAcpExit } from './acp-structured-connection' +import { waitForAcpExit, type AcpStructuredConnection } from './acp-structured-connection' import { AcpConnectionClosedError } from './acp-errors' import { awaitAcpTurnEnd, interruptAcpTurn, windDownAcpTurn } from './acp-structured-stop' import { acpDispatchPrompt } from './acp-prompt-content' @@ -172,6 +171,9 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte return { state: 'admitted' } } + compact: NonNullable = async (input) => + this.live(input.sessionId).turns.compact(input.command) + 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 @@ -243,8 +245,7 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte closeSession = (sessionId: string): Promise => this.close(sessionId) disposeSession = (sessionId: string): Promise => this.close(sessionId) - releaseAcquisition = (input: { sessionId: string }): Promise => - this.close(input.sessionId) + releaseAcquisition = (input: { sessionId: string }) => this.close(input.sessionId) /** After a sink failure: the exit is recovered as unexpected. */ forceCloseSession = (sessionId: string): Promise => this.stop(sessionId, false) diff --git a/src/main/acp/acp-structured-turns.ts b/src/main/acp/acp-structured-turns.ts index b3161cbe46ab..90e5d6b0c749 100644 --- a/src/main/acp/acp-structured-turns.ts +++ b/src/main/acp/acp-structured-turns.ts @@ -18,6 +18,11 @@ import { type AgentJournalDispatchRejection } from '../../shared/agent-session-failure-words' import type { AgentJournalItemIdentity } from '../../shared/agent-session-journal-types' +import type { + AgentSessionCommandAdmission, + 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 +114,35 @@ 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. The prompt's write is the + * receipt. */ + async compact(command: StructuredAgentSessionCommandRun): Promise { + 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.openPrompt( + send.clientMessageId, + send.requestedAt, + command.turnId + ) + this.active = send + this.run(send, opened.promptId) + } catch (error) { + lane.forgetCommand(command.turnId) + throw error + } + return { state: 'accepted', providerIdentity: null } + } + /** 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 +181,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 b0f5e8804dc0..fbfdfba9dfb0 100644 --- a/src/main/acp/acp-timeline-translator.ts +++ b/src/main/acp/acp-timeline-translator.ts @@ -8,7 +8,7 @@ import { AcpAgentError } from './acp-errors' 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' +import { ACP_SUBSTANTIVE_UPDATES, acpSessionUpdate } from './acp-session-update' import { AcpSubagentTimeline } from './acp-subagent-timeline' import { AcpToolTimeline } from './acp-tool-timeline' import { @@ -23,14 +23,6 @@ import type { NativeChatSubagentState } from '../../shared/native-chat-types' export { acpTurnEnd } from './acp-prompt-turns' const requestSessionSchema = z.object({ sessionId: z.string() }) -const SUBSTANTIVE_UPDATES = [ - 'user_message_chunk', - 'agent_message_chunk', - 'agent_thought_chunk', - 'tool_call', - 'tool_call_update', - 'plan' -] export type AcpTimelineTranslatorOptions = { sessionId: string @@ -59,10 +51,7 @@ export class AcpTimelineTranslator { this.dialect = options.dialect ?? GENERIC_ACP_DIALECT 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, - this.dialect.injectedPromptIdentity === true - ) + this.prompts = new AcpPromptTurns(options.sessionId, this.dialect, options.agentName) } dispose(): void { @@ -74,15 +63,17 @@ export class AcpTimelineTranslator { return this.dialect.injectedPromptIdentity === true } - /** The host injects promptId as session/prompt._meta.promptId (and requestId). */ + /** The host injects promptId as session/prompt._meta.promptId (and requestId). A `/compact` runs + * as `compactionTurn`, the command turn the host opened. */ openPrompt( clientMessageId: string, - at: number + at: number, + compactionTurn?: string ): { promptId: string; events: ProviderTimelineEvent[] } { if (this.loading) { throw new Error('ACP prompt overlaps a prompt or load') } - return this.prompts.open(clientMessageId, at) + return this.prompts.open(clientMessageId, at, compactionTurn) } promptResult( @@ -207,13 +198,15 @@ export class AcpTimelineTranslator { } const providerTurn = extension?.turn const opens = - (update !== undefined && SUBSTANTIVE_UPDATES.includes(update.sessionUpdate)) || + (update !== undefined && ACP_SUBSTANTIVE_UPDATES.includes(update.sessionUpdate)) || extension?.end !== undefined || extension?.started === true const offeredTurn = providerTurn ?? (this.prompts.current?.opened ? this.prompts.current.turn : this.activeTurn) const owner = update ? this.messages.owner(offeredTurn, update) : { turn: offeredTurn } - const turn = owner.turn + // A running compaction's turn is the host's, open in the assembler under no provider key, so + // its frames name none; its words and end are read for the result its answer writes. + const turn = this.prompts.compacting(owner.turn) ? undefined : owner.turn if ( owner.settled && (update?.sessionUpdate === 'agent_message_chunk' || @@ -245,6 +238,9 @@ export class AcpTimelineTranslator { if (extension?.usage) { events.push(...this.context.update(extension.usage, join)) } + if (this.prompts.absorbCompaction(owner.turn, extension, update)) { + return events + } if (extension?.failureDetail && turn) { events.push(...this.failures.row(turn, extension.failureDetail)) } @@ -303,8 +299,11 @@ export class AcpTimelineTranslator { failureDetail: string | undefined, notSignedIn = false ): ProviderTimelineEvent[] { - const events = this.failures.ended(turn, stopReason, failureDetail, notSignedIn) - events.push(acpTurnEnd(turn, stopReason, at, durationMs)) + const ending = { stopReason, at, failureDetail, notSignedIn } + const events = this.prompts.endCompaction(turn, ending) ?? [ + ...this.failures.ended(turn, stopReason, failureDetail, notSignedIn), + acpTurnEnd(turn, stopReason, at, durationMs) + ] this.end(turn) if (this.prompts.current?.turn === turn) { this.prompts.finish() 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/main/runtime/structured-agent-runtime-registrations-acp.test.ts b/src/main/runtime/structured-agent-runtime-registrations-acp.test.ts index c3892d5e6556..809b99aaeea3 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, diff --git a/src/renderer/src/components/native-chat/NativeChatNoticeRow.test.tsx b/src/renderer/src/components/native-chat/NativeChatNoticeRow.test.tsx index d412b58f18a3..7e9a627101f7 100644 --- a/src/renderer/src/components/native-chat/NativeChatNoticeRow.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatNoticeRow.test.tsx @@ -18,6 +18,17 @@ afterEach(async () => { await i18n.changeLanguage('en') }) +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 d5b08c833665..610b08e91670 100644 --- a/src/shared/structured-agent-session-projection.ts +++ b/src/shared/structured-agent-session-projection.ts @@ -134,7 +134,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 222420d217f4..2febb6af6872 100644 --- a/src/shared/structured-agent-session-status-block.ts +++ b/src/shared/structured-agent-session-status-block.ts @@ -2,18 +2,28 @@ import { readWholeAgentSessionFailureFact } 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 = readWholeAgentSessionFailureFact(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.contextClear !== undefined ? { contextClear: body.contextClear } : {}), ...(body.tone !== undefined ? { tone: body.tone } : {}), ...(body.providerFrame ? { providerFrame: body.providerFrame } : {}),