fix(native-chat): let the provider reopen a Claude turn it resumed itself

A Claude turn could only be opened by Orca's own send echo, while any
`result` frame closed it. The provider resumes work on its own — a
background task reports in and wakes the agent after `result` settled the
turn — and nothing Orca sent ever arrives to reopen one, so the session
projected `idle` for the rest of the work. The model's own output is the
evidence a turn is running, the way Codex's `turn/start` is, so it opens
one; whichever opened it, the next `result` settles it.

Subagent frames still open nothing: children outlive the turn that spawned
them, and their work is their parent turn's, never a turn of its own.
This commit is contained in:
Brennan Benson
2026-09-13 17:29:47 -07:00
parent 5e70014da8
commit 34a1b0551e
5 changed files with 255 additions and 19 deletions
@@ -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: [
@@ -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)
}
+10 -4
View File
@@ -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 },
+48
View File
@@ -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<string, unknown>
/** 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
}
@@ -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<string, AgentJournalRenderItem>()
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: '<task-notification><task-id>bfnmj08v6</task-id>' }]
}
}
}
}
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')
})
})