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.
This commit is contained in:
Jinjing 2026-07-17 15:59:38 -07:00 committed by GitHub
parent e719ef1a57
commit ba25e4306c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
41 changed files with 2700 additions and 375 deletions

View File

@ -1,4 +1,5 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { NativeChatTurnLifecycle } from '../../shared/native-chat-types'
const { handlers, listeners, subscribeTranscript } = vi.hoisted(() => ({
handlers: new Map<string, (_event: unknown, args?: unknown) => unknown>(),
@ -111,7 +112,8 @@ type InitialSnapshotCallback = (
messages: unknown[],
hasMore: boolean,
beforeOffset: number,
error?: string
error?: string,
lifecycle?: NativeChatTurnLifecycle
) => void
// The onInitialSnapshot callback the handler passed into the Nth subscribeTranscript
@ -275,7 +277,31 @@ describe('nativeChat subscribe lifecycle', () => {
expect(renderer.sender.send).toHaveBeenCalledWith('nativeChat:appended', {
subscriptionId: 'drain-clean',
frame: { type: 'snapshot', messages: [], hasMore: false }
frame: {
type: 'snapshot',
messages: [],
hasMore: false
}
})
})
it('forwards replayable lifecycle on the initial snapshot', () => {
const pending = deferredSubscription()
subscribeTranscript.mockReturnValueOnce(pending.promise)
const renderer = createSender(7)
const lifecycle = { state: 'completed', turnId: 'turn-1', timestamp: 42 } as const
subscribe(renderer.sender, 'lifecycle')
initialSnapshot(0)([], false, 0, undefined, lifecycle)
expect(renderer.sender.send).toHaveBeenCalledWith('nativeChat:appended', {
subscriptionId: 'lifecycle',
frame: {
type: 'snapshot',
messages: [],
hasMore: false,
lifecycle
}
})
})
})

View File

@ -1,5 +1,9 @@
import { ipcMain, type IpcMainEvent, type WebContents } from 'electron'
import type { AgentType, NativeChatMessage } from '../../shared/native-chat-types'
import type {
AgentType,
NativeChatMessage,
NativeChatTurnLifecycle
} from '../../shared/native-chat-types'
import { clearNativeChatTranscriptCache } from '../native-chat/transcript-read-cache'
import type { ReadTranscriptResult } from '../native-chat/transcript-reader'
import {
@ -53,9 +57,24 @@ export type NativeChatSubscribeArgs = {
export type NativeChatAppendedPayload = {
subscriptionId: string
frame:
| { type: 'snapshot'; messages: NativeChatMessage[]; hasMore: boolean; error?: string }
| { type: 'replacement'; messages: NativeChatMessage[]; hasMore: boolean }
| { type: 'appended'; messages: NativeChatMessage[] }
| {
type: 'snapshot'
messages: NativeChatMessage[]
hasMore: boolean
error?: string
lifecycle?: NativeChatTurnLifecycle
}
| {
type: 'replacement'
messages: NativeChatMessage[]
hasMore: boolean
lifecycle?: NativeChatTurnLifecycle
}
| {
type: 'appended'
messages: NativeChatMessage[]
lifecycle?: NativeChatTurnLifecycle
}
}
type LiveSubscription = {
@ -151,7 +170,7 @@ async function handleSubscribe(event: IpcMainEvent, args: NativeChatSubscribeArg
sessionId,
transcriptPath,
initialLimit: limit,
onInitialSnapshot: (messages, hasMore, _beforeOffset, error) => {
onInitialSnapshot: (messages, hasMore, _beforeOffset, error, lifecycle) => {
if (sender.isDestroyed()) {
return
}
@ -159,26 +178,41 @@ async function handleSubscribe(event: IpcMainEvent, args: NativeChatSubscribeArg
// instead of stranding the view at 'loading' when the read keeps throwing.
const payload: NativeChatAppendedPayload = {
subscriptionId,
frame: { type: 'snapshot', messages, hasMore, ...(error ? { error } : {}) }
frame: {
type: 'snapshot',
messages,
hasMore,
...(error ? { error } : {}),
...(lifecycle ? { lifecycle } : {})
}
}
sender.send('nativeChat:appended', payload)
},
onReplace: (messages, hasMore) => {
onReplace: (messages, hasMore, _beforeOffset, lifecycle) => {
if (sender.isDestroyed()) {
return
}
sender.send('nativeChat:appended', {
subscriptionId,
frame: { type: 'replacement', messages, hasMore }
frame: {
type: 'replacement',
messages,
hasMore,
...(lifecycle ? { lifecycle } : {})
}
} satisfies NativeChatAppendedPayload)
},
onAppend: (messages) => {
onAppend: (messages, lifecycle) => {
if (sender.isDestroyed()) {
return
}
const payload: NativeChatAppendedPayload = {
subscriptionId,
frame: { type: 'appended', messages }
frame: {
type: 'appended',
messages,
...(lifecycle ? { lifecycle } : {})
}
}
sender.send('nativeChat:appended', payload)
}

View File

@ -1,5 +1,5 @@
import { open, stat } from 'node:fs/promises'
import type { NativeChatMessage } from '../../shared/native-chat-types'
import type { NativeChatMessage, NativeChatTurnLifecycle } from '../../shared/native-chat-types'
import { transcriptFallbackId } from './transcript-fallback-id'
import {
MAX_NATIVE_CHAT_TRANSCRIPT_RECORD_BYTES,
@ -28,7 +28,9 @@ export async function readIncrementalTranscriptMessages(
filePath: string,
state: IncrementalTranscriptState,
decode: NativeChatLineDecoder,
onBatch?: (messages: NativeChatMessage[]) => void
onBatch?: (messages: NativeChatMessage[]) => void,
decodeLifecycle?: (line: string, fallbackId: string) => NativeChatTurnLifecycle | null,
onLifecycle?: (lifecycle: NativeChatTurnLifecycle) => void
): Promise<NativeChatMessage[]> {
const end = (await stat(filePath)).size
if (end <= state.offset) {
@ -91,7 +93,12 @@ export async function readIncrementalTranscriptMessages(
if (!line) {
return
}
const message = decode(line, transcriptFallbackId(filePath, state.pendingStart))
const fallbackId = transcriptFallbackId(filePath, state.pendingStart)
const lifecycle = decodeLifecycle?.(line, fallbackId)
if (lifecycle) {
onLifecycle?.(lifecycle)
}
const message = decode(line, fallbackId)
if (!message) {
return
}

View File

@ -0,0 +1,52 @@
import { describe, expect, it } from 'vitest'
import {
NATIVE_CHAT_INTERRUPTED_STATUS_TEXT,
type NativeChatMessage
} from '../../shared/native-chat-types'
import { stripNoiseMessages } from '../../shared/native-chat-noise'
import { decodeClaudeTranscriptLine } from './transcript-line-decoders-claude'
import { decodeCodexTranscriptLine } from './transcript-line-decoders-codex'
function expectNormalizedInterruption(message: NativeChatMessage | null): void {
expect(message).toMatchObject({
role: 'system',
blocks: [{ type: 'text', text: NATIVE_CHAT_INTERRUPTED_STATUS_TEXT }],
source: 'transcript'
})
expect(stripNoiseMessages(message ? [message] : [])).toEqual([message])
}
describe('native chat transcript interruption messages', () => {
it('normalizes Claude interruption boilerplate into one visible status row', () => {
const message = decodeClaudeTranscriptLine(
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'
)
expectNormalizedInterruption(message)
expect(message?.id).toBe('interrupt-row')
})
it('normalizes Codex turn_aborted into one visible status row', () => {
const message = decodeCodexTranscriptLine(
JSON.stringify({
type: 'event_msg',
timestamp: '2026-07-16T23:46:01.000Z',
payload: { type: 'turn_aborted', reason: 'interrupted', turn_id: 'turn-2' }
}),
'fallback'
)
expectNormalizedInterruption(message)
expect(message?.id).toBe('fallback')
})
})

View File

@ -1,6 +1,10 @@
// Claude JSONL line → NativeChatMessage decoder.
import type { NativeChatBlock, NativeChatMessage } from '../../shared/native-chat-types'
import {
NATIVE_CHAT_INTERRUPTED_STATUS_TEXT,
type NativeChatBlock,
type NativeChatMessage
} from '../../shared/native-chat-types'
import {
asRecord,
extractString,
@ -8,6 +12,7 @@ import {
timestampMs
} from '../ai-vault/session-scanner-values'
import { claudeContentBlocks } from './transcript-record-blocks'
import { claudeInterruptedMessageId } from './transcript-turn-markers'
export function decodeClaudeTranscriptLine(
line: string,
@ -21,6 +26,19 @@ export function decodeClaudeTranscriptLine(
if (role !== 'user' && role !== 'assistant') {
return null
}
const timestamp = parseTimestamp(record.timestamp)
const recordMessageId = extractString(record.uuid) ?? fallbackId
if (claudeInterruptedMessageId(record)) {
// Why: keep Claude's injected boilerplate out of the user-bubble path while
// preserving the interruption as a quiet, replayable conversation status.
return {
id: recordMessageId,
role: 'system',
blocks: [{ type: 'text', text: NATIVE_CHAT_INTERRUPTED_STATUS_TEXT }],
timestamp,
source: 'transcript'
}
}
const message = asRecord(record.message)
const decodedBlocks = claudeContentBlocks(message?.content)
if (decodedBlocks.length === 0) {
@ -42,7 +60,7 @@ export function decodeClaudeTranscriptLine(
id: messageId ?? fallbackId,
role: claudeMessageRole(role, blocks),
blocks,
timestamp: parseTimestamp(record.timestamp),
timestamp,
source: 'transcript'
}
}

View File

@ -1,6 +1,10 @@
// Codex JSONL line → NativeChatMessage decoder.
import type { NativeChatBlock, NativeChatMessage } from '../../shared/native-chat-types'
import {
NATIVE_CHAT_INTERRUPTED_STATUS_TEXT,
type NativeChatBlock,
type NativeChatMessage
} from '../../shared/native-chat-types'
import {
asRecord,
extractString,
@ -8,6 +12,7 @@ import {
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,
@ -87,6 +92,15 @@ function codexEventMessage(
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

View File

@ -1,5 +1,9 @@
import { createReadStream } from 'node:fs'
import type { AgentType, NativeChatMessage } from '../../shared/native-chat-types'
import type {
AgentType,
NativeChatMessage,
NativeChatTurnLifecycle
} from '../../shared/native-chat-types'
import { resolveNativeChatTranscriptAgent } from '../../shared/native-chat-agent-support'
import { errorMessage } from '../ai-vault/session-scanner-values'
import { resolveSessionFilePath, type ResolveSessionFileOptions } from './session-file-resolver'
@ -11,7 +15,10 @@ import {
import { decodeTranscriptStream } from './transcript-stream-lines'
export type ReadTranscriptResult =
| { messages: NativeChatMessage[] }
| {
messages: NativeChatMessage[]
lifecycle?: NativeChatTurnLifecycle
}
// notFound marks a retry-worthy miss (transcript not flushed to disk yet,
// #8401) as opposed to a real parse/IO error callers surface immediately.
| { error: string; notFound?: true }

View File

@ -1,5 +1,9 @@
import { open, stat } from 'node:fs/promises'
import type { AgentType, NativeChatMessage } from '../../shared/native-chat-types'
import type {
AgentType,
NativeChatMessage,
NativeChatTurnLifecycle
} from '../../shared/native-chat-types'
import { resolveNativeChatTranscriptAgent } from '../../shared/native-chat-agent-support'
import { resolveSessionFilePath, type ResolveSessionFileOptions } from './session-file-resolver'
import {
@ -8,6 +12,10 @@ import {
decodeGrokTranscriptLine
} from './transcript-line-decoders'
import { transcriptFallbackId } from './transcript-fallback-id'
import {
nativeChatTurnLifecycleDecoderForAgent,
type NativeChatTurnLifecycleDecoder
} from './transcript-turn-lifecycle'
export const MAX_NATIVE_CHAT_TRANSCRIPT_RECORD_BYTES = 2 * 1024 * 1024
const TAIL_CHUNK_BYTES = 64 * 1024
@ -33,9 +41,11 @@ export async function readNativeChatTranscriptTailFile(
limit: number,
decode: NativeChatLineDecoder,
includeTrailingLine = false,
endOffset?: number
endOffset?: number,
decodeLifecycle?: NativeChatTurnLifecycleDecoder | null
): Promise<{
messages: NativeChatMessage[]
lifecycle?: NativeChatTurnLifecycle
consumedTo: number
hasMore: boolean
beforeOffset: number
@ -48,6 +58,7 @@ export async function readNativeChatTranscriptTailFile(
const lineParts: Buffer[] = []
let lineBytes = 0
let lineOversized = false
let lifecycle: NativeChatTurnLifecycle | undefined
try {
const consumedTo = includeTrailingLine ? end : await findLastCompleteLineEnd(handle, end)
if (consumedTo === 0) {
@ -87,6 +98,7 @@ export async function readNativeChatTranscriptTailFile(
const selected = limit > 0 ? chronological.slice(Math.max(0, chronological.length - limit)) : []
return {
messages: selected.map((entry) => entry.message),
...(lifecycle ? { lifecycle } : {}),
consumedTo,
hasMore: limit > 0 && chronological.length > limit,
beforeOffset: selected[0]?.offset ?? end
@ -125,7 +137,12 @@ export async function readNativeChatTranscriptTailFile(
if (!line) {
return
}
const message = decode(line, transcriptFallbackId(filePath, lineOffset))
const fallbackId = transcriptFallbackId(filePath, lineOffset)
// Why: scan the same bounded JSONL window for provider-authored lifecycle
// records so reconnect snapshots can replay completion without guessing
// from the last visible assistant message.
lifecycle ??= decodeLifecycle?.(line, fallbackId) ?? undefined
const message = decode(line, fallbackId)
if (message) {
messages.push({ message, offset: lineOffset })
}
@ -165,10 +182,16 @@ export async function readNativeChatTranscriptTail(
beforeOffset?: number
}
): Promise<
| { messages: NativeChatMessage[]; hasMore: boolean; beforeOffset: number }
| {
messages: NativeChatMessage[]
lifecycle?: NativeChatTurnLifecycle
hasMore: boolean
beforeOffset: number
}
| { error: string; notFound?: true }
> {
const decode = nativeChatLineDecoderForAgent(args.agent)
const decodeLifecycle = nativeChatTurnLifecycleDecoderForAgent(args.agent)
const filePath = args.filePath ?? (await resolveSessionFilePath(args.agent, args.sessionId, args))
if (!decode) {
return { error: 'Transcript unavailable' }
@ -184,10 +207,16 @@ export async function readNativeChatTranscriptTail(
args.limit,
decode,
true,
args.beforeOffset
args.beforeOffset,
decodeLifecycle
)
return {
messages: result.messages,
// Why: an older pagination page must not rewind the live lifecycle; only
// the current transcript tail can authoritatively describe turn state.
...(args.beforeOffset === undefined && result.lifecycle
? { lifecycle: result.lifecycle }
: {}),
hasMore: result.hasMore,
beforeOffset: result.beforeOffset
}

View File

@ -0,0 +1,305 @@
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')
})
})
})

View File

@ -0,0 +1,169 @@
import type { AgentType, NativeChatTurnLifecycle } from '../../shared/native-chat-types'
import { resolveNativeChatTranscriptAgent } from '../../shared/native-chat-agent-support'
import { isNoiseMessage } from '../../shared/native-chat-noise'
import {
asRecord,
extractString,
parseJsonObject,
timestampMs
} from '../ai-vault/session-scanner-values'
import { decodeClaudeTranscriptLine } from './transcript-line-decoders-claude'
import {
claudeInterruptedMessageId,
CODEX_EVENT_TURN_ABORTED,
CODEX_EVENT_TURN_COMPLETE,
CODEX_EVENT_TURN_STARTED
} from './transcript-turn-markers'
export type NativeChatTurnLifecycleDecoder = (
line: string,
fallbackId: string
) => NativeChatTurnLifecycle | null
export function nativeChatTurnLifecycleDecoderForAgent(
agent: AgentType
): NativeChatTurnLifecycleDecoder | null {
const transcriptAgent = resolveNativeChatTranscriptAgent(agent)
if (transcriptAgent === 'codex') {
return decodeCodexTurnLifecycle
}
if (transcriptAgent === 'claude') {
return decodeClaudeTurnLifecycle
}
return null
}
export function decodeCodexTurnLifecycle(
line: string,
fallbackId: string
): NativeChatTurnLifecycle | null {
const record = parseJsonObject(line)
const payload = asRecord(record?.payload)
if (record?.type !== 'event_msg' || !payload) {
return null
}
if (
payload.type !== CODEX_EVENT_TURN_STARTED &&
payload.type !== CODEX_EVENT_TURN_COMPLETE &&
payload.type !== CODEX_EVENT_TURN_ABORTED
) {
return null
}
const state =
payload.type === CODEX_EVENT_TURN_STARTED
? 'working'
: payload.type === CODEX_EVENT_TURN_ABORTED
? 'interrupted'
: 'completed'
return {
state,
turnId: extractString(payload.turn_id) ?? fallbackId,
timestamp: lifecycleTimestamp(record.timestamp)
}
}
/** Claude stop reasons that end the lead generation (not mid-turn tool_use). */
const CLAUDE_TERMINAL_STOP_REASONS = new Set(['end_turn', 'max_tokens', 'stop_sequence', 'refusal'])
function isClaudeTerminalStopReason(value: unknown): boolean {
return typeof value === 'string' && CLAUDE_TERMINAL_STOP_REASONS.has(value)
}
export function decodeClaudeTurnLifecycle(
line: string,
fallbackId: string
): NativeChatTurnLifecycle | null {
const record = parseJsonObject(line)
if (!record) {
return null
}
const message = asRecord(record.message)
const timestamp = lifecycleTimestamp(record.timestamp)
const interruptedMessageId = claudeInterruptedMessageId(record)
if (interruptedMessageId) {
// Why: Claude stores its interrupt notice as an injected user row; it ends
// the active generation and must not be mistaken for the next user prompt.
return { state: 'interrupted', turnId: interruptedMessageId, timestamp }
}
if (record.type === 'assistant') {
const stopReason = message?.stop_reason
// Why: capable hosts rely on explicit terminals (prose is only a backup when
// the latest lifecycle is not mid-generation). Emit completed for every real
// end marker — including historical/OpenClaude rows that omit stop_reason —
// while tool_use stays non-terminal so mid-turn tool loops keep working. The
// no-stop_reason backup also excludes rows carrying a tool_use block: a
// pre-tool assistant row that omits stop_reason is mid-turn, not done, so it
// must not settle the spinner before the tool runs.
const isTerminal =
isClaudeTerminalStopReason(stopReason) ||
(stopReason == null &&
assistantHasRenderableContent(message) &&
!assistantHasToolUse(message))
if (isTerminal) {
return {
state: 'completed',
turnId: extractString(record.uuid) ?? extractString(message?.id) ?? fallbackId,
timestamp
}
}
return null
}
if (record.type !== 'user') {
return null
}
const decoded = decodeClaudeTranscriptLine(line, fallbackId)
if (decoded?.role !== 'user' || decoded.blocks.some((block) => block.type === 'tool-result')) {
// Why: Claude can attach text sidecars to tool-result user rows; those are
// continuations of the active turn, not a new user-authored generation.
return null
}
// Why: harness noise (task-notification, system-reminder, …) is user-role in
// the JSONL but not a new generation. Treating it as working would overwrite
// a real terminal marker and re-stick the chat spinner after done/interrupt.
if (isNoiseMessage(decoded)) {
return null
}
return { state: 'working', turnId: decoded.id, timestamp }
}
function lifecycleTimestamp(value: unknown): number | null {
const parsed = timestampMs(value)
return Number.isFinite(parsed) ? parsed : null
}
function assistantHasRenderableContent(message: Record<string, unknown> | null): boolean {
const content = message?.content
if (typeof content === 'string') {
return content.trim().length > 0
}
if (!Array.isArray(content)) {
return false
}
return content.some((block) => {
const record = asRecord(block)
if (!record) {
return false
}
if (
record.type === 'text' &&
typeof record.text === 'string' &&
record.text.trim().length > 0
) {
return true
}
if (record.type === 'thinking' || record.type === 'redacted_thinking') {
return true
}
return false
})
}
/** True when an assistant row contains a tool_use block the turn continues to
* a tool call, so a missing stop_reason must not be read as completion. */
function assistantHasToolUse(message: Record<string, unknown> | null): boolean {
const content = message?.content
if (!Array.isArray(content)) {
return false
}
return content.some((block) => asRecord(block)?.type === 'tool_use')
}

View File

@ -0,0 +1,26 @@
// Shared detection of provider-authored turn boundaries. The message decoders
// render a visible status row from these lines; the lifecycle decoders settle
// the chat spinner from the same lines. Keeping the predicates here means the
// two consumers can never disagree about which JSONL line is an interrupt/abort
// or a turn boundary — updating a provider's format touches one place, so a
// rename can't leave a visible "interrupted" row that never settles (or vice
// versa).
import { extractString } from '../ai-vault/session-scanner-values'
/**
* The `interruptedMessageId` on a Claude user row when that row is Claude's
* injected interrupt notice rather than a real user prompt. Returns undefined
* for genuine user turns.
*/
export function claudeInterruptedMessageId(record: Record<string, unknown>): string | undefined {
if (record.type !== 'user') {
return undefined
}
return extractString(record.interruptedMessageId) ?? undefined
}
/** Codex `event_msg` payload types that bound a turn's lifecycle. */
export const CODEX_EVENT_TURN_STARTED = 'task_started'
export const CODEX_EVENT_TURN_COMPLETE = 'task_complete'
export const CODEX_EVENT_TURN_ABORTED = 'turn_aborted'

View File

@ -0,0 +1,36 @@
import type {
AgentType,
NativeChatMessage,
NativeChatTurnLifecycle
} from '../../shared/native-chat-types'
import type { ResolveSessionFileOptions } from './session-file-resolver'
export type SubscribeNativeChatTranscriptArgs = ResolveSessionFileOptions & {
agent: AgentType
sessionId: string
onAppend: (messages: NativeChatMessage[], lifecycle?: NativeChatTurnLifecycle) => void
onInitialSnapshot?: (
messages: NativeChatMessage[],
hasMore: boolean,
beforeOffset: number,
/** Set when the initial drain could not deliver a transcript. */
error?: string,
lifecycle?: NativeChatTurnLifecycle
) => void
onReplace?: (
messages: NativeChatMessage[],
hasMore: boolean,
beforeOffset: number,
lifecycle?: NativeChatTurnLifecycle
) => void
initialLimit?: number
filePath?: string
debounceMs?: number
/** Test-only override for the production resolve-poll backoff. */
resolvePollIntervalMs?: number
}
export type NativeChatTranscriptSubscription = {
unsubscribe: () => void
watching: boolean
}

View File

@ -1,42 +1,18 @@
import { watch, type FSWatcher } from 'node:fs'
import { open, stat } from 'node:fs/promises'
import { basename, dirname } from 'node:path'
import type { AgentType, NativeChatMessage } from '../../shared/native-chat-types'
import type { ResolveSessionFileOptions } from './session-file-resolver'
import type { NativeChatMessage, NativeChatTurnLifecycle } from '../../shared/native-chat-types'
import {
readIncrementalTranscriptMessages,
resetIncrementalTranscriptState,
type IncrementalTranscriptState
} from './transcript-incremental-reader'
import { readNativeChatTranscriptTailFile } from './transcript-tail-reader'
export type SubscribeNativeChatTranscriptArgs = ResolveSessionFileOptions & {
agent: AgentType
sessionId: string
onAppend: (messages: NativeChatMessage[]) => void
onInitialSnapshot?: (
messages: NativeChatMessage[],
hasMore: boolean,
beforeOffset: number,
/** Set when the initial drain could not deliver a transcript; the subscriber
* surfaces it as an error snapshot so a watching client never sticks on
* 'loading'. Empty messages accompany it. */
error?: string
) => void
onReplace?: (messages: NativeChatMessage[], hasMore: boolean, beforeOffset: number) => void
initialLimit?: number
filePath?: string
debounceMs?: number
/** Overrides the resolve-poll interval (see subscribeViaResolvePoll) so tests
* don't wait out the production backoff. Production ignores this and backs
* off from 500ms to a 5s cap. */
resolvePollIntervalMs?: number
}
export type NativeChatTranscriptSubscription = {
unsubscribe: () => void
watching: boolean
}
import { nativeChatTurnLifecycleDecoderForAgent } from './transcript-turn-lifecycle'
import type {
NativeChatTranscriptSubscription,
SubscribeNativeChatTranscriptArgs
} from './transcript-watch-contract'
const DEFAULT_DEBOUNCE_MS = 40
const ROTATION_RETRY_MS = 25
@ -87,6 +63,7 @@ export async function installTranscriptWatcher(
return null
}
const { onAppend, onInitialSnapshot, onReplace, initialLimit, debounceMs } = args
const decodeLifecycle = nativeChatTurnLifecycleDecoderForAgent(args.agent)
const state: IncrementalTranscriptState = {
offset: 0,
@ -138,6 +115,7 @@ export async function installTranscriptWatcher(
}
async function readAndEmitAppends(): Promise<void> {
let lifecycle: NativeChatTurnLifecycle | undefined
const remaining = await readIncrementalTranscriptMessages(
filePath,
state,
@ -146,10 +124,14 @@ export async function installTranscriptWatcher(
if (!closed) {
onAppend(messages)
}
},
decodeLifecycle ?? undefined,
(nextLifecycle) => {
lifecycle = nextLifecycle
}
)
if (!closed && remaining.length > 0) {
onAppend(remaining)
if (!closed && (remaining.length > 0 || lifecycle)) {
onAppend(remaining, lifecycle)
}
}
@ -176,7 +158,14 @@ export async function installTranscriptWatcher(
// Why: 0 is a valid window — an explicit undefined check keeps an empty
// snapshot empty instead of falling back to an unbounded incremental read.
contentReplaced && !initialDrain && onReplace && initialLimit !== undefined
? await readNativeChatTranscriptTailFile(filePath, initialLimit, decode)
? await readNativeChatTranscriptTailFile(
filePath,
initialLimit,
decode,
false,
undefined,
decodeLifecycle
)
: null
if (closed) {
return
@ -187,7 +176,8 @@ export async function installTranscriptWatcher(
onReplace(
replacementSnapshot.messages,
replacementSnapshot.hasMore,
replacementSnapshot.beforeOffset
replacementSnapshot.beforeOffset,
replacementSnapshot.lifecycle
)
await readAndEmitAppends()
watchedBoundary = await boundaryFingerprint(filePath, state.offset)
@ -197,7 +187,14 @@ export async function installTranscriptWatcher(
const initialSnapshot =
initialDrain && onInitialSnapshot && initialLimit !== undefined
? await readNativeChatTranscriptTailFile(filePath, initialLimit, decode)
? await readNativeChatTranscriptTailFile(
filePath,
initialLimit,
decode,
false,
undefined,
decodeLifecycle
)
: null
if (closed) {
return
@ -210,12 +207,24 @@ export async function installTranscriptWatcher(
onInitialSnapshot(
initialSnapshot.messages,
initialSnapshot.hasMore,
initialSnapshot.beforeOffset
initialSnapshot.beforeOffset,
undefined,
initialSnapshot.lifecycle
)
await readAndEmitAppends()
} else {
const messages = await readIncrementalTranscriptMessages(filePath, state, decode)
onInitialSnapshot(messages, false, 0)
let lifecycle: NativeChatTurnLifecycle | undefined
const messages = await readIncrementalTranscriptMessages(
filePath,
state,
decode,
undefined,
decodeLifecycle ?? undefined,
(nextLifecycle) => {
lifecycle = nextLifecycle
}
)
onInitialSnapshot(messages, false, 0, undefined, lifecycle)
}
} else {
initialDrain = false

View File

@ -2,7 +2,7 @@ import { appendFile, mkdtemp, rename, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import type { NativeChatMessage } from '../../shared/native-chat-types'
import type { NativeChatMessage, NativeChatTurnLifecycle } from '../../shared/native-chat-types'
import {
getActiveNativeChatWatcherCount,
readNativeChatTranscriptTail,
@ -45,6 +45,30 @@ function claudeLine(uuid: string, role: 'user' | 'assistant', text: string): str
})}\n`
}
function claudeEndTurnLine(uuid: string, text: string): string {
return `${JSON.stringify({
type: 'assistant',
uuid,
timestamp: '2026-06-01T10:00:01.000Z',
message: {
role: 'assistant',
stop_reason: 'end_turn',
content: [{ type: 'text', text }]
}
})}\n`
}
function codexLifecycleLine(
state: 'task_started' | 'task_complete' | 'turn_aborted',
turnId = 'turn-1'
): string {
return `${JSON.stringify({
type: 'event_msg',
timestamp: state === 'task_started' ? '2026-06-01T10:00:00.000Z' : '2026-06-01T10:00:01.000Z',
payload: { type: state, turn_id: turnId }
})}\n`
}
async function waitFor(predicate: () => boolean, timeoutMs = 2000): Promise<void> {
const start = Date.now()
while (!predicate()) {
@ -99,6 +123,144 @@ describe('subscribeNativeChatTranscript', () => {
expect(snapshots).toEqual([[]])
})
it('replays and appends provider-authored turn lifecycle markers', async () => {
const filePath = await tempFile(claudeLine('u-1', 'user', 'first'))
const lifecycles: NativeChatTurnLifecycle[] = []
const sub = await subscribeNativeChatTranscript({
agent: 'claude',
sessionId: 'ignored',
filePath,
onInitialSnapshot: (_messages, _hasMore, _beforeOffset, _error, lifecycle) => {
if (lifecycle) {
lifecycles.push(lifecycle)
}
},
onAppend: (_messages, lifecycle) => {
if (lifecycle) {
lifecycles.push(lifecycle)
}
},
debounceMs: 5
})
await waitFor(() => lifecycles.length === 1)
await appendFile(filePath, claudeEndTurnLine('a-1', 'done'))
await waitFor(() => lifecycles.length === 2)
sub.unsubscribe()
expect(lifecycles.map((lifecycle) => lifecycle.state)).toEqual(['working', 'completed'])
expect(lifecycles.map((lifecycle) => lifecycle.turnId)).toEqual(['u-1', 'a-1'])
})
it('emits Codex task_complete even when the frame has no visible messages', async () => {
const filePath = await tempFile(codexLifecycleLine('task_started'))
const lifecycles: NativeChatTurnLifecycle[] = []
const sub = await subscribeNativeChatTranscript({
agent: 'codex',
sessionId: 'ignored',
filePath,
onInitialSnapshot: (_messages, _hasMore, _beforeOffset, _error, lifecycle) => {
if (lifecycle) {
lifecycles.push(lifecycle)
}
},
onAppend: (messages, lifecycle) => {
expect(messages).toEqual([])
if (lifecycle) {
lifecycles.push(lifecycle)
}
},
debounceMs: 5
})
await waitFor(() => lifecycles.length === 1)
await appendFile(filePath, codexLifecycleLine('task_complete'))
await waitFor(() => lifecycles.length === 2)
sub.unsubscribe()
expect(lifecycles).toMatchObject([
{ state: 'working', turnId: 'turn-1' },
{ state: 'completed', turnId: 'turn-1' }
])
})
it('replays Codex interruption as a terminal lifecycle and visible status row', async () => {
const filePath = await tempFile(
codexLifecycleLine('task_started') + codexLifecycleLine('turn_aborted')
)
let snapshot:
| { messages: NativeChatMessage[]; lifecycle: NativeChatTurnLifecycle | undefined }
| undefined
const sub = await subscribeNativeChatTranscript({
agent: 'codex',
sessionId: 'ignored',
filePath,
initialLimit: 40,
onInitialSnapshot: (messages, _hasMore, _beforeOffset, _error, lifecycle) => {
snapshot = { messages, lifecycle }
},
onAppend: () => {},
debounceMs: 5
})
await waitFor(() => snapshot !== undefined)
sub.unsubscribe()
expect(snapshot?.lifecycle).toMatchObject({ state: 'interrupted', turnId: 'turn-1' })
expect(snapshot?.messages).toMatchObject([
{ role: 'system', blocks: [{ type: 'text', text: 'Conversation interrupted' }] }
])
})
it('does not replay an older interruption over a newer working turn', async () => {
const filePath = await tempFile(
codexLifecycleLine('task_started', 'turn-1') +
codexLifecycleLine('turn_aborted', 'turn-1') +
codexLifecycleLine('task_started', 'turn-2')
)
const result = await readNativeChatTranscriptTail({
agent: 'codex',
sessionId: 'ignored',
filePath,
limit: 40
})
expect(result).toMatchObject({ lifecycle: { state: 'working', turnId: 'turn-2' } })
})
it('recovers a completion marker even when trailing non-boundary rows follow it', async () => {
// The lifecycle scan walks newest-first; rows that decode to no boundary
// (tool-results, harness noise) must not hide an earlier real completion
// within the window, or a reconnect snapshot would fail to settle.
const toolResult = `${JSON.stringify({
type: 'user',
uuid: 'tool-result-1',
timestamp: '2026-06-01T10:00:02.000Z',
message: {
role: 'user',
content: [{ type: 'tool_result', tool_use_id: 'tool-1', content: 'ok' }]
}
})}\n`
const noise = `${JSON.stringify({
type: 'user',
uuid: 'note-1',
timestamp: '2026-06-01T10:00:03.000Z',
message: {
role: 'user',
content: [{ type: 'text', text: '<system-reminder>continue</system-reminder>' }]
}
})}\n`
const filePath = await tempFile(claudeEndTurnLine('a-1', 'done') + toolResult + noise)
const result = await readNativeChatTranscriptTail({
agent: 'claude',
sessionId: 'ignored',
filePath,
limit: 40
})
expect(result).toMatchObject({ lifecycle: { state: 'completed', turnId: 'a-1' } })
})
it('emits a bulk append in bounded ordered batches', async () => {
const filePath = await tempFile('')
const batches: NativeChatMessage[][] = []

View File

@ -1,19 +1,19 @@
import { extname } from 'node:path'
import type { NativeChatMessage } from '../../shared/native-chat-types'
import { resolveSessionFilePath } from './session-file-resolver'
import {
installTranscriptWatcher,
type NativeChatTranscriptSubscription,
type SubscribeNativeChatTranscriptArgs
} from './transcript-watch-engine'
import { installTranscriptWatcher } from './transcript-watch-engine'
import type {
NativeChatTranscriptSubscription,
SubscribeNativeChatTranscriptArgs
} from './transcript-watch-contract'
import { nativeChatLineDecoderForAgent } from './transcript-tail-reader'
export { readNativeChatTranscriptTail } from './transcript-tail-reader'
export {
getActiveNativeChatWatcherCount,
type NativeChatTranscriptSubscription,
type SubscribeNativeChatTranscriptArgs
} from './transcript-watch-engine'
export { getActiveNativeChatWatcherCount } from './transcript-watch-engine'
export type {
NativeChatTranscriptSubscription,
SubscribeNativeChatTranscriptArgs
} from './transcript-watch-contract'
/** One resolve+install attempt. Returns null when the file isn't resolvable
* yet, or vanished between resolve and `watch()` either case is retried by

View File

@ -5,16 +5,53 @@ import type { RpcContext } from '../core'
// Stub the bounded tail reader so the handler returns a deterministic transcript with
// one oversized tool-result block; the test then asserts clip behavior per client.
const OVERSIZED = 'x'.repeat(5000)
const cachedResult = vi.hoisted(() => ({ value: { messages: [] as NativeChatMessage[] } }))
const cachedResult = vi.hoisted(() => ({
value: {
messages: [] as NativeChatMessage[],
// Optional so truncation-gating fixtures can omit it; lifecycle tests set it explicitly.
lifecycle: undefined as
| { state: 'working' | 'completed' | 'interrupted'; turnId: string; timestamp: number | null }
| undefined
} as {
messages: NativeChatMessage[]
lifecycle?: {
state: 'working' | 'completed' | 'interrupted'
turnId: string
timestamp: number | null
}
}
}))
const watcher = vi.hoisted(() => ({
args: null as null | {
onInitialSnapshot?: (
messages: NativeChatMessage[],
hasMore: boolean,
beforeOffset: number,
error?: string
error?: string,
lifecycle?: {
state: 'working' | 'completed' | 'interrupted'
turnId: string
timestamp: number | null
}
) => void
onReplace?: (
messages: NativeChatMessage[],
hasMore: boolean,
beforeOffset: number,
lifecycle?: {
state: 'working' | 'completed' | 'interrupted'
turnId: string
timestamp: number | null
}
) => void
onAppend: (
messages: NativeChatMessage[],
lifecycle?: {
state: 'working' | 'completed' | 'interrupted'
turnId: string
timestamp: number | null
}
) => void
onAppend: (messages: NativeChatMessage[]) => void
},
watching: true
}))
@ -24,7 +61,8 @@ vi.mock('../../../native-chat/transcript-watch', () => ({
return Promise.resolve({
messages: messages.slice(-limit),
hasMore: messages.length > limit,
beforeOffset: 123
beforeOffset: 123,
...(cachedResult.value.lifecycle ? { lifecycle: cachedResult.value.lifecycle } : {})
})
},
subscribeNativeChatTranscript: (args: NonNullable<typeof watcher.args>) => {
@ -331,7 +369,12 @@ describe('nativeChat.subscribe initial snapshot', () => {
)
expect(emitted).toEqual([
{ type: 'snapshot', messages: [], hasMore: false, error: 'Transcript unavailable' }
{
type: 'snapshot',
messages: [],
hasMore: false,
error: 'Transcript unavailable'
}
])
})
@ -379,4 +422,68 @@ describe('nativeChat.subscribe initial snapshot', () => {
}
])
})
it('forwards lifecycle on snapshot, append, and replacement frames', async () => {
watcher.watching = true
watcher.args = null
const emitted: unknown[] = []
await subscribeHandler()(
{ agent: 'claude', sessionId: 's' },
streamingContext('runtime'),
(value) => emitted.push(value)
)
const completed = {
state: 'completed' as const,
turnId: 'turn-rpc-1',
timestamp: 1_720_000_000_000
}
const callbacks = activeWatcherArgs()
callbacks.onInitialSnapshot?.([makeMessage('snap')], false, 3, undefined, completed)
callbacks.onAppend([], completed)
callbacks.onReplace?.([makeMessage('repl')], false, 9, completed)
expect(emitted).toEqual([
{
type: 'snapshot',
messages: [expect.objectContaining({ id: 'a-1' })],
hasMore: false,
beforeOffset: 3,
lifecycle: completed
},
{
type: 'appended',
messages: [],
lifecycle: completed
},
{
type: 'replacement',
messages: [expect.objectContaining({ id: 'a-1' })],
hasMore: false,
beforeOffset: 9,
lifecycle: completed
}
])
})
})
describe('nativeChat.readSession lifecycle payload', () => {
it('forwards lifecycle from the tail reader on success', async () => {
const lifecycle = {
state: 'interrupted' as const,
turnId: 'turn-read-1',
timestamp: 1_720_000_000_100
}
cachedResult.value = { messages: [makeMessage('done')], lifecycle }
const result = await readSessionHandler()(
{ agent: 'claude', sessionId: 's' },
ctxWith('runtime')
)
expect(result).toMatchObject({
hasMore: false,
beforeOffset: 123,
lifecycle
})
expect((result as { messages: NativeChatMessage[] }).messages).toHaveLength(1)
})
})

View File

@ -194,7 +194,8 @@ export const NATIVE_CHAT_METHODS: readonly RpcAnyMethod[] = [
? {
messages: windowForClient(result.messages, clientKind, limit),
hasMore: result.hasMore,
beforeOffset: result.beforeOffset
beforeOffset: result.beforeOffset,
...(result.lifecycle ? { lifecycle: result.lifecycle } : {})
}
: result
}
@ -232,7 +233,7 @@ export const NATIVE_CHAT_METHODS: readonly RpcAnyMethod[] = [
sessionId: params.sessionId,
transcriptPath: params.transcriptPath,
initialLimit: limit,
onInitialSnapshot: (messages, hasMore, beforeOffset, error) => {
onInitialSnapshot: (messages, hasMore, beforeOffset, error, lifecycle) => {
if (closed) {
return
}
@ -243,10 +244,11 @@ export const NATIVE_CHAT_METHODS: readonly RpcAnyMethod[] = [
messages: windowForClient(messages, clientKind, limit),
hasMore,
beforeOffset,
...(error ? { error } : {})
...(error ? { error } : {}),
...(lifecycle ? { lifecycle } : {})
})
},
onReplace: (messages, hasMore, beforeOffset) => {
onReplace: (messages, hasMore, beforeOffset, lifecycle) => {
if (closed) {
return
}
@ -254,14 +256,19 @@ export const NATIVE_CHAT_METHODS: readonly RpcAnyMethod[] = [
type: 'replacement',
messages: windowForClient(messages, clientKind, limit),
hasMore,
beforeOffset
beforeOffset,
...(lifecycle ? { lifecycle } : {})
})
},
onAppend: (messages) => {
onAppend: (messages, lifecycle) => {
if (closed) {
return
}
emit({ type: 'appended', messages: sanitizeAppendForClient(messages, clientKind) })
emit({
type: 'appended',
messages: sanitizeAppendForClient(messages, clientKind),
...(lifecycle ? { lifecycle } : {})
})
}
})
// The connection may have closed while the file was being resolved.
@ -270,7 +277,12 @@ export const NATIVE_CHAT_METHODS: readonly RpcAnyMethod[] = [
return
}
if (!subscription.watching) {
emit({ type: 'snapshot', messages: [], hasMore: false, error: 'Transcript unavailable' })
emit({
type: 'snapshot',
messages: [],
hasMore: false,
error: 'Transcript unavailable'
})
}
unsubscribe = subscription.unsubscribe
}

View File

@ -428,7 +428,11 @@ import type {
AiVaultSubagentListArgs,
AiVaultSubagentListResult
} from '../shared/ai-vault-types'
import type { AgentType, NativeChatMessage } from '../shared/native-chat-types'
import type {
AgentType,
NativeChatMessage,
NativeChatTurnLifecycle
} from '../shared/native-chat-types'
import type { TelemetryConsentState } from '../shared/telemetry-consent-types'
import type { AgentKind, LaunchSource, RequestKind } from '../shared/telemetry-events'
import type { AppStarSource } from '../shared/gh-star-source'
@ -837,16 +841,34 @@ export type AiVaultApi = {
// notFound marks a miss caused by the transcript not existing on disk yet
// (retry-worthy), as opposed to a real read/parse error (#8401).
export type NativeChatReadSessionResult =
| { messages: NativeChatMessage[] }
| {
messages: NativeChatMessage[]
lifecycle?: NativeChatTurnLifecycle
}
| { error: string; notFound?: true }
/** Messages appended to a live-tailed transcript since the previous emit. */
export type NativeChatAppendedMessages = NativeChatMessage[]
export type NativeChatSubscriptionFrame =
| { type: 'snapshot'; messages: NativeChatMessage[]; hasMore: boolean; error?: string }
| { type: 'replacement'; messages: NativeChatMessage[]; hasMore: boolean }
| { type: 'appended'; messages: NativeChatMessage[] }
| {
type: 'snapshot'
messages: NativeChatMessage[]
hasMore: boolean
error?: string
lifecycle?: NativeChatTurnLifecycle
}
| {
type: 'replacement'
messages: NativeChatMessage[]
hasMore: boolean
lifecycle?: NativeChatTurnLifecycle
}
| {
type: 'appended'
messages: NativeChatMessage[]
lifecycle?: NativeChatTurnLifecycle
}
/** Wire payload for the `nativeChat:appended` push channel. */
export type NativeChatAppendedPayload = {

View File

@ -156,18 +156,23 @@ function NativeChatResolvedView({
const launchPrompt = useAppStore((s) => s.nativeChatLaunchPromptByTabId[terminalTabId] ?? null)
const clearNativeChatLaunchPrompt = useAppStore((s) => s.clearNativeChatLaunchPrompt)
const paneLaunchPrompt = launchPrompt?.agent === agent ? launchPrompt : null
// Live hook state for this pane, selected directly so the working indicator
// flips the instant the agent reports 'working' — even when switching to chat
// mid-turn before the transcript merge has caught up.
const hookWorking = useAppStore((s) => s.agentStatusByPaneKey[paneKey]?.state === 'working')
// The live-session merge reconciles hooks with replayable transcript turn
// boundaries; all working consumers must use that one lifecycle decision.
const liveWorking = session.status === 'working'
// The agent's in-progress reply preview (hook), shown as a live streaming
// bubble while it works — before the completed turn flushes to the transcript.
const hookPreview = useAppStore((s) => s.agentStatusByPaneKey[paneKey]?.lastAssistantMessage)
// Why: Stop suppression must clear on a newer working epoch even when status
// never leaves 'working' (interrupt + immediate next turn coalesced).
const hookWorkingEpoch = useAppStore(
(s) => s.agentStatusByPaneKey[paneKey]?.stateStartedAt ?? null
)
const canSend = useNativeChatCanSend(targetPtyId)
// Reuse the verified composer send path for interactive cards and composer
// stop (Stop sends ESC, the agent-TUI interrupt key).
const interactiveSend = useNativeChatInteractiveSend(terminalTabId, paneKey, targetPtyId, agent)
const [workingInterrupted, setWorkingInterrupted] = useState(false)
const previousWorkingEpochRef = useRef<number | null>(null)
// True while a question card owns the input region, so the composer is hidden.
const [questionActive, setQuestionActive] = useState(false)
const rootRef = useRef<HTMLDivElement>(null)
@ -308,9 +313,9 @@ function NativeChatResolvedView({
? [...sessionAfterCommandBoundaries.messages, ...pendingMessages]
: sessionAfterCommandBoundaries.messages,
previewText: hookPreview,
working: hookWorking
working: liveWorking
})
}, [sessionAfterCommandBoundaries.messages, pendingMessages, hookPreview, hookWorking])
}, [sessionAfterCommandBoundaries.messages, pendingMessages, hookPreview, liveWorking])
const sessionWithPending = useMemo<typeof session>(() => {
if (pending.length === 0 && commandMarkers.length === 0 && !streamingText) {
return sessionAfterCommandBoundaries
@ -331,26 +336,38 @@ function NativeChatResolvedView({
const viewState = selectNativeChatViewState(sessionWithPending)
const isConversation = viewState.kind === 'ready'
// Drive "working" from the live hook state too: when toggling to chat while the
// agent is mid-turn, the merged transcript may not yet reflect the in-flight
// turn, but the hook already says 'working' — show the indicator immediately.
const viewWorking = viewState.kind === 'ready' && viewState.isWorking
useEffect(() => {
if (shouldClearNativeChatWorkingSuppression({ viewWorking, hookWorking })) {
if (
shouldClearNativeChatWorkingSuppression({
working: liveWorking,
interrupted: workingInterrupted,
workingEpoch: hookWorkingEpoch,
previousWorkingEpoch: previousWorkingEpochRef.current
})
) {
setWorkingInterrupted(false)
}
}, [viewWorking, hookWorking])
if (liveWorking && hookWorkingEpoch != null) {
previousWorkingEpochRef.current = hookWorkingEpoch
}
if (!liveWorking) {
previousWorkingEpochRef.current = null
}
}, [liveWorking, workingInterrupted, hookWorkingEpoch])
const isWorking = shouldShowNativeChatWorking({
isConversation,
viewWorking,
hookWorking,
working: liveWorking,
interrupted: workingInterrupted
})
const stopAgent = useCallback(() => {
setWorkingInterrupted(true)
// Why: Stop after a submitted turn drops the delayed-write handle once it
// settles, so cancelPendingSends no longer sees the optimistic id. Clear
// the echo cache here so a cancelled prompt cannot stick as a ghost bubble.
setPending(writePendingSendCache(pendingScope, []))
interactiveSend.cancel()
}, [interactiveSend])
}, [interactiveSend, pendingScope])
const nativeChatFileLinkClick = useNativeChatFileLinkClick(fileLinkContext)
// Chat-only font zoom via Cmd/Ctrl +/-/0, gated to the live conversation so

View File

@ -1,14 +1,15 @@
// Pure merge of live hook turn-state into a NativeChatSession status override.
// Kept separate from the React hook so the precedence rule (live 'working'
// surfaces before the transcript flushes the final assistant message, then is
// superseded once it lands) is unit-testable without IPC or the store.
// surfaces before the transcript flushes its explicit terminal record, then is
// reconciled once that boundary lands) is unit-testable without IPC.
import type { AgentStatusState } from '../../../../shared/agent-status-types'
import { assembleNativeChatSession, type NativeChatSources } from './native-chat-session-assembler'
import type {
AgentType,
NativeChatSession,
NativeChatSessionStatus
NativeChatSessionStatus,
NativeChatTurnLifecycle
} from '../../../../shared/native-chat-types'
export type NativeChatLiveMergeInput = {
@ -17,9 +18,12 @@ export type NativeChatLiveMergeInput = {
agent: AgentType
/** Live hook state for the pane, or null when no hook entry exists. */
hookState: AgentStatusState | null
/** Epoch ms when the current hook state began, or null when unknown. Lets a
* stale 'working' self-heal once this turn's own assistant reply has landed. */
/** Epoch ms when the current hook state began, or null when unknown. */
stateStartedAt?: number | null
/** Latest provider-authored turn boundary recovered from the transcript. */
transcriptLifecycle?: NativeChatTurnLifecycle
/** Claude can finish its lead turn while background children remain active. */
hookHasWorkingSubagents?: boolean
/** True before the initial snapshot resolves; forces 'loading'. */
loading?: boolean
/** Set when the initial snapshot failed; forces 'error'. */
@ -28,25 +32,42 @@ export type NativeChatLiveMergeInput = {
/**
* Decide the session status given the merged transcript/append messages and the
* live hook state. The transcript is the source of truth for content; the hook
* only fills the gap while the agent is mid-turn.
* live hook state. The transcript is the source of truth for content; explicit
* provider lifecycle records reconcile a dropped final hook.
*
* Precedence:
* - error / loading overrides win outright.
* - hook 'working' stays authoritative until the hook exits that state OR this
* turn's own assistant reply lands (a trailing reply newer than
* stateStartedAt); a trailing reply from an EARLIER turn does not suppress it.
* - errors win outright; live work wins over transcript loading.
* - hook 'working' stays authoritative until the hook exits that state OR an
* explicit terminal marker for this turn lands.
* - design is hook-first: lifecycle is a terminal suppressor for dropped
* Stop hooks, not a full authority for active-turn reconstruction.
*/
export function mergeNativeChatLiveSession(input: NativeChatLiveMergeInput): NativeChatSession {
const { sources, sessionId, agent, hookState, stateStartedAt, loading, error } = input
const {
sources,
sessionId,
agent,
hookState,
stateStartedAt,
transcriptLifecycle,
hookHasWorkingSubagents,
loading,
error
} = input
if (error) {
return assembleNativeChatSession({ sources, sessionId, agent, status: 'error', error })
}
if (loading) {
const status = liveStatusOverride(
hookState,
sources,
stateStartedAt,
transcriptLifecycle,
hookHasWorkingSubagents ?? false
)
if (loading && status !== 'working') {
return assembleNativeChatSession({ sources, sessionId, agent, status: 'loading' })
}
const status = liveStatusOverride(hookState, sources, stateStartedAt)
return assembleNativeChatSession({
sources,
sessionId,
@ -55,29 +76,75 @@ export function mergeNativeChatLiveSession(input: NativeChatLiveMergeInput): Nat
})
}
/** Slack for comparing transcript timestamps to hook receipt times across hosts. */
const LIFECYCLE_CLOCK_SKEW_SLACK_MS = 2_000
function liveStatusOverride(
hookState: AgentStatusState | null,
sources: NativeChatSources,
stateStartedAt: number | null | undefined
stateStartedAt: number | null | undefined,
transcriptLifecycle: NativeChatTurnLifecycle | undefined,
hookHasWorkingSubagents: boolean
): NativeChatSessionStatus | undefined {
// Only 'working' drives a live override; blocked/waiting/done leave the
// derived (ready/empty) status alone so completed turns render normally.
if (hookState !== 'working') {
return undefined
}
// Self-heal a stale 'working' (dropped/late Stop hook): if this turn's own
// assistant reply has already landed, the turn is effectively visible — stop
// asserting 'working'. A trailing reply from a PRIOR turn (older than the
// working turn's start) must not suppress it: the agent is working again.
if (trailingAssistantPostDates(sources, stateStartedAt)) {
const terminatesCurrentTurn = lifecycleTerminatesCurrentTurn(transcriptLifecycle, stateStartedAt)
// Why: an explicit interruption ends the whole turn, children included, so it
// settles the session even while a stale child status still reads working.
if (terminatesCurrentTurn && transcriptLifecycle?.state === 'interrupted') {
return undefined
}
// Why: a lead completion does not end Claude's aggregate turn while a
// background child still runs; callers must already scope the roster to the
// current working epoch so prior-turn children cannot veto forever.
if (hookHasWorkingSubagents) {
return 'working'
}
if (terminatesCurrentTurn) {
return undefined
}
// Why: prose recovery stays available whenever the latest lifecycle is not an
// explicit in-progress generation. That covers incapable hosts and capable
// hosts whose transcript never emitted a terminal marker for this window.
// Mid-turn (lifecycle === working) keeps prose off so partial assistant rows
// do not settle early on capable providers.
if (
transcriptLifecycle?.state !== 'working' &&
trailingAssistantPostDates(sources, stateStartedAt)
) {
return undefined
}
return 'working'
}
/** True when the transcript's last message is an assistant reply that landed at
* or after `stateStartedAt`. Unknown timings (no start, no message timestamp)
* return false so the caller keeps 'working' the safe, non-regressing default. */
function lifecycleTerminatesCurrentTurn(
lifecycle: NativeChatTurnLifecycle | undefined,
stateStartedAt: number | null | undefined
): boolean {
if (lifecycle?.state !== 'completed' && lifecycle?.state !== 'interrupted') {
return false
}
// Why: omit/null timestamps are valid on the wire. Prefer the terminal marker
// over a stuck spinner — the latest lifecycle is already last-wins from the
// watcher, so a newer user generation would have replaced it with working.
if (stateStartedAt == null || lifecycle.timestamp == null) {
return true
}
if (lifecycle.timestamp >= stateStartedAt) {
return true
}
// Why: transcript clocks and hook receipt times can skew over SSH/runtime.
// Only apply slack to real epoch timestamps so small logical clocks used in
// tests (and any non-wall-clock ids) keep strict ordering.
if (lifecycle.timestamp > 1e11 && stateStartedAt > 1e11) {
return lifecycle.timestamp + LIFECYCLE_CLOCK_SKEW_SLACK_MS >= stateStartedAt
}
return false
}
function trailingAssistantPostDates(
sources: NativeChatSources,
stateStartedAt: number | null | undefined
@ -86,8 +153,5 @@ function trailingAssistantPostDates(
return false
}
const last = (sources.transcript ?? []).at(-1)
if (last?.role !== 'assistant' || last.timestamp == null) {
return false
}
return last.timestamp >= stateStartedAt
return last?.role === 'assistant' && last.timestamp != null && last.timestamp >= stateStartedAt
}

View File

@ -38,6 +38,7 @@ describe('isNoiseMessage', () => {
it('keeps assistant and tool turns', () => {
expect(isNoiseMessage(msg('assistant', '<system-reminder> in prose'))).toBe(false)
expect(isNoiseMessage(msg('system', 'Conversation interrupted'))).toBe(false)
})
it('keeps a user turn that carries tool results', () => {

View File

@ -80,6 +80,125 @@ export function advancedNativeChatUserContentCounts(
return advanced
}
function nativeChatUserMessageNormalizedText(message: NativeChatMessage): string | null {
if (message.role !== 'user') {
return null
}
const text = normalizeNativeChatPendingText(
message.blocks
.filter(isTextBlock)
.map((block) => block.text)
.join(' ')
)
return text.length > 0 ? text : null
}
/** User texts that already have a later non-user turn (ready to prune echoes). */
export function advancedNativeChatUserTexts(
messages: readonly NativeChatMessage[]
): readonly string[] {
const advanced: string[] = []
const waiting: string[] = []
for (const message of messages) {
if (message.role === 'user') {
const text = nativeChatUserMessageNormalizedText(message)
if (text) {
waiting.push(text)
}
continue
}
advanced.push(...waiting)
waiting.length = 0
}
return advanced
}
/** All user texts (for hiding optimistic echoes once the turn exists). */
export function matchingNativeChatUserTexts(
messages: readonly NativeChatMessage[]
): readonly string[] {
const texts: string[] = []
for (const message of messages) {
const text = nativeChatUserMessageNormalizedText(message)
if (text) {
texts.push(text)
}
}
return texts
}
/**
* How many leading pending texts concatenate exactly to `userText`.
* Covers rapid-send glue ("joke"+"continue" "jokecontinue") without matching
* unrelated prefixes ("hi" "history").
*/
export function countLeadingPendingTextsGluedToUserText(
pendingTexts: readonly string[],
userText: string
): number {
if (pendingTexts.length === 0 || userText.length === 0) {
return 0
}
let combined = ''
for (let index = 0; index < pendingTexts.length; index += 1) {
const piece = pendingTexts[index]
if (!piece) {
return 0
}
combined += piece
if (combined === userText) {
return index + 1
}
if (!userText.startsWith(combined)) {
return 0
}
}
return 0
}
/**
* Mark pending entries represented only by multi-send glue (2+ consecutive
* optimistic texts concatenated into one transcript user row). Exact single
* matches stay in the content-key/occurrence path so repeated prompts and
* send boundaries keep their existing semantics.
*/
export function selectPendingIndicesRepresentedByUserTexts(
pending: readonly NativeChatPendingOccurrence[],
userTexts: readonly string[]
): Set<number> {
const represented = new Set<number>()
if (pending.length < 2 || userTexts.length === 0) {
return represented
}
const remaining = pending.map((entry, index) => ({
index,
text: normalizeNativeChatPendingText(entry.text)
}))
for (const userText of userTexts) {
const open = remaining.filter((entry) => !represented.has(entry.index) && entry.text.length > 0)
const gluedCount = countLeadingPendingTextsGluedToUserText(
open.map((entry) => entry.text),
userText
)
// Why: gluedCount === 1 is an exact match — leave it to occurrence counting.
if (gluedCount < 2) {
continue
}
for (let i = 0; i < gluedCount; i += 1) {
const entry = open[i]
if (!entry) {
continue
}
represented.add(entry.index)
const at = remaining.findIndex((candidate) => candidate.index === entry.index)
if (at >= 0) {
remaining.splice(at, 1)
}
}
}
return represented
}
export function nativeChatPendingMatchKey(pending: NativeChatPendingOccurrence): string {
return `${String(pending.afterMessageId)}\0${nativeChatPendingContentKey(pending)}`
}

View File

@ -140,6 +140,26 @@ describe('prunePendingSends', () => {
prunePendingSends(pending, [userMessage('u1', 'repeat'), assistantMessage('a1', 'done')])
).toEqual([pendingOf('p2', 'repeat')])
})
it('prunes consecutive optimistic sends that were glued into one transcript user turn', () => {
const pending = [pendingOf('p1', 'tell me a joke'), pendingOf('p2', 'continue')]
expect(
prunePendingSends(pending, [
userMessage('u1', 'tell me a jokecontinue'),
assistantMessage('a1', 'a joke')
])
).toEqual([])
})
it('does not treat an unrelated longer user turn as a glued match', () => {
const pending = [pendingOf('p1', 'hi')]
expect(
prunePendingSends(pending, [
userMessage('u1', 'history of the project'),
assistantMessage('a1', 'ok')
])
).toEqual(pending)
})
})
describe('pendingSendsAsMessages', () => {
@ -179,6 +199,13 @@ describe('pendingSendsAsMessages', () => {
expect(pendingSendsAsMessages(pending, [])).toHaveLength(1)
})
it('hides consecutive optimistic sends once a glued transcript user turn lands', () => {
const pending = [pendingOf('p1', 'tell me a joke'), pendingOf('p2', 'continue')]
expect(pendingSendsAsMessages(pending, [userMessage('u1', 'tell me a jokecontinue')])).toEqual(
[]
)
})
it('keeps a repeated prompt visible when its only match predates the send boundary', () => {
const history = [userMessage('old-user', 'run tests'), assistantMessage('old-answer', 'passed')]
const pending = [{ ...pendingOf('new-send', 'run tests'), afterMessageId: 'old-answer' }]

View File

@ -8,12 +8,15 @@ import { setBoundedScopeCacheEntry } from './native-chat-composer-scope-cache'
import type { NativeChatLaunchPrompt } from '@/lib/native-chat-launch-prompt'
import {
advancedNativeChatUserContentCounts,
advancedNativeChatUserTexts,
assignNativeChatPendingOccurrence,
matchingNativeChatUserContentCounts,
matchingNativeChatUserTexts,
nativeChatPendingContentKey,
nativeChatPendingMatchKey,
nativeChatPendingMatchingAfter,
nativeChatPendingOccurrence
nativeChatPendingOccurrence,
selectPendingIndicesRepresentedByUserTexts
} from './native-chat-pending-occurrence'
/** An optimistic, not-yet-confirmed composer send. */
@ -133,7 +136,7 @@ export function prunePendingSends(
return pending
}
const consumed = new Map<string, number>()
const next = pending.filter((entry) => {
const exactKeep = pending.map((entry) => {
const contentKey = nativeChatPendingContentKey(entry)
const key = nativeChatPendingMatchKey(entry)
const available =
@ -143,10 +146,22 @@ export function prunePendingSends(
const used = consumed.get(key) ?? 0
const occurrence = nativeChatPendingOccurrence(entry, used)
consumed.set(key, Math.max(used, occurrence))
if (occurrence > available) {
return true
return occurrence > available
})
// Why: when rapid body writes glued two optimistic sends into one transcript
// user row ("joke"+"continue"→"jokecontinue"), exact keys never match. Drop
// those echoes once an assistant turn advances past the glued user text.
const stillOpen = pending.filter((_, index) => exactKeep[index])
const gluedRepresented = selectPendingIndicesRepresentedByUserTexts(
stillOpen,
advancedNativeChatUserTexts(messages)
)
const next = pending.filter((entry, index) => {
if (!exactKeep[index]) {
return false
}
return false
const openIndex = stillOpen.indexOf(entry)
return openIndex < 0 || !gluedRepresented.has(openIndex)
})
return next.length === pending.length ? pending : next
}
@ -162,21 +177,32 @@ export function pendingSendsAsMessages(
existingMessages: NativeChatMessage[] = []
): NativeChatMessage[] {
const consumed = new Map<string, number>()
const exactVisible = pending.map((entry) => {
const contentKey = nativeChatPendingContentKey(entry)
const key = nativeChatPendingMatchKey(entry)
const represented =
matchingNativeChatUserContentCounts(
messagesAfterPendingBoundary(existingMessages, entry)
).get(contentKey) ?? 0
const used = consumed.get(key) ?? 0
const occurrence = nativeChatPendingOccurrence(entry, used)
consumed.set(key, Math.max(used, occurrence))
return occurrence > represented
})
// Hide optimistic echoes that were glued into a single transcript user row
// even before the assistant reply lands (matching, not advanced).
const stillVisible = pending.filter((_, index) => exactVisible[index])
const gluedRepresented = selectPendingIndicesRepresentedByUserTexts(
stillVisible,
matchingNativeChatUserTexts(existingMessages)
)
return pending
.filter((entry) => {
const contentKey = nativeChatPendingContentKey(entry)
const key = nativeChatPendingMatchKey(entry)
const represented =
matchingNativeChatUserContentCounts(
messagesAfterPendingBoundary(existingMessages, entry)
).get(contentKey) ?? 0
const used = consumed.get(key) ?? 0
const occurrence = nativeChatPendingOccurrence(entry, used)
consumed.set(key, Math.max(used, occurrence))
if (occurrence > represented) {
return true
.filter((entry, index) => {
if (!exactVisible[index]) {
return false
}
return false
const openIndex = stillVisible.indexOf(entry)
return openIndex < 0 || !gluedRepresented.has(openIndex)
})
.map((entry) => ({
id: `pending:${entry.id}`,

View File

@ -0,0 +1,191 @@
// Per-PTY serialization for native-chat clear+body+Enter sequences.
// Why: Enter is delayed (busy-agent safety). Without a queue, a second send's
// clear/body can interleave with the first Enter window and still glue or race
// the agent composer. Each sequence owns the line until its Enter fires.
//
// Option commands (model switch) cancel/await this queue first so a delayed chat
// Enter cannot land on Claude's model confirmation dialog.
export type NativeChatPtySendQueueHandle = {
cancel: () => void
settleAfterMs: number
bodyStarted: () => boolean
finished: () => boolean
}
export type EnqueueNativeChatPtySendOptions = {
/**
* Called when cancel aborts after `start` began but before Enter was marked
* submitted. Used to clear leftover body text from the agent TUI.
*/
onCancelUnsubmitted?: () => void
}
type PtyQueueState = {
tail: Promise<void>
freeAt: number
depth: number
handles: Set<NativeChatPtySendQueueHandle>
}
const ptyQueues = new Map<string, PtyQueueState>()
function getOrCreateState(ptyId: string): PtyQueueState {
let state = ptyQueues.get(ptyId)
if (!state) {
state = { tail: Promise.resolve(), freeAt: Date.now(), depth: 0, handles: new Set() }
ptyQueues.set(ptyId, state)
}
return state
}
export function resetNativeChatPtySendQueuesForTests(): void {
for (const state of ptyQueues.values()) {
for (const handle of state.handles) {
handle.cancel()
}
}
ptyQueues.clear()
}
/** Abort every in-flight/queued chat send on this PTY (clears delayed Enter). */
export function cancelNativeChatPtySends(ptyId: string): void {
const state = ptyQueues.get(ptyId)
if (!state) {
return
}
for (const handle of state.handles) {
handle.cancel()
}
}
/** Wait until every chat sequence on this PTY has finished or been cancelled. */
export async function waitForNativeChatPtyIdle(ptyId: string): Promise<void> {
const state = ptyQueues.get(ptyId)
if (!state) {
return
}
await state.tail
}
/**
* Run `start` only after prior sequences for `ptyId` finish. When the queue is
* idle, `start` runs synchronously so the body write is not deferred a tick.
*/
export function enqueueNativeChatPtySend(
ptyId: string,
durationMs: number,
start: (ctx: {
isCancelled: () => boolean
delay: (ms: number, fn: () => void) => void
/** Call when Enter (or the terminal write that completes the send) fires. */
markSubmitted: () => void
}) => void,
options?: EnqueueNativeChatPtySendOptions
): NativeChatPtySendQueueHandle {
const now = Date.now()
const state = getOrCreateState(ptyId)
const waitMs = Math.max(0, state.freeAt - now)
const settleAfterMs = waitMs + Math.max(0, durationMs)
state.freeAt = Math.max(now, state.freeAt) + Math.max(0, durationMs)
state.depth += 1
let cancelled = false
let bodyStarted = false
let finished = false
let submitted = false
const timers: ReturnType<typeof setTimeout>[] = []
let release: (() => void) | null = null
const delay = (ms: number, fn: () => void): void => {
const timer = setTimeout(() => {
if (!cancelled) {
fn()
}
}, ms)
timers.push(timer)
}
const markFinished = (): void => {
finished = true
}
const markSubmitted = (): void => {
submitted = true
}
const execute = (): Promise<void> =>
new Promise<void>((resolve) => {
release = resolve
if (cancelled) {
markFinished()
resolve()
return
}
bodyStarted = true
start({ isCancelled: () => cancelled, delay, markSubmitted })
if (durationMs <= 0) {
markSubmitted()
markFinished()
resolve()
return
}
// Why: always release after the declared duration so a cancel mid-flight
// cannot stall the per-pty queue forever.
const done = setTimeout(() => {
markFinished()
resolve()
}, durationMs)
timers.push(done)
})
const runPromise =
state.depth === 1 && waitMs === 0 ? execute() : state.tail.then(() => execute())
const dropHandle = (): void => {
state.handles.delete(handle)
}
const settleQueueEntry = (): void => {
state.depth = Math.max(0, state.depth - 1)
markFinished()
dropHandle()
// Why: drop the per-pty record once nothing is in flight so the map does not
// accumulate one permanent entry per pty over a long, multi-pane session.
if (state.depth === 0 && state.handles.size === 0 && ptyQueues.get(ptyId) === state) {
ptyQueues.delete(ptyId)
}
}
state.tail = runPromise.then(settleQueueEntry, settleQueueEntry)
const handle: NativeChatPtySendQueueHandle = {
cancel: () => {
if (cancelled) {
return
}
cancelled = true
for (const timer of timers) {
clearTimeout(timer)
}
const shouldClear = bodyStarted && !submitted
markFinished()
// Why: refund only THIS sequence's charged window rather than collapsing
// freeAt to now — later queued sends still hold the line, so a blanket
// reset would understate the next enqueue's settle time and let a send
// card drop while a queued Enter is still pending.
state.freeAt = Math.max(Date.now(), state.freeAt - Math.max(0, durationMs))
release?.()
release = null
dropHandle()
if (shouldClear) {
options?.onCancelUnsubmitted?.()
}
},
settleAfterMs,
bodyStarted: () => bodyStarted,
finished: () => finished
}
state.handles.add(handle)
return handle
}

View File

@ -0,0 +1,60 @@
import type {
NativeChatAppendedMessages,
NativeChatReadSessionResult
} from '../../../../preload/api-types'
import type { NativeChatTurnLifecycle } from '../../../../shared/native-chat-types'
export const RUNTIME_NATIVE_CHAT_READ_ERROR = "Couldn't read agent chat from the remote runtime."
export function parseRuntimeNativeChatTurnLifecycle(
value: unknown
): NativeChatTurnLifecycle | undefined {
if (typeof value !== 'object' || value === null) {
return undefined
}
const record = value as Record<string, unknown>
if (
(record.state !== 'working' &&
record.state !== 'completed' &&
record.state !== 'interrupted') ||
typeof record.turnId !== 'string' ||
record.turnId.trim().length === 0 ||
(record.timestamp !== null &&
record.timestamp !== undefined &&
(typeof record.timestamp !== 'number' ||
!Number.isFinite(record.timestamp) ||
record.timestamp <= 0))
) {
return undefined
}
return {
state: record.state,
turnId: record.turnId.trim(),
// Why: an omitted timestamp is a valid payload; normalize it to null rather
// than dropping the whole lifecycle record.
timestamp: record.timestamp ?? null
}
}
export function parseRuntimeNativeChatReadSessionResult(
value: unknown
): NativeChatReadSessionResult {
if (typeof value !== 'object' || value === null) {
return { error: RUNTIME_NATIVE_CHAT_READ_ERROR }
}
const record = value as Record<string, unknown>
if (Array.isArray(record.messages)) {
const lifecycle = parseRuntimeNativeChatTurnLifecycle(record.lifecycle)
return {
messages: record.messages as NativeChatAppendedMessages,
...(lifecycle ? { lifecycle } : {})
}
}
if (typeof record.error === 'string') {
return {
error: record.error,
...(record.notFound === true ? { notFound: true } : {})
}
}
return { error: RUNTIME_NATIVE_CHAT_READ_ERROR }
}

View File

@ -15,10 +15,12 @@ import {
sendNativeChatMessageWithImageAttachments,
submitNativeChatPrompt,
sendNativeChatAskAnswer,
resetNativeChatPtySendQueuesForTests,
NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS,
NATIVE_CHAT_SUBMIT_DELAY_MS,
NATIVE_CHAT_QUESTION_STEP_MS,
NATIVE_CHAT_ADVANCE_BUFFER_MS
NATIVE_CHAT_ADVANCE_BUFFER_MS,
NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT
} from './native-chat-runtime-send'
import {
buildNativeChatImagePasteBytes,
@ -29,25 +31,28 @@ import {
const SETTINGS = {} as Parameters<typeof sendNativeChatMessage>[0]
const PTY = 'pty-1'
function expectWriteOrder(calls: unknown[][], expected: string[]): void {
expect(calls.map((call) => call[2])).toEqual(expected)
}
describe('sendNativeChatMessage', () => {
beforeEach(() => {
vi.useFakeTimers()
sendRuntimePtyInput.mockClear()
resetNativeChatPtySendQueuesForTests()
sendRuntimePtyInput.mockReturnValue(true)
})
afterEach(() => {
vi.useRealTimers()
resetNativeChatPtySendQueuesForTests()
})
it('writes the framed body immediately, before the Enter', () => {
it('clears the TUI line, then writes the framed body, before the Enter', () => {
const handle = sendNativeChatMessage(SETTINGS, PTY, 'hello world')
// Body lands synchronously; Enter is still pending on the timer.
expect(sendRuntimePtyInput).toHaveBeenCalledTimes(1)
expect(sendRuntimePtyInput).toHaveBeenCalledWith(
SETTINGS,
PTY,
expectWriteOrder(sendRuntimePtyInput.mock.calls, [
NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT,
buildNativeChatPasteBytes('hello world')
)
])
expect(handle.settleAfterMs).toBe(NATIVE_CHAT_SUBMIT_DELAY_MS)
})
@ -56,43 +61,119 @@ describe('sendNativeChatMessage', () => {
// A short gap would fire Enter while a busy Codex has not yet landed the
// paste, submitting an empty box — so nothing must happen before 500ms.
vi.advanceTimersByTime(NATIVE_CHAT_SUBMIT_DELAY_MS - 1)
expect(sendRuntimePtyInput).toHaveBeenCalledTimes(1)
expect(sendRuntimePtyInput).toHaveBeenCalledTimes(2)
})
it('writes the bare carriage-return Enter as a separate delayed write', () => {
sendNativeChatMessage(SETTINGS, PTY, 'hi')
vi.advanceTimersByTime(NATIVE_CHAT_SUBMIT_DELAY_MS)
expect(sendRuntimePtyInput).toHaveBeenCalledTimes(2)
expect(sendRuntimePtyInput).toHaveBeenLastCalledWith(SETTINGS, PTY, NATIVE_CHAT_SUBMIT)
expectWriteOrder(sendRuntimePtyInput.mock.calls, [
NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT,
buildNativeChatPasteBytes('hi'),
NATIVE_CHAT_SUBMIT
])
})
it('cancels the delayed Enter when its owning composer is detached', () => {
it('cancels the delayed Enter and re-clears an unsubmitted body', () => {
const handle = sendNativeChatMessage(SETTINGS, PTY, 'hi')
handle.cancel()
vi.advanceTimersByTime(NATIVE_CHAT_SUBMIT_DELAY_MS)
expect(sendRuntimePtyInput).toHaveBeenCalledTimes(1)
// Pre-send clear + body + cancel clear; Enter must not fire.
expectWriteOrder(sendRuntimePtyInput.mock.calls, [
NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT,
buildNativeChatPasteBytes('hi'),
NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT
])
})
it('clears leftover unsubmitted body on cancel so the next send cannot glue', async () => {
const handle = sendNativeChatMessage(SETTINGS, PTY, 'tell me a joke')
handle.cancel()
sendNativeChatMessage(SETTINGS, PTY, 'continue')
// Queue release after cancel is promise-chained; flush so the next body runs.
await Promise.resolve()
await Promise.resolve()
expect(sendRuntimePtyInput.mock.calls.map((call) => call[2])).toEqual([
NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT,
buildNativeChatPasteBytes('tell me a joke'),
NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT, // cancel cleanup
NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT, // next send pre-clear
buildNativeChatPasteBytes('continue')
])
})
it('does not clear the TUI input when cancel runs after Enter already fired', () => {
const handle = sendNativeChatMessage(SETTINGS, PTY, 'already submitted')
vi.advanceTimersByTime(NATIVE_CHAT_SUBMIT_DELAY_MS)
sendRuntimePtyInput.mockClear()
handle.cancel()
expect(sendRuntimePtyInput).not.toHaveBeenCalled()
})
it('matches orca-runtime writeTerminalAction Enter gap (500ms)', () => {
expect(NATIVE_CHAT_SUBMIT_DELAY_MS).toBe(500)
})
it('serializes rapid sends on the same PTY so bodies cannot glue before Enter', async () => {
sendNativeChatMessage(SETTINGS, PTY, 'tell me a joke')
sendNativeChatMessage(SETTINGS, PTY, 'continue')
// First clear+body are immediate; second sequence waits for the first Enter.
expectWriteOrder(sendRuntimePtyInput.mock.calls, [
NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT,
buildNativeChatPasteBytes('tell me a joke')
])
await vi.advanceTimersByTimeAsync(NATIVE_CHAT_SUBMIT_DELAY_MS)
expectWriteOrder(sendRuntimePtyInput.mock.calls, [
NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT,
buildNativeChatPasteBytes('tell me a joke'),
NATIVE_CHAT_SUBMIT,
NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT,
buildNativeChatPasteBytes('continue')
])
await vi.advanceTimersByTimeAsync(NATIVE_CHAT_SUBMIT_DELAY_MS)
expect(sendRuntimePtyInput).toHaveBeenLastCalledWith(SETTINGS, PTY, NATIVE_CHAT_SUBMIT)
expect(sendRuntimePtyInput).toHaveBeenCalledTimes(6)
})
it('does not serialize sends across different PTYs', () => {
sendNativeChatMessage(SETTINGS, 'pty-a', 'one')
sendNativeChatMessage(SETTINGS, 'pty-b', 'two')
expectWriteOrder(sendRuntimePtyInput.mock.calls, [
NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT,
buildNativeChatPasteBytes('one'),
NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT,
buildNativeChatPasteBytes('two')
])
expect(sendRuntimePtyInput.mock.calls[1]?.[1]).toBe('pty-a')
expect(sendRuntimePtyInput.mock.calls[3]?.[1]).toBe('pty-b')
})
})
describe('sendNativeChatMessageVerified', () => {
beforeEach(() => {
vi.useFakeTimers()
sendRuntimePtyInputVerified.mockReset().mockResolvedValue(true)
resetNativeChatPtySendQueuesForTests()
})
afterEach(() => {
vi.useRealTimers()
resetNativeChatPtySendQueuesForTests()
})
it('awaits body acceptance before the delayed Enter write', async () => {
it('awaits body acceptance before the delayed Enter write (no pre-clear)', async () => {
// Why: model-switch confirmation watches the PTY while this send runs;
// verified option commands must not inject Ctrl+U noise.
const result = sendNativeChatMessageVerified(SETTINGS, PTY, '/model sonnet')
await Promise.resolve()
expect(sendRuntimePtyInputVerified).toHaveBeenCalledTimes(1)
await vi.waitFor(() => {
expect(sendRuntimePtyInputVerified).toHaveBeenCalledTimes(1)
})
expect(sendRuntimePtyInputVerified).toHaveBeenCalledWith(
SETTINGS,
PTY,
@ -103,6 +184,11 @@ describe('sendNativeChatMessageVerified', () => {
expect(await result).toBe(true)
expect(sendRuntimePtyInputVerified).toHaveBeenLastCalledWith(SETTINGS, PTY, NATIVE_CHAT_SUBMIT)
expect(
sendRuntimePtyInputVerified.mock.calls.some(
(call) => call[2] === NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT
)
).toBe(false)
})
it('does not send Enter when the body is rejected', async () => {
@ -112,16 +198,40 @@ describe('sendNativeChatMessageVerified', () => {
await vi.runAllTimersAsync()
expect(sendRuntimePtyInputVerified).toHaveBeenCalledTimes(1)
expect(
sendRuntimePtyInputVerified.mock.calls.some((call) => call[2] === NATIVE_CHAT_SUBMIT)
).toBe(false)
})
it('cancels the pending Enter when its composer detaches', async () => {
it('cancels an in-flight chat Enter before delivering a verified option command', async () => {
sendNativeChatMessage(SETTINGS, PTY, 'hello')
expect(sendRuntimePtyInput).toHaveBeenCalled()
const result = sendNativeChatMessageVerified(SETTINGS, PTY, '/model haiku')
// Chat cancel may Ctrl+U the unsubmitted body; Enter from chat must not fire.
await Promise.resolve()
await Promise.resolve()
await vi.advanceTimersByTimeAsync(NATIVE_CHAT_SUBMIT_DELAY_MS)
expect(await result).toBe(true)
const submits = sendRuntimePtyInput.mock.calls.filter((call) => call[2] === NATIVE_CHAT_SUBMIT)
// Only the verified path's Enter — chat's delayed Enter was cancelled.
expect(submits).toHaveLength(0)
expect(sendRuntimePtyInputVerified).toHaveBeenCalledWith(SETTINGS, PTY, NATIVE_CHAT_SUBMIT)
})
it('returns false when the delayed Enter wait is aborted', async () => {
const controller = new AbortController()
const result = sendNativeChatMessageVerified(SETTINGS, PTY, '/model sonnet', controller.signal)
await Promise.resolve()
await vi.waitFor(() => {
expect(sendRuntimePtyInputVerified).toHaveBeenCalledTimes(1)
})
controller.abort()
expect(await result).toBe(false)
expect(sendRuntimePtyInputVerified).toHaveBeenCalledTimes(1)
expect(
sendRuntimePtyInputVerified.mock.calls.some((call) => call[2] === NATIVE_CHAT_SUBMIT)
).toBe(false)
})
})
@ -129,12 +239,14 @@ describe('sendNativeChatMessageWithImageAttachments', () => {
beforeEach(() => {
vi.useFakeTimers()
sendRuntimePtyInput.mockClear()
resetNativeChatPtySendQueuesForTests()
})
afterEach(() => {
vi.useRealTimers()
resetNativeChatPtySendQueuesForTests()
})
it('bracket-pastes image paths before prompt text so the TUI creates image chips', () => {
it('clears the line, then bracket-pastes image paths before prompt text', () => {
const handle = sendNativeChatMessageWithImageAttachments(SETTINGS, PTY, 'what do you see?', [
'/tmp/orca-paste-image.png'
])
@ -143,15 +255,12 @@ describe('sendNativeChatMessageWithImageAttachments', () => {
NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS + NATIVE_CHAT_SUBMIT_DELAY_MS
)
expect(sendRuntimePtyInput).toHaveBeenCalledTimes(1)
expect(sendRuntimePtyInput).toHaveBeenLastCalledWith(
SETTINGS,
PTY,
expectWriteOrder(sendRuntimePtyInput.mock.calls, [
NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT,
buildNativeChatImagePasteBytes('/tmp/orca-paste-image.png')
)
])
vi.advanceTimersByTime(NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS)
expect(sendRuntimePtyInput).toHaveBeenCalledTimes(2)
expect(sendRuntimePtyInput).toHaveBeenLastCalledWith(
SETTINGS,
PTY,
@ -159,8 +268,8 @@ describe('sendNativeChatMessageWithImageAttachments', () => {
)
vi.advanceTimersByTime(NATIVE_CHAT_SUBMIT_DELAY_MS)
expect(sendRuntimePtyInput).toHaveBeenCalledTimes(3)
expect(sendRuntimePtyInput).toHaveBeenLastCalledWith(SETTINGS, PTY, NATIVE_CHAT_SUBMIT)
expect(sendRuntimePtyInput).toHaveBeenCalledTimes(4)
})
it('waits the normal submit gap for an attachment-only send', () => {
@ -170,11 +279,16 @@ describe('sendNativeChatMessageWithImageAttachments', () => {
expect(handle.settleAfterMs).toBe(NATIVE_CHAT_SUBMIT_DELAY_MS)
expectWriteOrder(sendRuntimePtyInput.mock.calls, [
NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT,
buildNativeChatImagePasteBytes('/tmp/orca-paste-image.png')
])
vi.advanceTimersByTime(NATIVE_CHAT_SUBMIT_DELAY_MS - 1)
expect(sendRuntimePtyInput).toHaveBeenCalledTimes(1)
expect(sendRuntimePtyInput).toHaveBeenCalledTimes(2)
vi.advanceTimersByTime(1)
expect(sendRuntimePtyInput).toHaveBeenCalledTimes(2)
expect(sendRuntimePtyInput).toHaveBeenCalledTimes(3)
expect(sendRuntimePtyInput).toHaveBeenLastCalledWith(SETTINGS, PTY, NATIVE_CHAT_SUBMIT)
})
@ -185,7 +299,15 @@ describe('sendNativeChatMessageWithImageAttachments', () => {
handle.cancel()
vi.runAllTimers()
expect(sendRuntimePtyInput).toHaveBeenCalledTimes(1)
// Pre-clear + image body + cancel clear; no Enter.
expectWriteOrder(sendRuntimePtyInput.mock.calls, [
NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT,
buildNativeChatImagePasteBytes('/tmp/orca-paste-image.png'),
NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT
])
expect(sendRuntimePtyInput.mock.calls.some((call) => call[2] === NATIVE_CHAT_SUBMIT)).toBe(
false
)
})
})
@ -196,9 +318,8 @@ describe('empty prompt submit', () => {
it('submits an empty prompt with a bare Enter', () => {
submitNativeChatPrompt(SETTINGS, PTY)
expect(sendRuntimePtyInput).toHaveBeenCalledTimes(1)
expect(sendRuntimePtyInput).toHaveBeenLastCalledWith(SETTINGS, PTY, NATIVE_CHAT_SUBMIT)
expect(sendRuntimePtyInput).toHaveBeenCalledOnce()
expect(sendRuntimePtyInput).toHaveBeenCalledWith(SETTINGS, PTY, NATIVE_CHAT_SUBMIT)
})
})
@ -213,72 +334,43 @@ describe('sendNativeChatAskAnswer', () => {
vi.useRealTimers()
})
it('paces each keystroke a full step apart (the proven submit gap + advance buffer)', () => {
expect(NATIVE_CHAT_QUESTION_STEP_MS).toBe(1000)
expect(NATIVE_CHAT_ADVANCE_BUFFER_MS).toBe(500)
})
it('no writes and 0 settle for an empty keystroke list', () => {
it('returns a no-op handle for an empty key group list', () => {
const handle = sendNativeChatAskAnswer(SETTINGS, PTY, [])
expect(handle.settleAfterMs).toBe(0)
vi.runAllTimers()
expect(sendRuntimePtyInput).toHaveBeenCalledTimes(0)
handle.cancel()
expect(sendRuntimePtyInput).not.toHaveBeenCalled()
})
it('single option-number keystroke: fires at t=0, settles a submit gap later', () => {
// The STA-1860 fix: a lone single-select pick is delivered as the option
// NUMBER, with no trailing Enter (the number both selects and commits).
const handle = sendNativeChatAskAnswer(SETTINGS, PTY, [{ raw: '2' }])
expect(handle.settleAfterMs).toBe(NATIVE_CHAT_SUBMIT_DELAY_MS)
// Scheduled at t=0 (setTimeout 0), not written synchronously.
expect(sendRuntimePtyInput).toHaveBeenCalledTimes(0)
vi.advanceTimersByTime(0)
expect(sendRuntimePtyInput).toHaveBeenCalledTimes(1)
expect(sendRuntimePtyInput).toHaveBeenLastCalledWith(SETTINGS, PTY, '2')
vi.runAllTimers()
expect(sendRuntimePtyInput).toHaveBeenCalledTimes(1)
})
it('writes each group a step apart; text groups go through the paste framer', () => {
const groups = [{ raw: '3' }, { text: 'custom answer' }, { raw: '\r' }]
const handle = sendNativeChatAskAnswer(SETTINGS, PTY, groups)
it('paces key groups so selector steps render before the next write', () => {
const handle = sendNativeChatAskAnswer(SETTINGS, PTY, [
{ raw: '1' },
{ raw: '2' },
{ text: 'custom answer' }
])
expect(handle.settleAfterMs).toBe(
2 * NATIVE_CHAT_QUESTION_STEP_MS + NATIVE_CHAT_SUBMIT_DELAY_MS
)
vi.advanceTimersByTime(0)
expect(sendRuntimePtyInput).toHaveBeenLastCalledWith(SETTINGS, PTY, '3')
expect(sendRuntimePtyInput).toHaveBeenCalledWith(SETTINGS, PTY, '1')
// The next group must wait a full step so the "Type something" row renders.
vi.advanceTimersByTime(NATIVE_CHAT_QUESTION_STEP_MS - 1)
expect(sendRuntimePtyInput).toHaveBeenCalledTimes(1)
vi.advanceTimersByTime(1)
vi.advanceTimersByTime(NATIVE_CHAT_QUESTION_STEP_MS)
expect(sendRuntimePtyInput).toHaveBeenCalledWith(SETTINGS, PTY, '2')
vi.advanceTimersByTime(NATIVE_CHAT_QUESTION_STEP_MS)
expect(sendRuntimePtyInput).toHaveBeenLastCalledWith(
SETTINGS,
PTY,
buildNativeChatPasteBytes('custom answer')
)
vi.advanceTimersByTime(NATIVE_CHAT_QUESTION_STEP_MS)
expect(sendRuntimePtyInput).toHaveBeenLastCalledWith(SETTINGS, PTY, NATIVE_CHAT_SUBMIT)
vi.runAllTimers()
const calls = sendRuntimePtyInput.mock.calls.map((c) => c[2])
expect(calls).toEqual(['3', buildNativeChatPasteBytes('custom answer'), NATIVE_CHAT_SUBMIT])
})
it('cancel clears every pending keystroke', () => {
const handle = sendNativeChatAskAnswer(SETTINGS, PTY, [
{ raw: '1' },
{ raw: '\x1b[C' },
{ raw: '\r' }
])
it('cancels remaining key group timers', () => {
const handle = sendNativeChatAskAnswer(SETTINGS, PTY, [{ raw: '1' }, { raw: '2' }])
vi.advanceTimersByTime(0)
expect(sendRuntimePtyInput).toHaveBeenCalledTimes(1)
handle.cancel()
vi.runAllTimers()
// Only the first keystroke landed; the rest were cancelled.
vi.advanceTimersByTime(NATIVE_CHAT_QUESTION_STEP_MS * 2)
expect(sendRuntimePtyInput).toHaveBeenCalledTimes(1)
})
@ -317,3 +409,9 @@ describe('sendNativeChatAskAnswer', () => {
await vi.waitFor(() => expect(onSettled).toHaveBeenCalledExactlyOnceWith(true))
})
})
describe('constants', () => {
it('exports the ask-answer advance buffer used by interactive cards', () => {
expect(NATIVE_CHAT_ADVANCE_BUFFER_MS).toBeGreaterThan(0)
})
})

View File

@ -1,6 +1,6 @@
// Runtime send for native chat: writes the framed message body, then the Enter
// as a SEPARATE delayed pty write. Kept apart from the pure byte builders in
// native-chat-send.ts so those stay IO-free and unit-testable without aliases.
// Runtime send for native chat: clear any unsubmitted TUI line, write the framed
// body, then Enter as a SEPARATE delayed pty write. Kept apart from the pure
// byte builders in native-chat-send.ts so those stay IO-free and unit-testable.
import {
sendRuntimePtyInput,
@ -18,11 +18,24 @@ import {
buildNativeChatPasteBytes,
NATIVE_CHAT_SUBMIT
} from './native-chat-send'
import {
cancelNativeChatPtySends,
enqueueNativeChatPtySend,
resetNativeChatPtySendQueuesForTests,
waitForNativeChatPtyIdle
} from './native-chat-pty-send-queue'
export { NATIVE_CHAT_ADVANCE_BUFFER_MS, NATIVE_CHAT_QUESTION_STEP_MS, NATIVE_CHAT_SUBMIT_DELAY_MS }
export { resetNativeChatPtySendQueuesForTests }
export const NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS = 300
// Why: agent TUI composers treat Ctrl+U as kill-to-start-of-line. Chat sends
// start from an empty line so a prior cancelled paste cannot glue onto the next
// prompt. Not used on verified option commands — model-switch confirmation
// observes the PTY and Ctrl+U can miss confirmation markers.
export const NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT = '\x15'
/** Cancels an in-flight send's pending pty writes (the delayed Enter, and any
* later question bodies/Enters). Safe to call after the send completes. */
export type NativeChatSendHandle = {
@ -31,22 +44,46 @@ export type NativeChatSendHandle = {
settleAfterMs: number
}
type RuntimeSettings = ReturnType<typeof getSettingsForAgentTabRuntimeOwner>
function clearUnsubmittedAgentInput(settings: RuntimeSettings, ptyId: string): void {
sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT)
}
/**
* Send a native-chat message through the verified runtime pty path: framed body
* first, then a separate delayed Enter. `sendRuntimePtyInput` branches local
* pty:write vs remote runtime RPC, so this works for SSH panes too. Returns a
* cancel handle so callers can drop the still-pending Enter on unmount/stop.
* Chat message path:
* 1. clear any unsubmitted TUI line
* 2. write framed body
* 3. delayed Enter (separate write same-write CR can be swallowed by paste)
*
* Serialized per PTY so rapid sends cannot glue before Enter.
*/
export function sendNativeChatMessage(
settings: ReturnType<typeof getSettingsForAgentTabRuntimeOwner>,
settings: RuntimeSettings,
ptyId: string,
text: string
): NativeChatSendHandle {
sendRuntimePtyInput(settings, ptyId, buildNativeChatPasteBytes(text))
const timer = setTimeout(() => {
sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_SUBMIT)
}, NATIVE_CHAT_SUBMIT_DELAY_MS)
return { cancel: () => clearTimeout(timer), settleAfterMs: NATIVE_CHAT_SUBMIT_DELAY_MS }
return enqueueNativeChatPtySend(
ptyId,
NATIVE_CHAT_SUBMIT_DELAY_MS,
({ isCancelled, delay, markSubmitted }) => {
if (isCancelled()) {
return
}
clearUnsubmittedAgentInput(settings, ptyId)
if (isCancelled()) {
return
}
sendRuntimePtyInput(settings, ptyId, buildNativeChatPasteBytes(text))
delay(NATIVE_CHAT_SUBMIT_DELAY_MS, () => {
sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_SUBMIT)
markSubmitted()
})
},
{
onCancelUnsubmitted: () => clearUnsubmittedAgentInput(settings, ptyId)
}
)
}
function waitForNativeChatSubmit(signal?: AbortSignal): Promise<boolean> {
@ -70,12 +107,28 @@ function waitForNativeChatSubmit(signal?: AbortSignal): Promise<boolean> {
})
}
/**
* Session-option / slash command path (model switch, /effort, ).
*
* Does not pre-clear the line (model-switch confirmation watches the PTY).
* Cancels any in-flight chat clear/body/Enter on this PTY first so a delayed
* chat Enter cannot dismiss Claude's "Switch model?" dialog.
*/
export async function sendNativeChatMessageVerified(
settings: ReturnType<typeof getSettingsForAgentTabRuntimeOwner>,
settings: RuntimeSettings,
ptyId: string,
text: string,
signal?: AbortSignal
): Promise<boolean> {
// Why: chat sends hold a delayed Enter for 500ms. Opening the model picker in
// that window used to let that Enter hit Claude's confirmation UI, so
// verification timed out with "Could not verify the model change".
cancelNativeChatPtySends(ptyId)
await waitForNativeChatPtyIdle(ptyId)
if (signal?.aborted) {
return false
}
// Why: option commands await remote/SSH acceptance so the Enter cannot race
// ahead of the body while a model-change observer is already armed.
const bodyAccepted = await sendRuntimePtyInputVerified(
@ -90,7 +143,7 @@ export async function sendNativeChatMessageVerified(
}
export function sendNativeChatMessageWithImageAttachments(
settings: ReturnType<typeof getSettingsForAgentTabRuntimeOwner>,
settings: RuntimeSettings,
ptyId: string,
text: string,
imagePaths: readonly string[]
@ -98,62 +151,59 @@ export function sendNativeChatMessageWithImageAttachments(
if (imagePaths.length === 0) {
return sendNativeChatMessage(settings, ptyId, text)
}
const timers: ReturnType<typeof setTimeout>[] = []
for (const imagePath of imagePaths) {
sendRuntimePtyInput(settings, ptyId, buildNativeChatImagePasteBytes(imagePath))
}
const trimmedText = text.trim()
if (trimmedText.length > 0) {
timers.push(
setTimeout(() => {
sendRuntimePtyInput(settings, ptyId, buildNativeChatPasteBytes(text))
}, NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS)
)
}
timers.push(
setTimeout(
() => {
sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_SUBMIT)
},
trimmedText.length > 0
? NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS + NATIVE_CHAT_SUBMIT_DELAY_MS
: NATIVE_CHAT_SUBMIT_DELAY_MS
)
)
return {
cancel: () => {
for (const timer of timers) {
clearTimeout(timer)
const durationMs =
trimmedText.length > 0
? NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS + NATIVE_CHAT_SUBMIT_DELAY_MS
: NATIVE_CHAT_SUBMIT_DELAY_MS
return enqueueNativeChatPtySend(
ptyId,
durationMs,
({ isCancelled, delay, markSubmitted }) => {
if (isCancelled()) {
return
}
clearUnsubmittedAgentInput(settings, ptyId)
if (isCancelled()) {
return
}
for (const imagePath of imagePaths) {
sendRuntimePtyInput(settings, ptyId, buildNativeChatImagePasteBytes(imagePath))
}
if (trimmedText.length > 0) {
delay(NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS, () => {
sendRuntimePtyInput(settings, ptyId, buildNativeChatPasteBytes(text))
})
delay(NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS + NATIVE_CHAT_SUBMIT_DELAY_MS, () => {
sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_SUBMIT)
markSubmitted()
})
return
}
delay(NATIVE_CHAT_SUBMIT_DELAY_MS, () => {
sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_SUBMIT)
markSubmitted()
})
},
settleAfterMs:
trimmedText.length > 0
? NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS + NATIVE_CHAT_SUBMIT_DELAY_MS
: NATIVE_CHAT_SUBMIT_DELAY_MS
}
{
onCancelUnsubmitted: () => clearUnsubmittedAgentInput(settings, ptyId)
}
)
}
/** Submit a TUI prompt with no body (Enter only) e.g. a plain submit when the
* composer is empty. */
export function submitNativeChatPrompt(
settings: ReturnType<typeof getSettingsForAgentTabRuntimeOwner>,
ptyId: string
): void {
export function submitNativeChatPrompt(settings: RuntimeSettings, ptyId: string): void {
sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_SUBMIT)
}
/**
* Answer Claude's AskUserQuestion by writing its keystroke groups (built by
* `buildAskAnswerKeys`) to the PTY, one group per `NATIVE_CHAT_QUESTION_STEP_MS`
* step so the arrow-navigate selector applies each before the next a
* navigation/number keystroke batched with the Enter that follows would commit
* the wrong (default) option. `raw` groups are written verbatim as keystrokes;
* `text` groups (a free-text answer) go through the composer's paste framing.
* Returns a cancel handle clearing every pending timer so a detached sequence
* can't keep writing PTY bytes after unmount/stop.
* step so the arrow-navigate selector applies each before the next.
*/
export function sendNativeChatAskAnswer(
settings: ReturnType<typeof getSettingsForAgentTabRuntimeOwner>,
settings: RuntimeSettings,
ptyId: string,
groups: AskAnswerKeyGroup[],
onSettled?: (delivered: boolean) => void

View File

@ -82,6 +82,59 @@ describe('getNativeChatSessionTransport — selection', () => {
expect(nativeChatReadSession).not.toHaveBeenCalled()
})
it('validates lifecycle metadata on runtime read responses', async () => {
markRuntimeEnvironmentCompatible(ENV)
const lifecycle = { state: 'completed', turnId: 'turn-1', timestamp: 42 } as const
runtimeEnvironmentsCall
.mockResolvedValueOnce(okEnvelope({ messages: [message('valid')], lifecycle }))
.mockResolvedValueOnce(
okEnvelope({
messages: [message('invalid')],
lifecycle: { state: 'completed', turnId: '', timestamp: undefined }
})
)
const transport = getNativeChatSessionTransport(ENV)
await expect(transport.readSession('claude', 'sess-1')).resolves.toEqual({
messages: [message('valid')],
lifecycle
})
// An invalid lifecycle payload is dropped, leaving prose recovery to settle.
await expect(transport.readSession('claude', 'sess-1')).resolves.toEqual({
messages: [message('invalid')]
})
})
it('accepts interrupted lifecycle metadata from a remote runtime', async () => {
markRuntimeEnvironmentCompatible(ENV)
const lifecycle = { state: 'interrupted', turnId: 'turn-2', timestamp: 43 } as const
runtimeEnvironmentsCall.mockResolvedValueOnce(
okEnvelope({ messages: [message('interrupted')], lifecycle })
)
const transport = getNativeChatSessionTransport(ENV)
await expect(transport.readSession('codex', 'sess-1')).resolves.toEqual({
messages: [message('interrupted')],
lifecycle
})
})
it('keeps lifecycle metadata whose timestamp is omitted, normalizing it to null', async () => {
markRuntimeEnvironmentCompatible(ENV)
runtimeEnvironmentsCall.mockResolvedValueOnce(
okEnvelope({
messages: [message('no-ts')],
lifecycle: { state: 'completed', turnId: 'turn-3' }
})
)
const transport = getNativeChatSessionTransport(ENV)
await expect(transport.readSession('claude', 'sess-1')).resolves.toEqual({
messages: [message('no-ts')],
lifecycle: { state: 'completed', turnId: 'turn-3', timestamp: null }
})
})
it('returns the local adapter on the web client even with an owner (R3 guard)', async () => {
;(window as unknown as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ = true
nativeChatReadSession.mockResolvedValue({ messages: [] })
@ -153,6 +206,64 @@ describe('runtime subscribe', () => {
})
})
it('validates lifecycle metadata on runtime stream frames', async () => {
markRuntimeEnvironmentCompatible(ENV)
const { deliver } = stubSubscribe()
const onFrame = vi.fn()
const transport = getNativeChatSessionTransport(ENV)
const lifecycle = { state: 'completed', turnId: 'turn-1', timestamp: 42 } as const
transport.subscribe({ subscriptionId: 's-1', agent: 'claude', sessionId: 'sess-1' }, onFrame)
await Promise.resolve()
deliver({
type: 'snapshot',
messages: [message('valid')],
lifecycle
})
deliver({
type: 'appended',
messages: [message('invalid')],
lifecycle: { state: 'completed', turnId: '', timestamp: undefined }
})
expect(onFrame).toHaveBeenNthCalledWith(1, {
type: 'snapshot',
messages: [message('valid')],
hasMore: false,
lifecycle
})
expect(onFrame).toHaveBeenNthCalledWith(2, {
type: 'appended',
messages: [message('invalid')]
})
})
it('omits an invalid lifecycle payload from a stream frame', async () => {
markRuntimeEnvironmentCompatible(ENV)
const { deliver } = stubSubscribe()
const onFrame = vi.fn()
const transport = getNativeChatSessionTransport(ENV)
transport.subscribe({ subscriptionId: 's-1', agent: 'claude', sessionId: 'sess-1' }, onFrame)
await Promise.resolve()
deliver({
type: 'snapshot',
messages: [message('seed')],
hasMore: false,
lifecycle: { state: 'completed', turnId: 'turn-ok', timestamp: 1 }
})
deliver({
type: 'appended',
messages: [message('bad-lifecycle')],
lifecycle: { state: 'completed', turnId: '', timestamp: undefined }
})
expect(onFrame).toHaveBeenNthCalledWith(2, {
type: 'appended',
messages: [message('bad-lifecycle')]
})
})
it('settles with an empty snapshot when the first ok frame is an unrecognized shape', async () => {
markRuntimeEnvironmentCompatible(ENV)
const { deliver } = stubSubscribe()

View File

@ -1,8 +1,4 @@
import type {
NativeChatApi,
NativeChatAppendedMessages,
NativeChatReadSessionResult
} from '../../../../preload/api-types'
import type { NativeChatApi, NativeChatAppendedMessages } from '../../../../preload/api-types'
import { isWebClientLocation } from '@/lib/web-client-location'
import {
callRuntimeRpc,
@ -10,6 +6,11 @@ import {
type RuntimeClientTarget
} from '@/runtime/runtime-rpc-client'
import { isRuntimeCompatBlockError } from '@/runtime/runtime-protocol-compat'
import {
parseRuntimeNativeChatReadSessionResult,
parseRuntimeNativeChatTurnLifecycle,
RUNTIME_NATIVE_CHAT_READ_ERROR
} from './native-chat-runtime-contract'
/** The read/subscribe surface the live-session hook needs, decoupled from where
* the transcript actually lives. Same shape as `window.api.nativeChat`, so the
@ -34,7 +35,7 @@ export function toRuntimeNativeChatErrorMessage(err: unknown): string {
if (isRuntimeCompatBlockError(err)) {
return RUNTIME_TOO_OLD
}
return "Couldn't read agent chat from the remote runtime."
return RUNTIME_NATIVE_CHAT_READ_ERROR
}
/** Delegates straight to the local Electron IPC bridge. On the web client
@ -53,12 +54,13 @@ function createRuntimeNativeChatTransport(environmentId: string): NativeChatSess
return {
readSession: async (agent, sessionId, limit, transcriptPath) => {
try {
return await callRuntimeRpc<NativeChatReadSessionResult>(
const result = await callRuntimeRpc<unknown>(
target,
'nativeChat.readSession',
{ agent, sessionId, limit, transcriptPath },
{ timeoutMs: 15_000 }
)
return parseRuntimeNativeChatReadSessionResult(result)
} catch (err) {
return { error: toRuntimeNativeChatErrorMessage(err) }
}
@ -131,7 +133,9 @@ function createRuntimeNativeChatTransport(environmentId: string): NativeChatSess
messages?: NativeChatAppendedMessages
hasMore?: boolean
error?: string
lifecycle?: unknown
}
const lifecycle = parseRuntimeNativeChatTurnLifecycle(frame?.lifecycle)
if (
(frame?.type === 'appended' ||
frame?.type === 'snapshot' ||
@ -144,14 +148,16 @@ function createRuntimeNativeChatTransport(environmentId: string): NativeChatSess
type: 'snapshot',
messages: frame.messages,
hasMore: frame.hasMore ?? frame.messages.length >= (limit ?? 300),
...(frame.error ? { error: frame.error } : {})
...(frame.error ? { error: frame.error } : {}),
...(lifecycle ? { lifecycle } : {})
})
} else if (frame.type === 'snapshot') {
onFrame({
type: 'snapshot',
messages: frame.messages,
hasMore: frame.hasMore ?? false,
...(frame.error ? { error: frame.error } : {})
...(frame.error ? { error: frame.error } : {}),
...(lifecycle ? { lifecycle } : {})
})
} else {
onFrame(
@ -159,9 +165,14 @@ function createRuntimeNativeChatTransport(environmentId: string): NativeChatSess
? {
type: 'replacement',
messages: frame.messages,
hasMore: frame.hasMore ?? false
hasMore: frame.hasMore ?? false,
...(lifecycle ? { lifecycle } : {})
}
: {
type: 'appended',
messages: frame.messages,
...(lifecycle ? { lifecycle } : {})
}
: { type: 'appended', messages: frame.messages }
)
}
} else if (!receivedInitial) {

View File

@ -9,8 +9,7 @@ describe('native chat working suppression', () => {
expect(
shouldShowNativeChatWorking({
isConversation: true,
viewWorking: true,
hookWorking: true,
working: true,
interrupted: true
})
).toBe(false)
@ -20,19 +19,33 @@ describe('native chat working suppression', () => {
expect(
shouldShowNativeChatWorking({
isConversation: true,
viewWorking: false,
hookWorking: true,
working: true,
interrupted: false
})
).toBe(true)
})
it('clears suppression only after all working signals clear', () => {
expect(shouldClearNativeChatWorkingSuppression({ viewWorking: true, hookWorking: false })).toBe(
false
)
it('clears suppression after reconciled working clears', () => {
expect(shouldClearNativeChatWorkingSuppression({ working: true })).toBe(false)
expect(shouldClearNativeChatWorkingSuppression({ working: false })).toBe(true)
})
it('clears suppression when a newer working epoch starts while interrupted', () => {
expect(
shouldClearNativeChatWorkingSuppression({ viewWorking: false, hookWorking: false })
shouldClearNativeChatWorkingSuppression({
working: true,
interrupted: true,
workingEpoch: 20,
previousWorkingEpoch: 10
})
).toBe(true)
expect(
shouldClearNativeChatWorkingSuppression({
working: true,
interrupted: true,
workingEpoch: 10,
previousWorkingEpoch: 10
})
).toBe(false)
})
})

View File

@ -1,16 +1,35 @@
export function shouldShowNativeChatWorking(args: {
isConversation: boolean
viewWorking: boolean
hookWorking: boolean
working: boolean
interrupted: boolean
}): boolean {
const rawWorking = args.isConversation && (args.viewWorking || args.hookWorking)
return rawWorking && !args.interrupted
return args.isConversation && args.working && !args.interrupted
}
/**
* Clear local Stop suppression when live work ends, or when a newer working
* epoch starts while suppressed (Stop immediate next turn without a ready gap).
*/
export function shouldClearNativeChatWorkingSuppression(args: {
viewWorking: boolean
hookWorking: boolean
working: boolean
interrupted?: boolean
/** Hook `stateStartedAt` for the current working epoch, when known. */
workingEpoch?: number | null
/** Previous observed working epoch; used to detect a new generation. */
previousWorkingEpoch?: number | null
}): boolean {
return !args.viewWorking && !args.hookWorking
if (!args.working) {
return true
}
// Why: interrupt + next-turn can coalesce so `working` never goes false; a
// newer epoch means the user started another generation and must see it.
if (
args.interrupted === true &&
args.workingEpoch != null &&
args.previousWorkingEpoch != null &&
args.workingEpoch > args.previousWorkingEpoch
) {
return true
}
return false
}

View File

@ -0,0 +1,27 @@
import { useAppStore } from '../../store'
import type { AgentStatusState } from '../../../../shared/agent-status-types'
export function useNativeChatHookStatus(
paneKey: string
): readonly [AgentStatusState | null, number | null, boolean] {
// Why: primitive selectors keep unrelated pane/status updates from rerendering
// native chat while still exposing the three fields used for reconciliation.
const state = useAppStore((store) => store.agentStatusByPaneKey[paneKey]?.state ?? null)
const stateStartedAt = useAppStore(
(store) => store.agentStatusByPaneKey[paneKey]?.stateStartedAt ?? null
)
// Why: only children that started during the current parent working epoch can
// keep the session working after lead completion. Prior-turn roster leftovers
// (missed SubagentStop, pane reuse) must not veto settle forever.
const hasWorkingSubagents = useAppStore((store) => {
const entry = store.agentStatusByPaneKey[paneKey]
const epochStart = entry?.stateStartedAt
return (
entry?.subagents?.some(
(subagent) =>
subagent.state === 'working' && (epochStart == null || subagent.startedAt >= epochStart)
) ?? false
)
})
return [state, stateStartedAt, hasWorkingSubagents]
}

View File

@ -99,9 +99,19 @@ describe('mergeNativeChatLiveSession', () => {
expect(session.status).toBe('working')
})
it("self-heals a stale 'working' once this turn's assistant reply lands", () => {
// Trailing assistant reply (ts 2) post-dates the working turn's start (ts 1),
// so a dropped/late Stop hook must not strand 'working' on a visible reply.
it('does not treat assistant prose as turn completion while lifecycle is mid-generation', () => {
const session = mergeNativeChatLiveSession({
sources: { transcript: [user('u-1', 'go'), assistant('a-1', 'done')] },
sessionId: 'sess',
agent: 'claude',
hookState: 'working',
stateStartedAt: 1,
transcriptLifecycle: { state: 'working', turnId: 'u-1', timestamp: 1 }
})
expect(session.status).toBe('working')
})
it('recovers via assistant prose when capable host has no in-progress lifecycle', () => {
const session = mergeNativeChatLiveSession({
sources: { transcript: [user('u-1', 'go'), assistant('a-1', 'done')] },
sessionId: 'sess',
@ -112,19 +122,120 @@ describe('mergeNativeChatLiveSession', () => {
expect(session.status).toBe('ready')
})
it("keeps 'working' when the trailing assistant reply predates the working turn", () => {
// Trailing reply (ts 2) is older than the new working turn (started at ts 5),
// so the agent is working again — the stale reply must not suppress 'working'.
it('settles a dropped working hook from an explicit completion marker', () => {
const session = mergeNativeChatLiveSession({
sources: { transcript: [user('u-1', 'go'), assistant('a-1', 'prior')] },
sources: { transcript: [user('u-1', 'go'), assistant('a-1', 'done')] },
sessionId: 'sess',
agent: 'claude',
hookState: 'working',
stateStartedAt: 5
stateStartedAt: 1,
transcriptLifecycle: { state: 'completed', turnId: 'turn-1', timestamp: 2 }
})
expect(session.status).toBe('ready')
})
it('settles a dropped working hook from an explicit interruption marker', () => {
const session = mergeNativeChatLiveSession({
sources: { transcript: [user('u-1', 'go')] },
sessionId: 'sess',
agent: 'claude',
hookState: 'working',
stateStartedAt: 1,
transcriptLifecycle: { state: 'interrupted', turnId: 'turn-1', timestamp: 2 }
})
expect(session.status).toBe('ready')
})
it('does not apply an older completion marker to a newer working turn', () => {
const session = mergeNativeChatLiveSession({
sources: { transcript: [assistant('a-1', 'prior')] },
sessionId: 'sess',
agent: 'claude',
hookState: 'working',
stateStartedAt: 5,
transcriptLifecycle: { state: 'completed', turnId: 'turn-1', timestamp: 2 }
})
expect(session.status).toBe('working')
})
it('does not apply an older interruption marker to a newer working turn', () => {
const session = mergeNativeChatLiveSession({
sources: { transcript: [assistant('a-1', 'prior')] },
sessionId: 'sess',
agent: 'claude',
hookState: 'working',
stateStartedAt: 5,
transcriptLifecycle: { state: 'interrupted', turnId: 'turn-1', timestamp: 2 }
})
expect(session.status).toBe('working')
})
it('settles an unorderable (null-timestamp) completion marker for live work', () => {
const session = mergeNativeChatLiveSession({
sources: { transcript: [assistant('a-1', 'prior')] },
sessionId: 'sess',
agent: 'claude',
hookState: 'working',
stateStartedAt: 5,
transcriptLifecycle: { state: 'completed', turnId: 'turn-1', timestamp: null }
})
expect(session.status).toBe('ready')
})
it('settles a completion slightly before hook receipt within clock-skew slack', () => {
const hookStartedAt = 1_700_000_000_000
const session = mergeNativeChatLiveSession({
sources: { transcript: [assistant('a-1', 'done')] },
sessionId: 'sess',
agent: 'claude',
hookState: 'working',
stateStartedAt: hookStartedAt,
transcriptLifecycle: {
state: 'completed',
turnId: 'turn-1',
timestamp: hookStartedAt - 500
}
})
expect(session.status).toBe('ready')
})
it('preserves the assistant fallback when the serving host lacks explicit boundaries', () => {
const session = mergeNativeChatLiveSession({
sources: { transcript: [assistant('a-1', 'done')] },
sessionId: 'sess',
agent: 'grok',
hookState: 'working',
stateStartedAt: 1
})
expect(session.status).toBe('ready')
})
it('keeps working while the hook reports a live background child', () => {
const session = mergeNativeChatLiveSession({
sources: { transcript: [assistant('a-1', 'lead done')] },
sessionId: 'sess',
agent: 'claude',
hookState: 'working',
stateStartedAt: 1,
transcriptLifecycle: { state: 'completed', turnId: 'turn-1', timestamp: 2 },
hookHasWorkingSubagents: true
})
expect(session.status).toBe('working')
})
it('settles on an interruption even while the hook reports a live background child', () => {
const session = mergeNativeChatLiveSession({
sources: { transcript: [assistant('a-1', 'lead done')] },
sessionId: 'sess',
agent: 'claude',
hookState: 'working',
stateStartedAt: 1,
transcriptLifecycle: { state: 'interrupted', turnId: 'turn-1', timestamp: 2 },
hookHasWorkingSubagents: true
})
expect(session.status).toBe('ready')
})
it('leaves completed states (done/waiting/blocked) on the derived status', () => {
const session = mergeNativeChatLiveSession({
sources: { transcript: [user('u-1', 'hi')] },
@ -135,7 +246,7 @@ describe('mergeNativeChatLiveSession', () => {
expect(session.status).toBe('ready')
})
it('honors loading and error overrides outright', () => {
it('surfaces live work while the transcript loads and honors errors outright', () => {
expect(
mergeNativeChatLiveSession({
sources: { transcript: [] },
@ -144,7 +255,7 @@ describe('mergeNativeChatLiveSession', () => {
hookState: 'working',
loading: true
}).status
).toBe('loading')
).toBe('working')
const errored = mergeNativeChatLiveSession({
sources: { transcript: [] },
@ -475,9 +586,7 @@ describe('useNativeChatLiveSession — transport routing', () => {
expect(latest?.messages.map((message) => message.id)).toEqual(['u-live'])
})
it("self-heals a stale 'working' hook once this turn's reply has landed", async () => {
// The hook must read stateStartedAt from the store and thread it into the
// merge, so a dropped Stop hook doesn't strand 'Agent is working'.
it("self-heals a stale 'working' hook once the turn-complete marker lands", async () => {
useAppStore.setState({
agentStatusByPaneKey: { [PANE]: { state: 'working', stateStartedAt: 1 } as never }
})
@ -487,11 +596,167 @@ describe('useNativeChatLiveSession — transport routing', () => {
transport.emit({
type: 'snapshot',
messages: [user('u-1', 'go'), assistant('a-1', 'done')],
hasMore: false
hasMore: false,
lifecycle: { state: 'completed', turnId: 'turn-1', timestamp: 2 }
})
)
expect(latest?.status).toBe('ready')
})
it('applies a lifecycle-only append after the final message frame', async () => {
useAppStore.setState({
agentStatusByPaneKey: { [PANE]: { state: 'working', stateStartedAt: 1 } as never }
})
const transport = getMockTransport('env-1')
await render({ paneKey: PANE, agent: AGENT, sessionId: SESSION, runtimeEnvironmentId: 'env-1' })
await act(async () =>
transport.emit({
type: 'snapshot',
messages: [user('u-1', 'go'), assistant('a-1', 'done')],
hasMore: false,
lifecycle: { state: 'working', turnId: 'turn-1', timestamp: 1 }
})
)
expect(latest?.status).toBe('working')
await act(async () =>
transport.emit({
type: 'appended',
messages: [],
lifecycle: { state: 'completed', turnId: 'turn-1', timestamp: 2 }
})
)
expect(latest?.status).toBe('ready')
})
it('applies a terminal-side interruption frame without a local Stop action', async () => {
useAppStore.setState({
agentStatusByPaneKey: { [PANE]: { state: 'working', stateStartedAt: 1 } as never }
})
const transport = getMockTransport('env-1')
await render({ paneKey: PANE, agent: AGENT, sessionId: SESSION, runtimeEnvironmentId: 'env-1' })
await act(async () =>
transport.emit({
type: 'snapshot',
messages: [user('u-1', 'go')],
hasMore: false,
lifecycle: { state: 'working', turnId: 'turn-1', timestamp: 1 }
})
)
expect(latest?.status).toBe('working')
await act(async () =>
transport.emit({
type: 'appended',
messages: [],
lifecycle: { state: 'interrupted', turnId: 'turn-1', timestamp: 2 }
})
)
expect(latest?.status).toBe('ready')
})
it('does not let an older pagination read rewind a live completion', async () => {
useAppStore.setState({
agentStatusByPaneKey: { [PANE]: { state: 'working', stateStartedAt: 1 } as never }
})
const transport = getMockTransport('env-1')
const many = Array.from({ length: NATIVE_CHAT_INITIAL_LIMIT }, (_unused, index) =>
assistant(`m-${index}`, 'working')
)
await render({ paneKey: PANE, agent: AGENT, sessionId: SESSION, runtimeEnvironmentId: 'env-1' })
await act(async () =>
transport.emit({
type: 'snapshot',
messages: many,
hasMore: true,
lifecycle: { state: 'working', turnId: 'turn-1', timestamp: 1 }
})
)
let resolveEarlier: (result: {
messages: NativeChatMessage[]
lifecycle: { state: 'working'; turnId: string; timestamp: number }
}) => void = () => {}
transport.readSession.mockImplementationOnce(
() => new Promise((resolve) => (resolveEarlier = resolve))
)
await act(async () => latest?.loadEarlier())
await act(async () =>
transport.emit({
type: 'appended',
messages: [],
lifecycle: { state: 'completed', turnId: 'turn-1', timestamp: 2 }
})
)
expect(latest?.status).toBe('ready')
await act(async () => {
resolveEarlier({
messages: many,
lifecycle: { state: 'working', turnId: 'turn-1', timestamp: 1 }
})
await Promise.resolve()
})
expect(latest?.status).toBe('ready')
})
it('reconciles completion from a reconnect snapshot', async () => {
useAppStore.setState({
agentStatusByPaneKey: { [PANE]: { state: 'working', stateStartedAt: 10 } as never }
})
const transport = getMockTransport('env-1')
await render({ paneKey: PANE, agent: AGENT, sessionId: SESSION, runtimeEnvironmentId: 'env-1' })
await act(async () =>
transport.emit({
type: 'snapshot',
messages: [user('u-1', 'go')],
hasMore: false,
lifecycle: { state: 'working', turnId: 'turn-1', timestamp: 10 }
})
)
expect(latest?.status).toBe('working')
await act(async () =>
transport.emit({
type: 'snapshot',
messages: [user('u-1', 'go'), assistant('a-1', 'done')],
hasMore: false,
lifecycle: { state: 'completed', turnId: 'turn-1', timestamp: 20 }
})
)
expect(latest?.status).toBe('ready')
})
it('reconciles interruption from a reconnect snapshot', async () => {
useAppStore.setState({
agentStatusByPaneKey: { [PANE]: { state: 'working', stateStartedAt: 10 } as never }
})
const transport = getMockTransport('env-1')
await render({ paneKey: PANE, agent: AGENT, sessionId: SESSION, runtimeEnvironmentId: 'env-1' })
await act(async () =>
transport.emit({
type: 'snapshot',
messages: [user('u-1', 'go')],
hasMore: false,
lifecycle: { state: 'working', turnId: 'turn-1', timestamp: 10 }
})
)
expect(latest?.status).toBe('working')
await act(async () =>
transport.emit({
type: 'snapshot',
messages: [user('u-1', 'go')],
hasMore: false,
lifecycle: { state: 'interrupted', turnId: 'turn-1', timestamp: 20 }
})
)
// The reply (ts 2) post-dates stateStartedAt (1): the stuck 'working' heals.
expect(latest?.status).toBe('ready')
})
})

View File

@ -1,5 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useAppStore } from '../../store'
import {
NATIVE_CHAT_SOURCE_PRIORITY,
type AgentType,
@ -23,6 +22,8 @@ import {
nextNativeChatLimit
} from './native-chat-pagination'
import { getNativeChatSessionTransport } from './native-chat-session-transport'
import { useNativeChatTranscriptLifecycle } from './use-native-chat-transcript-lifecycle'
import { useNativeChatHookStatus } from './use-native-chat-hook-status'
export type UseNativeChatLiveSessionArgs = {
/** Composite `${tabId}:${leafId}` key — selects the live hook entry. */
@ -126,6 +127,7 @@ export function useNativeChatLiveSession(
const [read, setRead] = useState<ReadState>({ phase: 'loading' })
const [hasMore, setHasMore] = useState(false)
const [loadingEarlier, setLoadingEarlier] = useState(false)
const [transcriptLifecycle, transcriptLifecycleControl] = useNativeChatTranscriptLifecycle()
// The active read window; raised by loadEarlier to page in older history.
const limitRef = useRef(NATIVE_CHAT_INITIAL_LIMIT)
@ -139,14 +141,7 @@ export function useNativeChatLiveSession(
// live frame costs O(incoming), not O(existing) (#18 parity for desktop).
const appendMergerRef = useRef(createNativeChatMerger(NATIVE_CHAT_SOURCE_PRIORITY))
// Live hook state for this pane, selected narrowly so unrelated status churn
// doesn't re-render the chat view.
const hookState = useAppStore((s) => s.agentStatusByPaneKey[paneKey]?.state ?? null)
// When that state began (epoch ms). A separate primitive selector so it doesn't
// churn renders; lets a stale 'working' self-heal once this turn's reply lands.
const hookStateStartedAt = useAppStore(
(s) => s.agentStatusByPaneKey[paneKey]?.stateStartedAt ?? null
)
const [hookState, hookStateStartedAt, hookHasWorkingSubagents] = useNativeChatHookStatus(paneKey)
const latestSessionId = useRef<string | null>(sessionId)
latestSessionId.current = sessionId
@ -170,6 +165,7 @@ export function useNativeChatLiveSession(
// every source generation must invalidate pagination captured before it.
transcriptEpochRef.current += 1
setLoadingEarlier(false)
transcriptLifecycleControl.reset()
if (!sessionId) {
// No session id yet: nothing to read or tail. Surface live hook state on
// an empty transcript; backfills once the id arrives (effect re-runs).
@ -225,6 +221,7 @@ export function useNativeChatLiveSession(
return
}
const messages = result?.messages ?? []
transcriptLifecycleControl.replace(result?.lifecycle)
setRead({ phase: 'ready', messages })
setHasMore(hasMoreNativeChatHistory(messages.length, limitRef.current))
})
@ -258,12 +255,14 @@ export function useNativeChatLiveSession(
setRead({ phase: 'error', error: frame.error })
return
}
transcriptLifecycleControl.replace(frame.lifecycle)
replaceList(appendMergerRef.current, frame.messages)
setAppended([])
setRead({ phase: 'ready', messages: appendMergerRef.current.list })
setHasMore(frame.hasMore)
return
}
transcriptLifecycleControl.append(frame.lifecycle)
// Merge by id (re-emits replace in place) then bound to the window so
// the bucket can't grow without limit. The base read still holds older
// turns, and the assembler re-dedups the concat, so trimming the recent
@ -296,7 +295,7 @@ export function useNativeChatLiveSession(
}
// `transport` identity changes on an owner flip, re-running this effect to
// tear down the old host's subscription and open one against the new host.
}, [agent, sessionId, transcriptPath, transport])
}, [agent, sessionId, transcriptPath, transport, transcriptLifecycleControl])
const loadEarlier = useCallback(() => {
if (!sessionId || loadingEarlier || !hasMore || read.phase !== 'ready') {
@ -304,6 +303,7 @@ export function useNativeChatLiveSession(
}
const nextLimit = nextNativeChatLimit(limitRef.current)
const requestEpoch = transcriptEpochRef.current
const lifecycleRevision = transcriptLifecycleControl.revision()
setLoadingEarlier(true)
void transport
.readSession(agent, sessionId, nextLimit, transcriptPath ?? undefined)
@ -324,6 +324,7 @@ export function useNativeChatLiveSession(
// Read results are an ordered tail — replace the base list so the older
// page prepends in order; live appends stay in their separate bucket.
setRead({ phase: 'ready', messages: result.messages })
transcriptLifecycleControl.replaceFromPagination(result.lifecycle, lifecycleRevision)
setHasMore(hasMoreNativeChatHistory(result.messages.length, nextLimit))
})
.catch(() => {
@ -339,7 +340,16 @@ export function useNativeChatLiveSession(
setLoadingEarlier(false)
}
})
}, [agent, sessionId, transcriptPath, transport, hasMore, loadingEarlier, read.phase])
}, [
agent,
sessionId,
transcriptPath,
transport,
hasMore,
loadingEarlier,
read.phase,
transcriptLifecycleControl
])
// Assembled messages reuse the incremental assembler across appends. Computed
// outside the status memo: hookState changes only the status override, not the
@ -383,6 +393,8 @@ export function useNativeChatLiveSession(
agent,
hookState,
stateStartedAt: hookStateStartedAt,
transcriptLifecycle,
hookHasWorkingSubagents,
// Why: a watcher append (fix for #8401) can land content while the read is
// still retrying ('loading') or after it settled into 'error' — in both
// cases showing the live content beats a spinner or a stale error, so each
@ -398,6 +410,8 @@ export function useNativeChatLiveSession(
agent,
hookState,
hookStateStartedAt,
transcriptLifecycle,
hookHasWorkingSubagents,
hasMore,
loadingEarlier,
loadEarlier,

View File

@ -7,6 +7,7 @@ import {
} from './native-chat-composer-target'
import { pushHistory, type HistoryState } from './native-chat-composer-state'
import { sendNativeChatMessageVerified } from './native-chat-runtime-send'
import { cancelNativeChatPtySends, waitForNativeChatPtyIdle } from './native-chat-pty-send-queue'
import {
createClaudeModelSwitchConfirmationObserver,
type ClaudeModelSwitchConfirmationObserver
@ -53,30 +54,38 @@ export function useNativeChatSessionOptionCommand(args: {
if (!target || disabled) {
throw new Error('No live terminal is available.')
}
const detectClaudeConfirmation =
options?.detectAgentInteraction === 'claude-model-switch-confirmation'
let observer: ClaudeModelSwitchConfirmationObserver | null = null
if (detectClaudeConfirmation) {
observer = createClaudeModelSwitchConfirmationObserver({
ptyId: target.ptyId,
settings: target.settings,
expectedModelLabel: options?.expectedChoiceLabel ?? null
})
activeObserversRef.current.add(observer)
await observer.ready
if (!mountedRef.current) {
activeObserversRef.current.delete(observer)
observer.dispose()
throw new Error('Native chat command was canceled because the composer closed.')
}
// Why: arm only after the observer reaches the live PTY tail, then
// submit immediately so historical output cannot satisfy the match.
observer.arm()
}
const sendController = new AbortController()
activeSendsRef.current.add(sendController)
// Why: block composer chat sends for the whole drain+observe+verify window.
setIsDispatching(true)
let observer: ClaudeModelSwitchConfirmationObserver | null = null
try {
// Why: chat sends keep a delayed Enter for 500ms. Drain them *before*
// arming the model-switch observer so (a) that Enter cannot hit Claude's
// confirmation UI and (b) any Ctrl+U cleanup is outside the observation
// window (Ctrl+U mid-observe can miss "Set model to …" markers).
cancelNativeChatPtySends(target.ptyId)
await waitForNativeChatPtyIdle(target.ptyId)
if (!mountedRef.current || sendController.signal.aborted) {
throw new Error('Native chat command was canceled because the composer closed.')
}
const detectClaudeConfirmation =
options?.detectAgentInteraction === 'claude-model-switch-confirmation'
if (detectClaudeConfirmation) {
observer = createClaudeModelSwitchConfirmationObserver({
ptyId: target.ptyId,
settings: target.settings,
expectedModelLabel: options?.expectedChoiceLabel ?? null
})
activeObserversRef.current.add(observer)
await observer.ready
if (!mountedRef.current || sendController.signal.aborted) {
throw new Error('Native chat command was canceled because the composer closed.')
}
// Why: arm only after the observer reaches the live PTY tail, then
// submit immediately so historical output cannot satisfy the match.
observer.arm()
}
const accepted = await sendNativeChatMessageVerified(
target.settings,
target.ptyId,

View File

@ -0,0 +1,54 @@
import { useCallback, useMemo, useRef, useState } from 'react'
import type { NativeChatTurnLifecycle } from '../../../../shared/native-chat-types'
type TranscriptLifecycleState = {
lifecycle?: NativeChatTurnLifecycle
}
type TranscriptLifecycleControl = {
reset: () => void
replace: (lifecycle: NativeChatTurnLifecycle | undefined) => void
append: (lifecycle: NativeChatTurnLifecycle | undefined) => void
revision: () => number
replaceFromPagination: (lifecycle: NativeChatTurnLifecycle | undefined, revision: number) => void
}
export function useNativeChatTranscriptLifecycle(): readonly [
NativeChatTurnLifecycle | undefined,
TranscriptLifecycleControl
] {
const [state, setState] = useState<TranscriptLifecycleState>({})
// Why: pagination may resolve after a live completion; its older boundary
// can update history only when no live lifecycle write won the race.
const revisionRef = useRef(0)
const replace = useCallback((lifecycle: NativeChatTurnLifecycle | undefined): void => {
revisionRef.current += 1
setState({ lifecycle })
}, [])
const reset = useCallback((): void => replace(undefined), [replace])
const append = useCallback((lifecycle: NativeChatTurnLifecycle | undefined): void => {
if (!lifecycle) {
return
}
revisionRef.current += 1
setState({ lifecycle })
}, [])
const revision = useCallback((): number => revisionRef.current, [])
const replaceFromPagination = useCallback(
(lifecycle: NativeChatTurnLifecycle | undefined, expectedRevision: number): void => {
if (!lifecycle || revisionRef.current !== expectedRevision) {
return
}
revisionRef.current += 1
setState((current) => ({ ...current, lifecycle }))
},
[]
)
const control = useMemo<TranscriptLifecycleControl>(
() => ({ reset, replace, append, revision, replaceFromPagination }),
[append, replace, replaceFromPagination, reset, revision]
)
return [state.lifecycle, control]
}

View File

@ -329,7 +329,9 @@ export function AppearanceWindowSidebarSection({
checked={settings.showPinnedWorktreesInGroups === true}
onChange={() =>
updateSettings({
showPinnedWorktreesInGroups: !(settings.showPinnedWorktreesInGroups === true)
showPinnedWorktreesInGroups: !(
settings.showPinnedWorktreesInGroups === true
)
})
}
/>

View File

@ -702,6 +702,88 @@ describe('web settings preload API', () => {
})
})
describe('web native chat preload API', () => {
beforeEach(() => {
vi.resetModules()
})
afterEach(() => {
vi.unstubAllGlobals()
vi.doUnmock('./web-runtime-client')
})
it('forwards validated lifecycle metadata from reads and stream frames', async () => {
const lifecycle = { state: 'completed', turnId: 'turn-1', timestamp: 42 } as const
const message = {
id: 'a-1',
role: 'assistant' as const,
blocks: [{ type: 'text' as const, text: 'done' }],
timestamp: 42,
source: 'transcript' as const
}
vi.doMock('./web-runtime-client', () => ({
WebRuntimeClient: class {
call(): Promise<RuntimeRpcResponse<unknown>> {
return Promise.resolve({
id: 'read-1',
ok: true,
result: { messages: [message], lifecycle },
_meta: { runtimeId: 'runtime-1' }
})
}
subscribe(
_method: string,
_params: unknown,
callbacks: { onResponse: (response: RuntimeRpcResponse<unknown>) => void }
): Promise<{ unsubscribe: () => void }> {
callbacks.onResponse({
id: 'stream-1',
ok: true,
result: {
type: 'snapshot',
messages: [message],
hasMore: false,
lifecycle
},
_meta: { runtimeId: 'runtime-1' }
})
return Promise.resolve({ unsubscribe: vi.fn() })
}
close(): void {}
}
}))
const globals = installBrowserGlobals('Linux')
writeStoredRuntimeEnvironment(globals.storage)
const { installWebPreloadApi } = await import('./web-preload-api')
installWebPreloadApi()
await expect(globals.window.api.nativeChat.readSession('claude', 'session-1')).resolves.toEqual(
{
messages: [message],
lifecycle
}
)
const frames: unknown[] = []
globals.window.api.nativeChat.subscribe(
{ subscriptionId: 'sub-1', agent: 'claude', sessionId: 'session-1' },
(frame) => frames.push(frame)
)
await Promise.resolve()
expect(frames).toEqual([
{
type: 'snapshot',
messages: [message],
hasMore: false,
lifecycle
}
])
})
})
describe('web MiniMax preload API', () => {
beforeEach(() => {
vi.resetModules()

View File

@ -6,7 +6,6 @@ import type {
PreflightStatus,
RefreshAgentsResult,
NativeChatApi,
NativeChatReadSessionResult,
NativeChatAppendedMessages
} from '../../../preload/api-types'
import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope'
@ -127,6 +126,10 @@ import {
import { normalizeContextualTourIds, type ContextualTourId } from '../../../shared/contextual-tours'
import { translate } from '@/i18n/i18n'
import { getDefaultCreateProjectParent } from '@/components/sidebar/create-project-defaults'
import {
parseRuntimeNativeChatReadSessionResult,
parseRuntimeNativeChatTurnLifecycle
} from '@/components/native-chat/native-chat-runtime-contract'
const SETTINGS_STORAGE_KEY = 'orca.web.settings.v1'
const UI_STORAGE_KEY = 'orca.web.ui.v1'
@ -1085,13 +1088,15 @@ function createWebKeybindingsApi(): WebKeybindingsApi {
// undefined on web and the chat view showed no messages.
function createNativeChatApi(): NativeChatApi {
return {
readSession: (agent, sessionId, limit, transcriptPath) =>
callRuntimeResult<NativeChatReadSessionResult>('nativeChat.readSession', {
agent,
sessionId,
limit,
transcriptPath
}),
readSession: async (agent, sessionId, limit, transcriptPath) =>
parseRuntimeNativeChatReadSessionResult(
await callRuntimeResult<unknown>('nativeChat.readSession', {
agent,
sessionId,
limit,
transcriptPath
})
),
subscribe: (args, onFrame) => {
// No paired runtime yet: nothing to subscribe to, and
// requireActiveEnvironment() would throw. Return a no-op teardown so the
@ -1145,7 +1150,9 @@ function createNativeChatApi(): NativeChatApi {
messages?: NativeChatAppendedMessages
hasMore?: boolean
error?: string
lifecycle?: unknown
}
const lifecycle = parseRuntimeNativeChatTurnLifecycle(result?.lifecycle)
if (
(result?.type === 'appended' ||
result?.type === 'snapshot' ||
@ -1158,14 +1165,16 @@ function createNativeChatApi(): NativeChatApi {
type: 'snapshot',
messages: result.messages,
hasMore: result.hasMore ?? result.messages.length >= (args.limit ?? 300),
...(result.error ? { error: result.error } : {})
...(result.error ? { error: result.error } : {}),
...(lifecycle ? { lifecycle } : {})
})
} else if (result.type === 'snapshot') {
onFrame({
type: 'snapshot',
messages: result.messages,
hasMore: result.hasMore ?? false,
...(result.error ? { error: result.error } : {})
...(result.error ? { error: result.error } : {}),
...(lifecycle ? { lifecycle } : {})
})
} else {
onFrame(
@ -1173,9 +1182,14 @@ function createNativeChatApi(): NativeChatApi {
? {
type: 'replacement',
messages: result.messages,
hasMore: result.hasMore ?? false
hasMore: result.hasMore ?? false,
...(lifecycle ? { lifecycle } : {})
}
: {
type: 'appended',
messages: result.messages,
...(lifecycle ? { lifecycle } : {})
}
: { type: 'appended', messages: result.messages }
)
}
} else if (!receivedInitial) {

View File

@ -79,6 +79,22 @@ export type NativeChatMessage = {
turnId?: string
}
export const NATIVE_CHAT_TURN_LIFECYCLE_STATES = ['working', 'completed', 'interrupted'] as const
export type NativeChatTurnLifecycleState = (typeof NATIVE_CHAT_TURN_LIFECYCLE_STATES)[number]
export const NATIVE_CHAT_INTERRUPTED_STATUS_TEXT = 'Conversation interrupted'
/** A provider-authored turn boundary recovered from the transcript itself.
* Unlike assistant prose, this is explicit lifecycle evidence (completion or
* interruption records) and is safe to replay. */
export type NativeChatTurnLifecycle = {
state: NativeChatTurnLifecycleState
/** Stable provider id when available, otherwise the JSONL record position. */
turnId: string
/** Provider timestamp; null only when the transcript omitted one. */
timestamp: number | null
}
export const NATIVE_CHAT_SESSION_STATUSES = [
'loading',
'ready',