diff --git a/src/main/claude/claude-structured-journal-translation.test.ts b/src/main/claude/claude-structured-journal-translation.test.ts index 7a8ecf54344..faf79a7b15d 100644 --- a/src/main/claude/claude-structured-journal-translation.test.ts +++ b/src/main/claude/claude-structured-journal-translation.test.ts @@ -568,7 +568,11 @@ describe('Claude structured journal translation', () => { translator.handle(message('assistant', 'assistant-thinking', [{ type: 'thinking', thinking }])) - expect(state.items.at(-1)?.body).toEqual({ + // The frame also opens the turn it produced in, so pick the reasoning row itself. + const reasoning = state.items.find( + (item) => item.body.kind === 'message' && item.body.role === 'reasoning' + ) + expect(reasoning?.body).toEqual({ kind: 'message', role: 'reasoning', blocks: [ diff --git a/src/main/claude/claude-structured-journal-translation.ts b/src/main/claude/claude-structured-journal-translation.ts index 8b71149cba2..9e5bfc499b4 100644 --- a/src/main/claude/claude-structured-journal-translation.ts +++ b/src/main/claude/claude-structured-journal-translation.ts @@ -10,7 +10,6 @@ import type { ClaudeStructuredSessionEvent } from './claude-structured-session-s import { claudeMessageBody, claudeMessageIdentity, - claudeHasReplayContent, claudeOutputEnvelope, claudeStreamingMessageBody, claudeThinkingIdentity, @@ -39,6 +38,7 @@ import { import { ClaudeSubagentRoster } from './claude-subagent-roster' import { createClaudeStreamedBlockRegistry } from './claude-streamed-block-identity' import { createClaudeStreamedTextCheckpoints } from './claude-streamed-text-checkpoints' +import { claudeTurnOpenedByFrame } from './claude-turn-opening' import { claudeTurnEndForResult, claudeTurnLifecycleItem, @@ -189,25 +189,24 @@ export function createClaudeJournalTranslator( changed = true } changed = appendUnmodeledClaudeContent(providerFallback, outputEnvelope, message) || changed - if ( - envelope.role === 'user' && - startsTurn && - claudeHasReplayContent(envelope) && - message.parent_tool_use_id === null - ) { + const opened = claudeTurnOpenedByFrame({ + envelope, + frame: message, + startsTurn, + producedContent: changed, + hasOpenTurn: currentTurn !== null, + observedAt, + // A user echo lands on its own message identity, so this is the user row's key. + userItemId: agentJournalItemKey(identity) + }) + if (opened) { if (currentTurn) { // A new turn starting is the only end the previous one gets when its // result never arrives; settling it later would sweep THIS turn. subagents.settleTurn(groupKeyOf(currentTurn)) publishLifecycle(currentTurn, { state: 'interrupted', completedAt: observedAt }) } - currentTurn = { - sessionId: envelope.sessionId, - turnId: envelope.uuid, - startedAt: observedAt, - // A user echo lands on its own message identity, so this is the user row's key. - userItemId: agentJournalItemKey(identity) - } + currentTurn = opened publishLifecycle(currentTurn) deps.sink.setActivity?.(null) } diff --git a/src/main/claude/claude-turn-lifecycle-item.ts b/src/main/claude/claude-turn-lifecycle-item.ts index 00d7c4dd65e..ac679a5b47f 100644 --- a/src/main/claude/claude-turn-lifecycle-item.ts +++ b/src/main/claude/claude-turn-lifecycle-item.ts @@ -10,8 +10,9 @@ export type ClaudeCurrentTurn = { sessionId: string turnId: string startedAt: number - /** Provider key of the user echo that opened the turn. */ - userItemId: string + /** Provider key of the user echo that opened the turn. Absent when the + * provider resumed the work itself and there is no user row to anchor to. */ + userItemId?: string } export type ClaudeTurnEnd = { @@ -71,10 +72,15 @@ export function claudeTurnLifecycleItem( state: end.state, startedAt, completedAt: end.completedAt, - userItemId, + ...(userItemId === undefined ? {} : { userItemId }), ...(end.durationMs === undefined ? {} : { durationMs: end.durationMs }) } - : { turnId, state: 'running', startedAt, userItemId } + : { + turnId, + state: 'running', + startedAt, + ...(userItemId === undefined ? {} : { userItemId }) + } ), // The running row's ts is the turn start itself, so clients read no append lag. options: end ? {} : { observedAt: startedAt }, diff --git a/src/main/claude/claude-turn-opening.ts b/src/main/claude/claude-turn-opening.ts new file mode 100644 index 00000000000..bafaa8edb4a --- /dev/null +++ b/src/main/claude/claude-turn-opening.ts @@ -0,0 +1,48 @@ +// Which provider frame opens a Claude turn. +// +// Orca's own send echo used to be the only opener, while any `result` frame +// closed the turn. That asymmetry is what leaves a working session reading +// idle: the provider resumes on its own — a background task reports in and +// wakes the agent after a `result` settled the turn — and nothing Orca sent +// ever arrives to reopen one. The model's own output is the evidence that a +// turn is running, the way Codex's `turn/start` is, so it opens one here. +// Whichever opened it, the next `result` settles it. + +import { + claudeHasReplayContent, + type ClaudeMessageEnvelope +} from './claude-structured-item-translation' +import type { ClaudeCurrentTurn } from './claude-turn-lifecycle-item' + +export type ClaudeTurnOpeningInput = { + envelope: ClaudeMessageEnvelope + /** The raw frame: the turn boundary reads `parent_tool_use_id` off it, and an + * absent field is not the same claim as an explicit `null`. */ + frame: Record + /** Orca dispatched this send and the provider is replaying it back. */ + startsTurn: boolean + /** The frame appended journal content, so the provider produced just now. */ + producedContent: boolean + hasOpenTurn: boolean + observedAt: number + /** Provider key of the user row, used only by the send echo. */ + userItemId: string +} + +export function claudeTurnOpenedByFrame(input: ClaudeTurnOpeningInput): ClaudeCurrentTurn | null { + // A subagent's frames are its parent turn's work, never a turn of their own. + if (input.frame.parent_tool_use_id !== null) { + return null + } + const { envelope, observedAt } = input + const turn = { sessionId: envelope.sessionId, turnId: envelope.uuid, startedAt: observedAt } + if (envelope.role === 'user') { + return input.startsTurn && claudeHasReplayContent(envelope) + ? { ...turn, userItemId: input.userItemId } + : null + } + // Resumed work has no user row to anchor to. Reopening only when no turn is + // open keeps every frame of one reply inside the turn its first frame opened, + // and keeps this off the path of a turn Orca is already tracking. + return !input.hasOpenTurn && input.producedContent ? turn : null +} diff --git a/src/main/claude/claude-turn-resumption.test.ts b/src/main/claude/claude-turn-resumption.test.ts new file mode 100644 index 00000000000..5a94ac46cd9 --- /dev/null +++ b/src/main/claude/claude-turn-resumption.test.ts @@ -0,0 +1,179 @@ +// Regression for a structured Claude session that reported idle while it was +// working. Reproduced from the journal of the reported session +// (962e6f25…/epoch 3d214e6f…, 2026-09-13): a `result` settled the turn at +// 13:56:06, a background task reported in at 13:58:59, and the agent then ran +// tool calls until 14:05:18 — nine minutes in which the shared projector, and +// so the sidebar row and the chat indicator, read `idle`. + +import { describe, expect, it, vi } from 'vitest' +import type { + AgentJournalItemBody, + AgentJournalItemIdentity, + AgentJournalRenderItem +} from '../../shared/agent-session-journal-types' +import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' +import { + hasUnansweredStructuredAgentSessionDispatch, + projectStructuredAgentSessionStatus +} from '../../shared/structured-agent-session-projection' +import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { createClaudeJournalTranslator } from './claude-structured-journal-translation' + +const SESSION = 'claude-session' + +function harness() { + const appended: { identity: AgentJournalItemIdentity; body: AgentJournalItemBody }[] = [] + const sink: StructuredAgentSessionEventSink = { + appendItem: (identity, body) => appended.push({ identity, body }), + appendTombstone: () => {}, + publish: vi.fn() + } + const translator = createClaudeJournalTranslator({ sink, fallbackIdPrefix: 'test' }) + // The reducer keys items by identity and orders them by first append, so the + // render list the projector reads is the deduplicated append order. + const items = (): AgentJournalRenderItem[] => { + const byKey = new Map() + appended.forEach(({ identity, body }, index) => { + const key = agentJournalItemKey(identity) + const existing = byKey.get(key) + byKey.set(key, { + itemId: key, + revision: (existing?.revision ?? 0) + 1, + body, + sequence: existing?.sequence ?? index, + observedAt: index + }) + }) + return [...byKey.values()].sort((a, b) => a.sequence - b.sequence) + } + return { translator, items } +} + +function frame( + type: 'assistant' | 'user', + uuid: string, + content: unknown[], + parentToolUseId: string | null = null +) { + return { + type: 'message' as const, + sessionId: 'orca-session', + ...(type === 'user' && parentToolUseId === null ? { startsTurn: true as const } : {}), + message: { + type, + uuid, + session_id: SESSION, + parent_tool_use_id: parentToolUseId, + message: { role: type, content } + } + } +} + +/** The captured `task-notification` wake-up: a main-thread user frame Orca never + * dispatched, so it carries no replay waiter and cannot start a turn. */ +function taskNotification(uuid: string) { + return { + type: 'message' as const, + sessionId: 'orca-session', + message: { + type: 'user', + uuid, + session_id: SESSION, + parent_tool_use_id: null, + message: { + role: 'user', + content: [{ type: 'text', text: 'bfnmj08v6' }] + } + } + } +} + +function result(uuid: string) { + return { + type: 'message' as const, + sessionId: 'orca-session', + message: { + type: 'result', + subtype: 'success', + uuid, + session_id: SESSION, + duration_ms: 322_937 + } + } +} + +function projected(items: readonly AgentJournalRenderItem[]): string { + // No submission is outstanding: the send was acknowledged long ago, which is + // exactly the state in which the reported session fell back to idle. + expect(hasUnansweredStructuredAgentSessionDispatch([], null)).toBe(false) + return projectStructuredAgentSessionStatus(items, [], null) +} + +describe('a Claude turn the provider resumed on its own', () => { + it('reports working while the agent runs tool calls after a result settled the turn', () => { + const { translator, items } = harness() + + translator.handle(frame('user', 'u1', [{ type: 'text', text: 'go' }])) + expect(projected(items())).toBe('working') + + translator.handle(result('r1')) + // The agent really did stop here, so idle is correct. + expect(projected(items())).toBe('idle') + + // A background task reports in and wakes the agent; it starts working again. + translator.handle(taskNotification('n1')) + translator.handle(frame('assistant', 'a1', [{ type: 'text', text: 'Back on it.' }])) + expect(projected(items())).toBe('working') + + translator.handle( + frame('assistant', 'a2', [ + { type: 'tool_use', id: 'toolu_1', name: 'Bash', input: { command: 'rg foo' } } + ]) + ) + expect(projected(items())).toBe('working') + + // The next result settles the turn the provider opened, so nothing over-claims. + translator.handle(result('r2')) + expect(projected(items())).toBe('idle') + }) + + it('gives the resumed turn its own record, with no user row to anchor to', () => { + const { translator, items } = harness() + translator.handle(frame('user', 'u1', [{ type: 'text', text: 'go' }])) + translator.handle(result('r1')) + translator.handle(frame('assistant', 'a1', [{ type: 'text', text: 'Back on it.' }])) + + const turns = items().flatMap((item) => { + const turn = readAgentJournalTurn(item.body) + return turn ? [turn] : [] + }) + expect(turns.map((turn) => turn.state)).toEqual(['completed', 'running']) + expect(turns[1]?.turnId).toBe('a1') + expect(turns[1]?.userItemId).toBeUndefined() + }) + + it('leaves a settled turn settled when only a subagent is still producing', () => { + const { translator, items } = harness() + translator.handle(frame('user', 'u1', [{ type: 'text', text: 'go' }])) + translator.handle(result('r1')) + + // Children outlive the turn that spawned them; their frames are not a turn. + translator.handle( + frame('assistant', 'a1', [{ type: 'text', text: 'child work' }], 'toolu_parent') + ) + expect(projected(items())).toBe('idle') + }) + + it('does not reopen a turn that is already running', () => { + const { translator, items } = harness() + translator.handle(frame('user', 'u1', [{ type: 'text', text: 'go' }])) + translator.handle(frame('assistant', 'a1', [{ type: 'text', text: 'one' }])) + translator.handle(frame('assistant', 'a2', [{ type: 'text', text: 'two' }])) + + const running = items().filter((item) => readAgentJournalTurn(item.body)?.state === 'running') + expect(running).toHaveLength(1) + expect(readAgentJournalTurn(running[0]!.body)?.turnId).toBe('u1') + expect(projected(items())).toBe('working') + }) +})