From a5882855eff47e12caa4726ebe714c9e1e8ac5df Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 16 Sep 2026 16:34:55 -0700 Subject: [PATCH] fix(native-chat): correlate Claude compaction terminals --- .../claude-structured-compaction.test.ts | 44 +++++++++++++++++++ .../claude/claude-structured-compaction.ts | 9 ++++ src/main/claude/claude-structured-dispatch.ts | 8 +++- .../structured-session-compaction.ts | 38 ++++++++++++++++ 4 files changed, 97 insertions(+), 2 deletions(-) diff --git a/src/main/claude/claude-structured-compaction.test.ts b/src/main/claude/claude-structured-compaction.test.ts index 5255ce37ea1..ebb8b4026f1 100644 --- a/src/main/claude/claude-structured-compaction.test.ts +++ b/src/main/claude/claude-structured-compaction.test.ts @@ -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) + }) }) diff --git a/src/main/claude/claude-structured-compaction.ts b/src/main/claude/claude-structured-compaction.ts index 6c3155739c2..046ccdd4fb3 100644 --- a/src/main/claude/claude-structured-compaction.ts +++ b/src/main/claude/claude-structured-compaction.ts @@ -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>[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') { diff --git a/src/main/claude/claude-structured-dispatch.ts b/src/main/claude/claude-structured-dispatch.ts index a69508ce071..e5a47705134 100644 --- a/src/main/claude/claude-structured-dispatch.ts +++ b/src/main/claude/claude-structured-dispatch.ts @@ -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 { 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, diff --git a/src/main/native-chat/agent-session-wire/structured-session-compaction.ts b/src/main/native-chat/agent-session-wire/structured-session-compaction.ts index 81efc4473b4..715239707f4 100644 --- a/src/main/native-chat/agent-session-wire/structured-session-compaction.ts +++ b/src/main/native-chat/agent-session-wire/structured-session-compaction.ts @@ -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'))