mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 00:03:45 +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.
154 lines
4.1 KiB
TypeScript
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
|
|
}
|