mirror of
https://github.com/stablyai/orca.git
synced 2026-09-26 16:02:43 +00:00
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:
@@ -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,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 },
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user