mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 17:17:38 +00:00
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:
@@ -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)
|
||||
})
|
||||
@@ -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)]
|
||||
}
|
||||
@@ -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 = {}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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[] = [
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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' }
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}))
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
Reference in New Issue
Block a user