From ba25e4306c77fd4c2b8181d1d1d86c54e4f72e80 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Fri, 17 Jul 2026 15:59:38 -0700 Subject: [PATCH] Replace assistant-prose heuristic with explicit turn lifecycle markers (#9121) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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. --- .../native-chat-subscribe-lifecycle.test.ts | 30 +- src/main/ipc/native-chat.ts | 54 +++- .../transcript-incremental-reader.ts | 13 +- .../transcript-interruption-message.test.ts | 52 +++ .../transcript-line-decoders-claude.ts | 22 +- .../transcript-line-decoders-codex.ts | 16 +- src/main/native-chat/transcript-reader.ts | 11 +- .../native-chat/transcript-tail-reader.ts | 39 ++- .../transcript-turn-lifecycle.test.ts | 305 ++++++++++++++++++ .../native-chat/transcript-turn-lifecycle.ts | 169 ++++++++++ .../native-chat/transcript-turn-markers.ts | 26 ++ .../native-chat/transcript-watch-contract.ts | 36 +++ .../native-chat/transcript-watch-engine.ts | 85 ++--- src/main/native-chat/transcript-watch.test.ts | 164 +++++++++- src/main/native-chat/transcript-watch.ts | 20 +- .../runtime/rpc/methods/native-chat.test.ts | 117 ++++++- src/main/runtime/rpc/methods/native-chat.ts | 28 +- src/preload/api-types.ts | 32 +- .../components/native-chat/NativeChatView.tsx | 47 ++- .../native-chat/native-chat-live-status.ts | 120 +++++-- .../native-chat/native-chat-noise.test.ts | 1 + .../native-chat-pending-occurrence.ts | 119 +++++++ .../native-chat/native-chat-pending.test.ts | 27 ++ .../native-chat/native-chat-pending.ts | 62 ++-- .../native-chat/native-chat-pty-send-queue.ts | 191 +++++++++++ .../native-chat-runtime-contract.ts | 60 ++++ .../native-chat-runtime-send.test.ts | 258 ++++++++++----- .../native-chat/native-chat-runtime-send.ts | 162 ++++++---- .../native-chat-session-transport.test.ts | 111 +++++++ .../native-chat-session-transport.ts | 33 +- .../native-chat-working-suppression.test.ts | 31 +- .../native-chat-working-suppression.ts | 33 +- .../use-native-chat-hook-status.ts | 27 ++ .../use-native-chat-live-session.test.ts | 295 ++++++++++++++++- .../use-native-chat-live-session.ts | 36 ++- .../use-native-chat-session-option-command.ts | 49 +-- .../use-native-chat-transcript-lifecycle.ts | 54 ++++ .../AppearanceWindowSidebarSection.tsx | 4 +- src/renderer/src/web/web-preload-api.test.ts | 82 +++++ src/renderer/src/web/web-preload-api.ts | 38 ++- src/shared/native-chat-types.ts | 16 + 41 files changed, 2700 insertions(+), 375 deletions(-) create mode 100644 src/main/native-chat/transcript-interruption-message.test.ts create mode 100644 src/main/native-chat/transcript-turn-lifecycle.test.ts create mode 100644 src/main/native-chat/transcript-turn-lifecycle.ts create mode 100644 src/main/native-chat/transcript-turn-markers.ts create mode 100644 src/main/native-chat/transcript-watch-contract.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-pty-send-queue.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-runtime-contract.ts create mode 100644 src/renderer/src/components/native-chat/use-native-chat-hook-status.ts create mode 100644 src/renderer/src/components/native-chat/use-native-chat-transcript-lifecycle.ts diff --git a/src/main/ipc/native-chat-subscribe-lifecycle.test.ts b/src/main/ipc/native-chat-subscribe-lifecycle.test.ts index 516f839fc..683e02710 100644 --- a/src/main/ipc/native-chat-subscribe-lifecycle.test.ts +++ b/src/main/ipc/native-chat-subscribe-lifecycle.test.ts @@ -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 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 + } }) }) }) diff --git a/src/main/ipc/native-chat.ts b/src/main/ipc/native-chat.ts index 227918a68..6521bef39 100644 --- a/src/main/ipc/native-chat.ts +++ b/src/main/ipc/native-chat.ts @@ -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) } diff --git a/src/main/native-chat/transcript-incremental-reader.ts b/src/main/native-chat/transcript-incremental-reader.ts index ffb3ad83c..8ad8cc335 100644 --- a/src/main/native-chat/transcript-incremental-reader.ts +++ b/src/main/native-chat/transcript-incremental-reader.ts @@ -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 { 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 } diff --git a/src/main/native-chat/transcript-interruption-message.test.ts b/src/main/native-chat/transcript-interruption-message.test.ts new file mode 100644 index 000000000..a365e5744 --- /dev/null +++ b/src/main/native-chat/transcript-interruption-message.test.ts @@ -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') + }) +}) diff --git a/src/main/native-chat/transcript-line-decoders-claude.ts b/src/main/native-chat/transcript-line-decoders-claude.ts index 76c895258..2e9368744 100644 --- a/src/main/native-chat/transcript-line-decoders-claude.ts +++ b/src/main/native-chat/transcript-line-decoders-claude.ts @@ -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' } } diff --git a/src/main/native-chat/transcript-line-decoders-codex.ts b/src/main/native-chat/transcript-line-decoders-codex.ts index 64d04fa5a..98eeb1cc0 100644 --- a/src/main/native-chat/transcript-line-decoders-codex.ts +++ b/src/main/native-chat/transcript-line-decoders-codex.ts @@ -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 diff --git a/src/main/native-chat/transcript-reader.ts b/src/main/native-chat/transcript-reader.ts index 182296bda..85cd73572 100644 --- a/src/main/native-chat/transcript-reader.ts +++ b/src/main/native-chat/transcript-reader.ts @@ -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 } diff --git a/src/main/native-chat/transcript-tail-reader.ts b/src/main/native-chat/transcript-tail-reader.ts index 9ac029d59..2f874e957 100644 --- a/src/main/native-chat/transcript-tail-reader.ts +++ b/src/main/native-chat/transcript-tail-reader.ts @@ -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 } diff --git a/src/main/native-chat/transcript-turn-lifecycle.test.ts b/src/main/native-chat/transcript-turn-lifecycle.test.ts new file mode 100644 index 000000000..e46fcde00 --- /dev/null +++ b/src/main/native-chat/transcript-turn-lifecycle.test.ts @@ -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: 'background task finished' + } + ] + } + }), + '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: 'continue' }] + } + }), + '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: 'continue' } + ] + } + }), + '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') + }) + }) +}) diff --git a/src/main/native-chat/transcript-turn-lifecycle.ts b/src/main/native-chat/transcript-turn-lifecycle.ts new file mode 100644 index 000000000..b91e4fa7b --- /dev/null +++ b/src/main/native-chat/transcript-turn-lifecycle.ts @@ -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 | 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 | null): boolean { + const content = message?.content + if (!Array.isArray(content)) { + return false + } + return content.some((block) => asRecord(block)?.type === 'tool_use') +} diff --git a/src/main/native-chat/transcript-turn-markers.ts b/src/main/native-chat/transcript-turn-markers.ts new file mode 100644 index 000000000..727bef5d5 --- /dev/null +++ b/src/main/native-chat/transcript-turn-markers.ts @@ -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 | 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' diff --git a/src/main/native-chat/transcript-watch-contract.ts b/src/main/native-chat/transcript-watch-contract.ts new file mode 100644 index 000000000..2942594b3 --- /dev/null +++ b/src/main/native-chat/transcript-watch-contract.ts @@ -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 +} diff --git a/src/main/native-chat/transcript-watch-engine.ts b/src/main/native-chat/transcript-watch-engine.ts index d610f5a15..6ce52de47 100644 --- a/src/main/native-chat/transcript-watch-engine.ts +++ b/src/main/native-chat/transcript-watch-engine.ts @@ -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 { + 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 diff --git a/src/main/native-chat/transcript-watch.test.ts b/src/main/native-chat/transcript-watch.test.ts index 512da6b01..1aeced2c3 100644 --- a/src/main/native-chat/transcript-watch.test.ts +++ b/src/main/native-chat/transcript-watch.test.ts @@ -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 { 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: 'continue' }] + } + })}\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[][] = [] diff --git a/src/main/native-chat/transcript-watch.ts b/src/main/native-chat/transcript-watch.ts index f3228d970..1d92afe29 100644 --- a/src/main/native-chat/transcript-watch.ts +++ b/src/main/native-chat/transcript-watch.ts @@ -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 diff --git a/src/main/runtime/rpc/methods/native-chat.test.ts b/src/main/runtime/rpc/methods/native-chat.test.ts index 15bb6b3a2..2e0358e33 100644 --- a/src/main/runtime/rpc/methods/native-chat.test.ts +++ b/src/main/runtime/rpc/methods/native-chat.test.ts @@ -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) => { @@ -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) + }) }) diff --git a/src/main/runtime/rpc/methods/native-chat.ts b/src/main/runtime/rpc/methods/native-chat.ts index 513a21340..51758dbc8 100644 --- a/src/main/runtime/rpc/methods/native-chat.ts +++ b/src/main/runtime/rpc/methods/native-chat.ts @@ -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 } diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 0b2cfd982..c25c2acbc 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -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 = { diff --git a/src/renderer/src/components/native-chat/NativeChatView.tsx b/src/renderer/src/components/native-chat/NativeChatView.tsx index 984a49191..945c26701 100644 --- a/src/renderer/src/components/native-chat/NativeChatView.tsx +++ b/src/renderer/src/components/native-chat/NativeChatView.tsx @@ -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(null) // True while a question card owns the input region, so the composer is hidden. const [questionActive, setQuestionActive] = useState(false) const rootRef = useRef(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(() => { 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 diff --git a/src/renderer/src/components/native-chat/native-chat-live-status.ts b/src/renderer/src/components/native-chat/native-chat-live-status.ts index 7936fd7e2..06efe472d 100644 --- a/src/renderer/src/components/native-chat/native-chat-live-status.ts +++ b/src/renderer/src/components/native-chat/native-chat-live-status.ts @@ -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 } diff --git a/src/renderer/src/components/native-chat/native-chat-noise.test.ts b/src/renderer/src/components/native-chat/native-chat-noise.test.ts index 198749b35..a9942ed82 100644 --- a/src/renderer/src/components/native-chat/native-chat-noise.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-noise.test.ts @@ -38,6 +38,7 @@ describe('isNoiseMessage', () => { it('keeps assistant and tool turns', () => { expect(isNoiseMessage(msg('assistant', ' in prose'))).toBe(false) + expect(isNoiseMessage(msg('system', 'Conversation interrupted'))).toBe(false) }) it('keeps a user turn that carries tool results', () => { diff --git a/src/renderer/src/components/native-chat/native-chat-pending-occurrence.ts b/src/renderer/src/components/native-chat/native-chat-pending-occurrence.ts index 3c94e5bfa..d552747ef 100644 --- a/src/renderer/src/components/native-chat/native-chat-pending-occurrence.ts +++ b/src/renderer/src/components/native-chat/native-chat-pending-occurrence.ts @@ -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 { + const represented = new Set() + 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)}` } diff --git a/src/renderer/src/components/native-chat/native-chat-pending.test.ts b/src/renderer/src/components/native-chat/native-chat-pending.test.ts index b63dba307..78ffa01b7 100644 --- a/src/renderer/src/components/native-chat/native-chat-pending.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-pending.test.ts @@ -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' }] diff --git a/src/renderer/src/components/native-chat/native-chat-pending.ts b/src/renderer/src/components/native-chat/native-chat-pending.ts index 92dbaf17e..2fb916c0f 100644 --- a/src/renderer/src/components/native-chat/native-chat-pending.ts +++ b/src/renderer/src/components/native-chat/native-chat-pending.ts @@ -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() - 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() + 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}`, diff --git a/src/renderer/src/components/native-chat/native-chat-pty-send-queue.ts b/src/renderer/src/components/native-chat/native-chat-pty-send-queue.ts new file mode 100644 index 000000000..1ccaefc42 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-pty-send-queue.ts @@ -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 + freeAt: number + depth: number + handles: Set +} + +const ptyQueues = new Map() + +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 { + 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[] = [] + 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 => + new Promise((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 +} diff --git a/src/renderer/src/components/native-chat/native-chat-runtime-contract.ts b/src/renderer/src/components/native-chat/native-chat-runtime-contract.ts new file mode 100644 index 000000000..329c93f85 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-runtime-contract.ts @@ -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 + 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 + 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 } +} diff --git a/src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts b/src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts index 4982bbda2..e688c2254 100644 --- a/src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts @@ -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[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) + }) +}) diff --git a/src/renderer/src/components/native-chat/native-chat-runtime-send.ts b/src/renderer/src/components/native-chat/native-chat-runtime-send.ts index f067064e0..33e653d07 100644 --- a/src/renderer/src/components/native-chat/native-chat-runtime-send.ts +++ b/src/renderer/src/components/native-chat/native-chat-runtime-send.ts @@ -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 + +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, + 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 { @@ -70,12 +107,28 @@ function waitForNativeChatSubmit(signal?: AbortSignal): Promise { }) } +/** + * 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, + settings: RuntimeSettings, ptyId: string, text: string, signal?: AbortSignal ): Promise { + // 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, + 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[] = [] - 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, - 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, + settings: RuntimeSettings, ptyId: string, groups: AskAnswerKeyGroup[], onSettled?: (delivered: boolean) => void diff --git a/src/renderer/src/components/native-chat/native-chat-session-transport.test.ts b/src/renderer/src/components/native-chat/native-chat-session-transport.test.ts index 849897c38..47832b108 100644 --- a/src/renderer/src/components/native-chat/native-chat-session-transport.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-session-transport.test.ts @@ -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() diff --git a/src/renderer/src/components/native-chat/native-chat-session-transport.ts b/src/renderer/src/components/native-chat/native-chat-session-transport.ts index a57d255b6..0fe22211d 100644 --- a/src/renderer/src/components/native-chat/native-chat-session-transport.ts +++ b/src/renderer/src/components/native-chat/native-chat-session-transport.ts @@ -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( + const result = await callRuntimeRpc( 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) { diff --git a/src/renderer/src/components/native-chat/native-chat-working-suppression.test.ts b/src/renderer/src/components/native-chat/native-chat-working-suppression.test.ts index a82bb964e..a603e31cd 100644 --- a/src/renderer/src/components/native-chat/native-chat-working-suppression.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-working-suppression.test.ts @@ -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) }) }) diff --git a/src/renderer/src/components/native-chat/native-chat-working-suppression.ts b/src/renderer/src/components/native-chat/native-chat-working-suppression.ts index e265ae517..f17979837 100644 --- a/src/renderer/src/components/native-chat/native-chat-working-suppression.ts +++ b/src/renderer/src/components/native-chat/native-chat-working-suppression.ts @@ -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 } diff --git a/src/renderer/src/components/native-chat/use-native-chat-hook-status.ts b/src/renderer/src/components/native-chat/use-native-chat-hook-status.ts new file mode 100644 index 000000000..0d462dd06 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-hook-status.ts @@ -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] +} diff --git a/src/renderer/src/components/native-chat/use-native-chat-live-session.test.ts b/src/renderer/src/components/native-chat/use-native-chat-live-session.test.ts index 89ae267c8..821abbe81 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-live-session.test.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-live-session.test.ts @@ -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') }) }) diff --git a/src/renderer/src/components/native-chat/use-native-chat-live-session.ts b/src/renderer/src/components/native-chat/use-native-chat-live-session.ts index ec1ebbc82..bcca032fa 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-live-session.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-live-session.ts @@ -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({ 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(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, diff --git a/src/renderer/src/components/native-chat/use-native-chat-session-option-command.ts b/src/renderer/src/components/native-chat/use-native-chat-session-option-command.ts index 9720ab9f9..d57fa2110 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-session-option-command.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-session-option-command.ts @@ -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, diff --git a/src/renderer/src/components/native-chat/use-native-chat-transcript-lifecycle.ts b/src/renderer/src/components/native-chat/use-native-chat-transcript-lifecycle.ts new file mode 100644 index 000000000..92126528a --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-transcript-lifecycle.ts @@ -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({}) + // 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( + () => ({ reset, replace, append, revision, replaceFromPagination }), + [append, replace, replaceFromPagination, reset, revision] + ) + return [state.lifecycle, control] +} diff --git a/src/renderer/src/components/settings/AppearanceWindowSidebarSection.tsx b/src/renderer/src/components/settings/AppearanceWindowSidebarSection.tsx index ad68464b3..1d7e5d8ec 100644 --- a/src/renderer/src/components/settings/AppearanceWindowSidebarSection.tsx +++ b/src/renderer/src/components/settings/AppearanceWindowSidebarSection.tsx @@ -329,7 +329,9 @@ export function AppearanceWindowSidebarSection({ checked={settings.showPinnedWorktreesInGroups === true} onChange={() => updateSettings({ - showPinnedWorktreesInGroups: !(settings.showPinnedWorktreesInGroups === true) + showPinnedWorktreesInGroups: !( + settings.showPinnedWorktreesInGroups === true + ) }) } /> diff --git a/src/renderer/src/web/web-preload-api.test.ts b/src/renderer/src/web/web-preload-api.test.ts index bb77e2362..102b45c67 100644 --- a/src/renderer/src/web/web-preload-api.test.ts +++ b/src/renderer/src/web/web-preload-api.test.ts @@ -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> { + 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) => 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() diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index cddcfae93..6ae5cfa1c 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -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('nativeChat.readSession', { - agent, - sessionId, - limit, - transcriptPath - }), + readSession: async (agent, sessionId, limit, transcriptPath) => + parseRuntimeNativeChatReadSessionResult( + await callRuntimeResult('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) { diff --git a/src/shared/native-chat-types.ts b/src/shared/native-chat-types.ts index aa0539ce4..f88ff0137 100644 --- a/src/shared/native-chat-types.ts +++ b/src/shared/native-chat-types.ts @@ -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',