mirror of
https://github.com/stablyai/orca.git
synced 2026-09-24 16:02:41 +00:00
fix(native-chat): correlate Claude compaction terminals
This commit is contained in:
@@ -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'))
|
||||
|
||||
Reference in New Issue
Block a user