Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
172 changes: 172 additions & 0 deletions config/scripts/native-chat-compaction-pagination.test.mjs
Original file line number Diff line number Diff line change
@@ -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 })
})
33 changes: 33 additions & 0 deletions src/main/acp/acp-compaction-journal-fixture.test.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 134 additions & 0 deletions src/main/acp/acp-compaction-turn.ts
Original file line number Diff line number Diff line change
@@ -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 } : {})
Comment on lines +91 to +120

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🔎 Supported by static analysis

🏁 Script executed:

rg -n 'structuredCompactionOutcome|failureDetail|absorbCompaction' src/main/acp src/shared/agent-session-compaction.ts
sed -n '1,180p' src/shared/agent-session-compaction.ts
sed -n '85,125p' src/main/acp/acp-compaction-turn.ts

Repository: stablyai/orca

Length of output: 5519


🏁 Script executed:

set -o pipefail
printf '%s\n' '--- outcome helper references ---'
rg -n -F --glob '*.ts' -- 'structuredCompactionOutcome' src test tests 2>/dev/null || true
printf '%s\n' '--- outcome helper source ---'
helper=$(rg -l -F --glob '*.ts' -- 'export function structuredCompactionOutcome' src)
printf '%s\n' "$helper"
sed -n '1,240p' "$helper"
printf '%s\n' '--- compaction turn source ---'
sed -n '1,190p' src/main/acp/acp-compaction-turn.ts
printf '%s\n' '--- ACP translator caller ---'
sed -n '90,145p' src/main/acp/acp-timeline-translator.ts
sed -n '215,320p' src/main/acp/acp-timeline-translator.ts
printf '%s\n' '--- prompt owner ---'
sed -n '1,130p' src/main/acp/acp-prompt-turns.ts
printf '%s\n' '--- focused tests ---'
rg -n -i --glob '*.{ts,tsx}' -- 'compaction|failureDetail|Context compacted|Nothing to compact|structuredCompactionOutcome' test tests src 2>/dev/null | head -240

Repository: stablyai/orca

Length of output: 41958


🏁 Script executed:

printf '%s\n' '--- ACP translator frame path ---'
nl -ba src/main/acp/acp-timeline-translator.ts | sed -n '185,275p'
printf '%s\n' '--- ACP compaction settlement tests ---'
nl -ba src/main/acp/acp-structured-compaction.test.ts | sed -n '110,335p'
printf '%s\n' '--- dialect reply contracts and implementations ---'
nl -ba src/main/acp/acp-dialects/acp-dialect.ts | sed -n '45,105p'
rg -n -F --glob '*.ts' -- 'compactionReply' src/main/acp/acp-dialects

Repository: stablyai/orca

Length of output: 17865


Make extension failures override the default end_turn success.

When an ACP extension supplies failureDetail and the prompt ends with end_turn without a parsed reply, acpCompactionEnd passes both compacted: true and failed: { detail }. structuredCompactionOutcome checks compacted first, so the turn reports successful compaction instead of the failure.

Suggested fix
-    compacted: stopReason === 'end_turn' && reply === undefined,
+    compacted: stopReason === 'end_turn' && reply === undefined && detail === undefined,
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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 } : {})
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 && detail === 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 changes: 5 additions & 0 deletions src/main/acp/acp-dialects/acp-dialect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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?: {
Expand Down Expand Up @@ -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 = {}
18 changes: 17 additions & 1 deletion src/main/acp/acp-dialects/omp-dialect.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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 (
Expand Down
Loading
Loading