Files
orca/src/main/native-chat/transcript-line-decoders-codex.ts
T
Jinjing ba25e4306c Replace assistant-prose heuristic with explicit turn lifecycle markers (#9121)
* 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.
2026-07-17 15:59:38 -07:00

154 lines
4.1 KiB
TypeScript

// Codex JSONL line → NativeChatMessage decoder.
import {
NATIVE_CHAT_INTERRUPTED_STATUS_TEXT,
type NativeChatBlock,
type NativeChatMessage
} from '../../shared/native-chat-types'
import {
asRecord,
extractString,
parseJsonObject,
timestampMs
} from '../ai-vault/session-scanner-values'
import { claudeContentBlocks, toolResultOutput } from './transcript-record-blocks'
import { CODEX_EVENT_TURN_ABORTED } from './transcript-turn-markers'
export function decodeCodexTranscriptLine(
line: string,
fallbackId: string
): NativeChatMessage | null {
const record = parseJsonObject(line)
if (!record) {
return null
}
const payload = asRecord(record.payload)
if (!payload) {
return null
}
const timestamp = parseTimestamp(record.timestamp)
const baseId = extractString(payload.id) ?? fallbackId
if (record.type === 'response_item') {
return codexResponseItem(payload, baseId, timestamp)
}
if (record.type === 'event_msg') {
return codexEventMessage(payload, baseId, timestamp)
}
return null
}
function codexResponseItem(
payload: Record<string, unknown>,
id: string,
timestamp: number | null
): NativeChatMessage | null {
if (payload.type === 'message') {
const blocks = claudeContentBlocks(payload.content)
if (blocks.length === 0) {
return null
}
const role =
payload.role === 'assistant' ? 'assistant' : payload.role === 'user' ? 'user' : 'system'
return { id, role, blocks, timestamp, source: 'transcript' }
}
if (payload.type === 'reasoning') {
const text = extractString(payload.text) ?? codexSummaryText(payload.summary)
if (!text) {
return null
}
return {
id,
role: 'reasoning',
blocks: [{ type: 'text', text }],
timestamp,
source: 'transcript'
}
}
if (payload.type === 'function_call' || payload.type === 'local_shell_call') {
const name = extractString(payload.name) ?? 'tool'
return {
id,
role: 'assistant',
blocks: [{ type: 'tool-call', name, input: codexCallInput(payload) }],
timestamp,
source: 'transcript'
}
}
if (payload.type === 'function_call_output') {
return {
id,
role: 'tool',
blocks: [codexToolResult(payload.output)],
timestamp,
source: 'transcript'
}
}
return null
}
function codexEventMessage(
payload: Record<string, unknown>,
id: string,
timestamp: number | null
): NativeChatMessage | null {
if (payload.type === CODEX_EVENT_TURN_ABORTED) {
return {
id,
role: 'system',
blocks: [{ type: 'text', text: NATIVE_CHAT_INTERRUPTED_STATUS_TEXT }],
timestamp,
source: 'transcript'
}
}
if (payload.type === 'user_message') {
const text = extractString(payload.message)
return text
? { id, role: 'user', blocks: [{ type: 'text', text }], timestamp, source: 'transcript' }
: null
}
if (payload.type === 'agent_message') {
const text = extractString(payload.message)
return text
? { id, role: 'assistant', blocks: [{ type: 'text', text }], timestamp, source: 'transcript' }
: null
}
return null
}
function codexCallInput(payload: Record<string, unknown>): unknown {
if (payload.arguments !== undefined) {
return payload.arguments
}
return payload.input ?? payload.action ?? null
}
function codexToolResult(output: unknown): NativeChatBlock {
const record = asRecord(output)
const isError = record?.success === false || record?.is_error === true
return {
type: 'tool-result',
output: toolResultOutput(record?.content ?? record?.output ?? output),
...(isError ? { isError: true } : {})
}
}
function codexSummaryText(summary: unknown): string | null {
if (!Array.isArray(summary)) {
return null
}
const parts: string[] = []
for (const item of summary) {
const text = extractString(asRecord(item)?.text) ?? extractString(item)
if (text) {
parts.push(text)
}
}
return parts.length ? parts.join('\n') : null
}
function parseTimestamp(value: unknown): number | null {
const parsed = timestampMs(value)
return Number.isFinite(parsed) ? parsed : null
}