fix(native-chat): correlate Claude compaction terminals

This commit is contained in:
Brennan Benson
2026-09-16 16:34:55 -07:00
parent 713b68a279
commit a5882855ef
4 changed files with 97 additions and 2 deletions
@@ -63,4 +63,48 @@ describe('Claude compaction transcript content', () => {
await rejection
})
it('retains an interrupted command until its matching Claude lifecycle terminal', async () => {
const session = sessionFor()
session.capabilities = ['msg_lifecycle_v1']
const tracker = new StructuredSessionCompaction()
const pending = compactClaudeSession(session, tracker, {
sessionId: 'orca-session',
fence: 1,
turnId: 'compact:operation-1'
})
const rejected = expect(pending).rejects.toThrow('interrupted')
await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1))
const commandUuid = session.dispatchWaiters[0]!.sentUuid
tracker.claude('orca-session', {
type: 'command_lifecycle',
state: 'started',
command_uuid: commandUuid,
session_id: 'provider-session'
})
tracker.interrupted('orca-session')
await rejected
tracker.claude('orca-session', {
type: 'result',
subtype: 'success',
session_id: 'provider-session'
})
tracker.claude('orca-session', {
type: 'command_lifecycle',
state: 'completed',
command_uuid: 'later-command',
session_id: 'provider-session'
})
expect(tracker.hasPending('orca-session')).toBe(true)
tracker.claude('orca-session', {
type: 'command_lifecycle',
state: 'cancelled',
command_uuid: commandUuid,
session_id: 'provider-session'
})
expect(tracker.hasPending('orca-session')).toBe(false)
})
})
@@ -1,3 +1,4 @@
import { randomUUID } from 'node:crypto'
import type { ClaudeSession, ClaudeStructuredSessionEvent } from './claude-structured-session-state'
import type { StructuredSessionCompaction } from '../native-chat/agent-session-wire/structured-session-compaction'
import { dispatchClaudeTurn } from './claude-structured-dispatch'
@@ -10,11 +11,19 @@ export function compactClaudeSession(
compactions: StructuredSessionCompaction,
input: Parameters<NonNullable<StructuredAgentSessionAdapter['compact']>>[0]
): Promise<{ error?: string }> {
const providerCommandId = randomUUID()
return compactions.run(
input.sessionId,
session.providerSessionId,
async () => {
compactions.bindClaudeCommand(
input.sessionId,
input.turnId,
providerCommandId,
session.capabilities.includes('msg_lifecycle_v1')
)
const result = await dispatchClaudeTurn(session, {
providerMessageUuid: providerCommandId,
body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: '/compact' }] }
})
if (result.state === 'rejected') {
@@ -273,7 +273,11 @@ export function retireClaudeDispatchWaiters(session: ClaudeSession): void {
export async function dispatchClaudeTurn(
session: ClaudeSession,
input: { clientMessageId?: string; body: AgentJournalMessageItem }
input: {
clientMessageId?: string
providerMessageUuid?: string
body: AgentJournalMessageItem
}
): Promise<AgentSessionDispatchOutcome> {
let content: unknown[]
try {
@@ -288,7 +292,7 @@ export async function dispatchClaudeTurn(
// Read the sent content, not the journal blocks: only the mapped trailing prompt decides
// whether Claude runs a command, so the two cannot disagree about which frame settles this.
const acceptsResult = claudeDispatchInvokesSlashCommand(content)
const sentUuid = randomUUID()
const sentUuid = input.providerMessageUuid ?? randomUUID()
const replay = waitForReplay(
session,
acceptsResult,
@@ -2,6 +2,8 @@ type PendingCompaction = {
identity: string
commandTurnId?: string
turnId?: string
claudeLifecycleExpected: boolean
claudeLifecycleObserved: boolean
error?: string
compacted: boolean
interrupted: boolean
@@ -45,6 +47,8 @@ export class StructuredSessionCompaction {
const pending: PendingCompaction = {
identity,
commandTurnId,
claudeLifecycleExpected: false,
claudeLifecycleObserved: false,
compacted: false,
interrupted: false,
finish: (result) => {
@@ -114,6 +118,20 @@ export class StructuredSessionCompaction {
return this.ownsTurn(sessionId, turnId) ? this.pending.get(sessionId)?.turnId : turnId
}
bindClaudeCommand(
sessionId: string,
commandTurnId: string,
providerCommandId: string,
lifecycleExpected: boolean
): void {
const pending = this.pending.get(sessionId)
if (!pending || pending.commandTurnId !== commandTurnId || pending.turnId !== undefined) {
return
}
pending.turnId = providerCommandId
pending.claudeLifecycleExpected = lifecycleExpected
}
ended(sessionId: string): void {
this.pending.get(sessionId)?.finish({ error: 'The provider exited during compaction.' })
}
@@ -152,6 +170,21 @@ export class StructuredSessionCompaction {
if (!pending || message.session_id !== pending.identity) {
return
}
if (message.type === 'command_lifecycle') {
if (message.command_uuid !== pending.turnId) {
return
}
pending.claudeLifecycleObserved = true
if (message.state === 'cancelled') {
pending.finish({ error: 'Compaction was interrupted.' })
} else if (message.state === 'completed') {
const error =
pending.error ??
(pending.compacted ? undefined : 'Compaction was not confirmed by the provider.')
pending.finish(error ? { error } : {})
}
return
}
if (message.compact_result === 'failed') {
pending.error =
typeof message.compact_error === 'string' ? message.compact_error : 'Compaction failed.'
@@ -160,6 +193,11 @@ export class StructuredSessionCompaction {
pending.compacted = true
}
if (message.type === 'result') {
// Lifecycle-capable Claude versions identify the exact queued command. Their result frame
// does not, so wait for the matching command terminal instead of consuming a later turn.
if (pending.claudeLifecycleExpected || pending.claudeLifecycleObserved) {
return
}
if (
message.is_error === true ||
(typeof message.subtype === 'string' && message.subtype.startsWith('error'))