feat(acp): compact Grok, OpenCode and OMP chats (#26502)

* feat(acp): compact Grok, OpenCode and OMP chats

* test(acp): expect Grok compaction capability

* fix(chat): keep skipped compaction outcomes visible across history pages

* 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.

* 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.
This commit is contained in:
Brennan Benson
2026-10-09 02:05:31 -07:00
committed by GitHub
parent e43997c015
commit f16212a29c
33 changed files with 1100 additions and 50 deletions
@@ -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 })
})
@@ -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)
})
+134
View File
@@ -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)]
}
+5
View File
@@ -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 = {}
+17 -1
View File
@@ -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 (
+8 -3
View File
@@ -85,6 +85,8 @@ export type AcpLaunchSpec = {
installDirectories(input: { env: Readonly<Record<string, string>>; 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[] = [
+67 -3
View File
@@ -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 {
+10
View File
@@ -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,
@@ -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.
@@ -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<StructuredAgentSessionCommandRun> {
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' }
})
})
})
+13
View File
@@ -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
@@ -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<StructuredAgentSessionAdapter['compact']> = 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<boolean> => this.close(sessionId)
disposeSession = (sessionId: string): Promise<boolean> => this.close(sessionId)
releaseAcquisition = (input: { sessionId: string }): Promise<boolean> =>
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<boolean> => this.stop(sessionId, false)
+40 -1
View File
@@ -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<AgentSessionCommandAdmission> {
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) {
+19 -20
View File
@@ -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()
@@ -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}
@@ -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"}}}
@@ -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}]}
@@ -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"}}}
@@ -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"}}}
@@ -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"}}}
@@ -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"}}}
@@ -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"}}}
@@ -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. */
@@ -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,
@@ -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
@@ -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<AgentJournalRenderItem>(isAdmissibleAgentJournalRenderItem)),
submissions: z.array(z.custom<AgentJournalSubmission>(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<ReturnType<typeof capturedCompaction>>) {
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)
})
})
@@ -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)
)
}))
+15
View File
@@ -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
)
}
@@ -320,6 +320,7 @@ describe('optional notice metadata', () => {
it.each([
{},
{ presentation: 'compaction' },
{ presentation: 'compaction-skipped', tone: 'warning' },
{ presentation: 'plan-document' },
{ tone: 'warning' },
{ tone: 'error' },
+9
View File
@@ -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)
}
@@ -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' },
@@ -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 {
@@ -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 } : {}),