mirror of
https://github.com/stablyai/orca.git
synced 2026-10-28 16:02:55 +00:00
* Replace assistant-prose heuristic with explicit turn lifecycle markers Extract provider-authored turn boundaries (completion, interruption) directly from Claude/Codex transcripts so the chat view knows when work ends without guessing from message presence. Reconciles live hook state with transcript lifecycle: when a terminal boundary lands, it settles a dropped Stop hook instead of letting prose mislead the UI into showing 'working' after done. * fix(review): cover Claude terminal stop_reasons and RPC lifecycle frames Treat max_tokens/stop_sequence/refusal as completed markers so capable hosts do not stay working after a dropped Stop, and assert lifecycle payloads on runtime subscribe/read frames plus mid-turn non-terminal stop_reason cases. * test(native-chat): clarify that lifecycle field is optional Add type assertion and comment documenting that lifecycle field is optional and can be omitted in truncation-gating test fixtures. * fix(native-chat): settle status on interruption despite working subagent When Claude's turn is explicitly interrupted, the session should show ready immediately — even if background subagents are still running. Add an interruption check before consulting the hook's working-subagents flag so interruptions take precedence. Also normalize omitted lifecycle timestamps to null instead of leaving them undefined, and add test coverage for both cases. * fix(native-chat): settle loading spinner on explicit turn boundaries Explicit transcript turn-lifecycle markers now fully replace the prose-fallback settlement path. Remove the now-unused `turnLifecycleCapable` flag and wire lifecycle to suppress spinner even when hook status lingers. Refine Claude lifecycle detection to distinguish terminal stops from mid-turn tool_use rows, exclude harness noise from new-generation detection, and apply clock-skew slack over SSH/relay. Serialize PTY sends per line to prevent rapid prompts from gluing before Enter, clearing unsubmitted input on cancel. Update working suppression to detect epoch rollovers so interrupt+next-turn without a ready gap resets the spinner correctly.
306 lines
8.7 KiB
TypeScript
306 lines
8.7 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
|
import {
|
|
decodeClaudeTurnLifecycle,
|
|
decodeCodexTurnLifecycle,
|
|
nativeChatTurnLifecycleDecoderForAgent
|
|
} from './transcript-turn-lifecycle'
|
|
|
|
describe('native chat transcript turn lifecycle', () => {
|
|
it('exposes a lifecycle decoder only for transcript formats with explicit boundaries', () => {
|
|
expect(nativeChatTurnLifecycleDecoderForAgent('claude')).not.toBeNull()
|
|
expect(nativeChatTurnLifecycleDecoderForAgent('openclaude')).not.toBeNull()
|
|
expect(nativeChatTurnLifecycleDecoderForAgent('codex')).not.toBeNull()
|
|
expect(nativeChatTurnLifecycleDecoderForAgent('grok')).toBeNull()
|
|
})
|
|
|
|
it('decodes Codex task boundaries with the provider turn id', () => {
|
|
expect(
|
|
decodeCodexTurnLifecycle(
|
|
JSON.stringify({
|
|
timestamp: '2026-07-16T23:40:14.001Z',
|
|
type: 'event_msg',
|
|
payload: { type: 'task_started', turn_id: 'turn-1' }
|
|
}),
|
|
'fallback'
|
|
)
|
|
).toEqual({
|
|
state: 'working',
|
|
turnId: 'turn-1',
|
|
timestamp: Date.parse('2026-07-16T23:40:14.001Z')
|
|
})
|
|
|
|
expect(
|
|
decodeCodexTurnLifecycle(
|
|
JSON.stringify({
|
|
timestamp: '2026-07-16T23:45:37.608Z',
|
|
type: 'event_msg',
|
|
payload: { type: 'task_complete', turn_id: 'turn-1' }
|
|
}),
|
|
'fallback'
|
|
)
|
|
).toEqual({
|
|
state: 'completed',
|
|
turnId: 'turn-1',
|
|
timestamp: Date.parse('2026-07-16T23:45:37.608Z')
|
|
})
|
|
|
|
expect(
|
|
decodeCodexTurnLifecycle(
|
|
JSON.stringify({
|
|
timestamp: '2026-07-16T23:46:01.000Z',
|
|
type: 'event_msg',
|
|
payload: { type: 'turn_aborted', reason: 'interrupted', turn_id: 'turn-2' }
|
|
}),
|
|
'fallback'
|
|
)
|
|
).toEqual({
|
|
state: 'interrupted',
|
|
turnId: 'turn-2',
|
|
timestamp: Date.parse('2026-07-16T23:46:01.000Z')
|
|
})
|
|
})
|
|
|
|
it('does not mistake a Codex assistant message for completion', () => {
|
|
expect(
|
|
decodeCodexTurnLifecycle(
|
|
JSON.stringify({
|
|
timestamp: '2026-07-16T23:45:37.472Z',
|
|
type: 'event_msg',
|
|
payload: { type: 'agent_message', message: 'final-looking prose' }
|
|
}),
|
|
'fallback'
|
|
)
|
|
).toBeNull()
|
|
})
|
|
|
|
it('uses Claude terminal stop_reasons and excludes tool-result user rows', () => {
|
|
for (const stopReason of ['end_turn', 'max_tokens', 'stop_sequence', 'refusal'] as const) {
|
|
expect(
|
|
decodeClaudeTurnLifecycle(
|
|
JSON.stringify({
|
|
type: 'assistant',
|
|
uuid: `assistant-${stopReason}`,
|
|
timestamp: '2026-07-16T23:45:37.608Z',
|
|
message: { role: 'assistant', stop_reason: stopReason, content: [] }
|
|
}),
|
|
'fallback'
|
|
)
|
|
).toEqual({
|
|
state: 'completed',
|
|
turnId: `assistant-${stopReason}`,
|
|
timestamp: Date.parse('2026-07-16T23:45:37.608Z')
|
|
})
|
|
}
|
|
|
|
expect(
|
|
decodeClaudeTurnLifecycle(
|
|
JSON.stringify({
|
|
type: 'user',
|
|
uuid: 'tool-result-1',
|
|
timestamp: '2026-07-16T23:45:38.000Z',
|
|
message: {
|
|
role: 'user',
|
|
content: [{ type: 'tool_result', tool_use_id: 'tool-1', content: 'ok' }]
|
|
}
|
|
}),
|
|
'fallback'
|
|
)
|
|
).toBeNull()
|
|
})
|
|
|
|
it('does not treat Claude mid-turn tool_use assistant rows as completed', () => {
|
|
expect(
|
|
decodeClaudeTurnLifecycle(
|
|
JSON.stringify({
|
|
type: 'assistant',
|
|
uuid: 'assistant-tool',
|
|
timestamp: '2026-07-16T23:45:37.608Z',
|
|
message: {
|
|
role: 'assistant',
|
|
stop_reason: 'tool_use',
|
|
content: [{ type: 'tool_use', id: 't1', name: 'Bash', input: {} }]
|
|
}
|
|
}),
|
|
'fallback'
|
|
)
|
|
).toBeNull()
|
|
})
|
|
|
|
it('treats Claude assistant rows with omitted stop_reason and content as completed', () => {
|
|
expect(
|
|
decodeClaudeTurnLifecycle(
|
|
JSON.stringify({
|
|
type: 'assistant',
|
|
uuid: 'assistant-null-stop',
|
|
timestamp: '2026-07-16T23:45:37.608Z',
|
|
message: {
|
|
role: 'assistant',
|
|
stop_reason: null,
|
|
content: [{ type: 'text', text: 'final answer' }]
|
|
}
|
|
}),
|
|
'fallback'
|
|
)
|
|
).toEqual({
|
|
state: 'completed',
|
|
turnId: 'assistant-null-stop',
|
|
timestamp: Date.parse('2026-07-16T23:45:37.608Z')
|
|
})
|
|
|
|
expect(
|
|
decodeClaudeTurnLifecycle(
|
|
JSON.stringify({
|
|
type: 'assistant',
|
|
uuid: 'assistant-missing-stop',
|
|
timestamp: '2026-07-16T23:45:37.608Z',
|
|
message: {
|
|
role: 'assistant',
|
|
content: [{ type: 'text', text: 'final answer' }]
|
|
}
|
|
}),
|
|
'fallback'
|
|
)
|
|
).toEqual({
|
|
state: 'completed',
|
|
turnId: 'assistant-missing-stop',
|
|
timestamp: Date.parse('2026-07-16T23:45:37.608Z')
|
|
})
|
|
})
|
|
|
|
it('does not complete a Claude assistant row that omits stop_reason but carries a tool_use block', () => {
|
|
// A pre-tool row can hold prose AND a tool_use block; without stop_reason the
|
|
// turn is still mid-flight, so it must not settle the spinner before the tool.
|
|
for (const stopReason of [null, undefined] as const) {
|
|
expect(
|
|
decodeClaudeTurnLifecycle(
|
|
JSON.stringify({
|
|
type: 'assistant',
|
|
uuid: `assistant-pretool-${stopReason}`,
|
|
timestamp: '2026-07-16T23:45:37.608Z',
|
|
message: {
|
|
role: 'assistant',
|
|
...(stopReason === null ? { stop_reason: null } : {}),
|
|
content: [
|
|
{ type: 'text', text: 'let me check' },
|
|
{ type: 'tool_use', id: 't1', name: 'Bash', input: {} }
|
|
]
|
|
}
|
|
}),
|
|
'fallback'
|
|
)
|
|
).toBeNull()
|
|
}
|
|
})
|
|
|
|
it('does not complete Claude assistant rows with omitted stop_reason and no content', () => {
|
|
expect(
|
|
decodeClaudeTurnLifecycle(
|
|
JSON.stringify({
|
|
type: 'assistant',
|
|
uuid: 'assistant-empty',
|
|
timestamp: '2026-07-16T23:45:37.608Z',
|
|
message: { role: 'assistant', stop_reason: null, content: [] }
|
|
}),
|
|
'fallback'
|
|
)
|
|
).toBeNull()
|
|
})
|
|
|
|
it('does not treat harness noise user rows as a new working generation', () => {
|
|
expect(
|
|
decodeClaudeTurnLifecycle(
|
|
JSON.stringify({
|
|
type: 'user',
|
|
uuid: 'task-note-1',
|
|
timestamp: '2026-07-16T23:46:10.000Z',
|
|
message: {
|
|
role: 'user',
|
|
content: [
|
|
{
|
|
type: 'text',
|
|
text: '<task-notification>background task finished</task-notification>'
|
|
}
|
|
]
|
|
}
|
|
}),
|
|
'fallback'
|
|
)
|
|
).toBeNull()
|
|
|
|
expect(
|
|
decodeClaudeTurnLifecycle(
|
|
JSON.stringify({
|
|
type: 'user',
|
|
uuid: 'reminder-1',
|
|
timestamp: '2026-07-16T23:46:11.000Z',
|
|
message: {
|
|
role: 'user',
|
|
content: [{ type: 'text', text: '<system-reminder>continue</system-reminder>' }]
|
|
}
|
|
}),
|
|
'fallback'
|
|
)
|
|
).toBeNull()
|
|
})
|
|
|
|
it('excludes Claude tool-result rows that also carry text sidecars', () => {
|
|
expect(
|
|
decodeClaudeTurnLifecycle(
|
|
JSON.stringify({
|
|
type: 'user',
|
|
uuid: 'mixed-tool-result',
|
|
timestamp: '2026-07-16T23:45:38.000Z',
|
|
message: {
|
|
role: 'user',
|
|
content: [
|
|
{ type: 'tool_result', tool_use_id: 'tool-1', content: 'ok' },
|
|
{ type: 'text', text: '<system-reminder>continue</system-reminder>' }
|
|
]
|
|
}
|
|
}),
|
|
'fallback'
|
|
)
|
|
).toBeNull()
|
|
})
|
|
|
|
it('treats a real Claude user row as the next working generation', () => {
|
|
expect(
|
|
decodeClaudeTurnLifecycle(
|
|
JSON.stringify({
|
|
type: 'user',
|
|
uuid: 'user-2',
|
|
timestamp: '2026-07-16T23:46:00.000Z',
|
|
message: { role: 'user', content: 'next task' }
|
|
}),
|
|
'fallback'
|
|
)
|
|
).toEqual({
|
|
state: 'working',
|
|
turnId: 'user-2',
|
|
timestamp: Date.parse('2026-07-16T23:46:00.000Z')
|
|
})
|
|
})
|
|
|
|
it('treats Claude interruptedMessageId as terminal instead of a user generation', () => {
|
|
expect(
|
|
decodeClaudeTurnLifecycle(
|
|
JSON.stringify({
|
|
type: 'user',
|
|
uuid: 'interrupt-row',
|
|
interruptedMessageId: 'assistant-request-1',
|
|
timestamp: '2026-07-16T23:46:01.000Z',
|
|
message: {
|
|
role: 'user',
|
|
content: [{ type: 'text', text: '[Request interrupted by user]' }]
|
|
}
|
|
}),
|
|
'fallback'
|
|
)
|
|
).toEqual({
|
|
state: 'interrupted',
|
|
turnId: 'assistant-request-1',
|
|
timestamp: Date.parse('2026-07-16T23:46:01.000Z')
|
|
})
|
|
})
|
|
})
|