Replace assistant-prose heuristic with explicit turn lifecycle markers (#9121)
* Replace assistant-prose heuristic with explicit turn lifecycle markers Extract provider-authored turn boundaries (completion, interruption) directly from Claude/Codex transcripts so the chat view knows when work ends without guessing from message presence. Reconciles live hook state with transcript lifecycle: when a terminal boundary lands, it settles a dropped Stop hook instead of letting prose mislead the UI into showing 'working' after done. * fix(review): cover Claude terminal stop_reasons and RPC lifecycle frames Treat max_tokens/stop_sequence/refusal as completed markers so capable hosts do not stay working after a dropped Stop, and assert lifecycle payloads on runtime subscribe/read frames plus mid-turn non-terminal stop_reason cases. * test(native-chat): clarify that lifecycle field is optional Add type assertion and comment documenting that lifecycle field is optional and can be omitted in truncation-gating test fixtures. * fix(native-chat): settle status on interruption despite working subagent When Claude's turn is explicitly interrupted, the session should show ready immediately — even if background subagents are still running. Add an interruption check before consulting the hook's working-subagents flag so interruptions take precedence. Also normalize omitted lifecycle timestamps to null instead of leaving them undefined, and add test coverage for both cases. * fix(native-chat): settle loading spinner on explicit turn boundaries Explicit transcript turn-lifecycle markers now fully replace the prose-fallback settlement path. Remove the now-unused `turnLifecycleCapable` flag and wire lifecycle to suppress spinner even when hook status lingers. Refine Claude lifecycle detection to distinguish terminal stops from mid-turn tool_use rows, exclude harness noise from new-generation detection, and apply clock-skew slack over SSH/relay. Serialize PTY sends per line to prevent rapid prompts from gluing before Enter, clearing unsubmitted input on cancel. Update working suppression to detect epoch rollovers so interrupt+next-turn without a ready gap resets the spinner correctly.
This commit is contained in:
parent
e719ef1a57
commit
ba25e4306c
|
|
@ -1,4 +1,5 @@
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { NativeChatTurnLifecycle } from '../../shared/native-chat-types'
|
||||
|
||||
const { handlers, listeners, subscribeTranscript } = vi.hoisted(() => ({
|
||||
handlers: new Map<string, (_event: unknown, args?: unknown) => unknown>(),
|
||||
|
|
@ -111,7 +112,8 @@ type InitialSnapshotCallback = (
|
|||
messages: unknown[],
|
||||
hasMore: boolean,
|
||||
beforeOffset: number,
|
||||
error?: string
|
||||
error?: string,
|
||||
lifecycle?: NativeChatTurnLifecycle
|
||||
) => void
|
||||
|
||||
// The onInitialSnapshot callback the handler passed into the Nth subscribeTranscript
|
||||
|
|
@ -275,7 +277,31 @@ describe('nativeChat subscribe lifecycle', () => {
|
|||
|
||||
expect(renderer.sender.send).toHaveBeenCalledWith('nativeChat:appended', {
|
||||
subscriptionId: 'drain-clean',
|
||||
frame: { type: 'snapshot', messages: [], hasMore: false }
|
||||
frame: {
|
||||
type: 'snapshot',
|
||||
messages: [],
|
||||
hasMore: false
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('forwards replayable lifecycle on the initial snapshot', () => {
|
||||
const pending = deferredSubscription()
|
||||
subscribeTranscript.mockReturnValueOnce(pending.promise)
|
||||
const renderer = createSender(7)
|
||||
const lifecycle = { state: 'completed', turnId: 'turn-1', timestamp: 42 } as const
|
||||
|
||||
subscribe(renderer.sender, 'lifecycle')
|
||||
initialSnapshot(0)([], false, 0, undefined, lifecycle)
|
||||
|
||||
expect(renderer.sender.send).toHaveBeenCalledWith('nativeChat:appended', {
|
||||
subscriptionId: 'lifecycle',
|
||||
frame: {
|
||||
type: 'snapshot',
|
||||
messages: [],
|
||||
hasMore: false,
|
||||
lifecycle
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { open, stat } from 'node:fs/promises'
|
||||
import type { NativeChatMessage } from '../../shared/native-chat-types'
|
||||
import type { NativeChatMessage, NativeChatTurnLifecycle } from '../../shared/native-chat-types'
|
||||
import { transcriptFallbackId } from './transcript-fallback-id'
|
||||
import {
|
||||
MAX_NATIVE_CHAT_TRANSCRIPT_RECORD_BYTES,
|
||||
|
|
@ -28,7 +28,9 @@ export async function readIncrementalTranscriptMessages(
|
|||
filePath: string,
|
||||
state: IncrementalTranscriptState,
|
||||
decode: NativeChatLineDecoder,
|
||||
onBatch?: (messages: NativeChatMessage[]) => void
|
||||
onBatch?: (messages: NativeChatMessage[]) => void,
|
||||
decodeLifecycle?: (line: string, fallbackId: string) => NativeChatTurnLifecycle | null,
|
||||
onLifecycle?: (lifecycle: NativeChatTurnLifecycle) => void
|
||||
): Promise<NativeChatMessage[]> {
|
||||
const end = (await stat(filePath)).size
|
||||
if (end <= state.offset) {
|
||||
|
|
@ -91,7 +93,12 @@ export async function readIncrementalTranscriptMessages(
|
|||
if (!line) {
|
||||
return
|
||||
}
|
||||
const message = decode(line, transcriptFallbackId(filePath, state.pendingStart))
|
||||
const fallbackId = transcriptFallbackId(filePath, state.pendingStart)
|
||||
const lifecycle = decodeLifecycle?.(line, fallbackId)
|
||||
if (lifecycle) {
|
||||
onLifecycle?.(lifecycle)
|
||||
}
|
||||
const message = decode(line, fallbackId)
|
||||
if (!message) {
|
||||
return
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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'
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,305 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
decodeClaudeTurnLifecycle,
|
||||
decodeCodexTurnLifecycle,
|
||||
nativeChatTurnLifecycleDecoderForAgent
|
||||
} from './transcript-turn-lifecycle'
|
||||
|
||||
describe('native chat transcript turn lifecycle', () => {
|
||||
it('exposes a lifecycle decoder only for transcript formats with explicit boundaries', () => {
|
||||
expect(nativeChatTurnLifecycleDecoderForAgent('claude')).not.toBeNull()
|
||||
expect(nativeChatTurnLifecycleDecoderForAgent('openclaude')).not.toBeNull()
|
||||
expect(nativeChatTurnLifecycleDecoderForAgent('codex')).not.toBeNull()
|
||||
expect(nativeChatTurnLifecycleDecoderForAgent('grok')).toBeNull()
|
||||
})
|
||||
|
||||
it('decodes Codex task boundaries with the provider turn id', () => {
|
||||
expect(
|
||||
decodeCodexTurnLifecycle(
|
||||
JSON.stringify({
|
||||
timestamp: '2026-07-16T23:40:14.001Z',
|
||||
type: 'event_msg',
|
||||
payload: { type: 'task_started', turn_id: 'turn-1' }
|
||||
}),
|
||||
'fallback'
|
||||
)
|
||||
).toEqual({
|
||||
state: 'working',
|
||||
turnId: 'turn-1',
|
||||
timestamp: Date.parse('2026-07-16T23:40:14.001Z')
|
||||
})
|
||||
|
||||
expect(
|
||||
decodeCodexTurnLifecycle(
|
||||
JSON.stringify({
|
||||
timestamp: '2026-07-16T23:45:37.608Z',
|
||||
type: 'event_msg',
|
||||
payload: { type: 'task_complete', turn_id: 'turn-1' }
|
||||
}),
|
||||
'fallback'
|
||||
)
|
||||
).toEqual({
|
||||
state: 'completed',
|
||||
turnId: 'turn-1',
|
||||
timestamp: Date.parse('2026-07-16T23:45:37.608Z')
|
||||
})
|
||||
|
||||
expect(
|
||||
decodeCodexTurnLifecycle(
|
||||
JSON.stringify({
|
||||
timestamp: '2026-07-16T23:46:01.000Z',
|
||||
type: 'event_msg',
|
||||
payload: { type: 'turn_aborted', reason: 'interrupted', turn_id: 'turn-2' }
|
||||
}),
|
||||
'fallback'
|
||||
)
|
||||
).toEqual({
|
||||
state: 'interrupted',
|
||||
turnId: 'turn-2',
|
||||
timestamp: Date.parse('2026-07-16T23:46:01.000Z')
|
||||
})
|
||||
})
|
||||
|
||||
it('does not mistake a Codex assistant message for completion', () => {
|
||||
expect(
|
||||
decodeCodexTurnLifecycle(
|
||||
JSON.stringify({
|
||||
timestamp: '2026-07-16T23:45:37.472Z',
|
||||
type: 'event_msg',
|
||||
payload: { type: 'agent_message', message: 'final-looking prose' }
|
||||
}),
|
||||
'fallback'
|
||||
)
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('uses Claude terminal stop_reasons and excludes tool-result user rows', () => {
|
||||
for (const stopReason of ['end_turn', 'max_tokens', 'stop_sequence', 'refusal'] as const) {
|
||||
expect(
|
||||
decodeClaudeTurnLifecycle(
|
||||
JSON.stringify({
|
||||
type: 'assistant',
|
||||
uuid: `assistant-${stopReason}`,
|
||||
timestamp: '2026-07-16T23:45:37.608Z',
|
||||
message: { role: 'assistant', stop_reason: stopReason, content: [] }
|
||||
}),
|
||||
'fallback'
|
||||
)
|
||||
).toEqual({
|
||||
state: 'completed',
|
||||
turnId: `assistant-${stopReason}`,
|
||||
timestamp: Date.parse('2026-07-16T23:45:37.608Z')
|
||||
})
|
||||
}
|
||||
|
||||
expect(
|
||||
decodeClaudeTurnLifecycle(
|
||||
JSON.stringify({
|
||||
type: 'user',
|
||||
uuid: 'tool-result-1',
|
||||
timestamp: '2026-07-16T23:45:38.000Z',
|
||||
message: {
|
||||
role: 'user',
|
||||
content: [{ type: 'tool_result', tool_use_id: 'tool-1', content: 'ok' }]
|
||||
}
|
||||
}),
|
||||
'fallback'
|
||||
)
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('does not treat Claude mid-turn tool_use assistant rows as completed', () => {
|
||||
expect(
|
||||
decodeClaudeTurnLifecycle(
|
||||
JSON.stringify({
|
||||
type: 'assistant',
|
||||
uuid: 'assistant-tool',
|
||||
timestamp: '2026-07-16T23:45:37.608Z',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
stop_reason: 'tool_use',
|
||||
content: [{ type: 'tool_use', id: 't1', name: 'Bash', input: {} }]
|
||||
}
|
||||
}),
|
||||
'fallback'
|
||||
)
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('treats Claude assistant rows with omitted stop_reason and content as completed', () => {
|
||||
expect(
|
||||
decodeClaudeTurnLifecycle(
|
||||
JSON.stringify({
|
||||
type: 'assistant',
|
||||
uuid: 'assistant-null-stop',
|
||||
timestamp: '2026-07-16T23:45:37.608Z',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
stop_reason: null,
|
||||
content: [{ type: 'text', text: 'final answer' }]
|
||||
}
|
||||
}),
|
||||
'fallback'
|
||||
)
|
||||
).toEqual({
|
||||
state: 'completed',
|
||||
turnId: 'assistant-null-stop',
|
||||
timestamp: Date.parse('2026-07-16T23:45:37.608Z')
|
||||
})
|
||||
|
||||
expect(
|
||||
decodeClaudeTurnLifecycle(
|
||||
JSON.stringify({
|
||||
type: 'assistant',
|
||||
uuid: 'assistant-missing-stop',
|
||||
timestamp: '2026-07-16T23:45:37.608Z',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'final answer' }]
|
||||
}
|
||||
}),
|
||||
'fallback'
|
||||
)
|
||||
).toEqual({
|
||||
state: 'completed',
|
||||
turnId: 'assistant-missing-stop',
|
||||
timestamp: Date.parse('2026-07-16T23:45:37.608Z')
|
||||
})
|
||||
})
|
||||
|
||||
it('does not complete a Claude assistant row that omits stop_reason but carries a tool_use block', () => {
|
||||
// A pre-tool row can hold prose AND a tool_use block; without stop_reason the
|
||||
// turn is still mid-flight, so it must not settle the spinner before the tool.
|
||||
for (const stopReason of [null, undefined] as const) {
|
||||
expect(
|
||||
decodeClaudeTurnLifecycle(
|
||||
JSON.stringify({
|
||||
type: 'assistant',
|
||||
uuid: `assistant-pretool-${stopReason}`,
|
||||
timestamp: '2026-07-16T23:45:37.608Z',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
...(stopReason === null ? { stop_reason: null } : {}),
|
||||
content: [
|
||||
{ type: 'text', text: 'let me check' },
|
||||
{ type: 'tool_use', id: 't1', name: 'Bash', input: {} }
|
||||
]
|
||||
}
|
||||
}),
|
||||
'fallback'
|
||||
)
|
||||
).toBeNull()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not complete Claude assistant rows with omitted stop_reason and no content', () => {
|
||||
expect(
|
||||
decodeClaudeTurnLifecycle(
|
||||
JSON.stringify({
|
||||
type: 'assistant',
|
||||
uuid: 'assistant-empty',
|
||||
timestamp: '2026-07-16T23:45:37.608Z',
|
||||
message: { role: 'assistant', stop_reason: null, content: [] }
|
||||
}),
|
||||
'fallback'
|
||||
)
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('does not treat harness noise user rows as a new working generation', () => {
|
||||
expect(
|
||||
decodeClaudeTurnLifecycle(
|
||||
JSON.stringify({
|
||||
type: 'user',
|
||||
uuid: 'task-note-1',
|
||||
timestamp: '2026-07-16T23:46:10.000Z',
|
||||
message: {
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: '<task-notification>background task finished</task-notification>'
|
||||
}
|
||||
]
|
||||
}
|
||||
}),
|
||||
'fallback'
|
||||
)
|
||||
).toBeNull()
|
||||
|
||||
expect(
|
||||
decodeClaudeTurnLifecycle(
|
||||
JSON.stringify({
|
||||
type: 'user',
|
||||
uuid: 'reminder-1',
|
||||
timestamp: '2026-07-16T23:46:11.000Z',
|
||||
message: {
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: '<system-reminder>continue</system-reminder>' }]
|
||||
}
|
||||
}),
|
||||
'fallback'
|
||||
)
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('excludes Claude tool-result rows that also carry text sidecars', () => {
|
||||
expect(
|
||||
decodeClaudeTurnLifecycle(
|
||||
JSON.stringify({
|
||||
type: 'user',
|
||||
uuid: 'mixed-tool-result',
|
||||
timestamp: '2026-07-16T23:45:38.000Z',
|
||||
message: {
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'tool_result', tool_use_id: 'tool-1', content: 'ok' },
|
||||
{ type: 'text', text: '<system-reminder>continue</system-reminder>' }
|
||||
]
|
||||
}
|
||||
}),
|
||||
'fallback'
|
||||
)
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('treats a real Claude user row as the next working generation', () => {
|
||||
expect(
|
||||
decodeClaudeTurnLifecycle(
|
||||
JSON.stringify({
|
||||
type: 'user',
|
||||
uuid: 'user-2',
|
||||
timestamp: '2026-07-16T23:46:00.000Z',
|
||||
message: { role: 'user', content: 'next task' }
|
||||
}),
|
||||
'fallback'
|
||||
)
|
||||
).toEqual({
|
||||
state: 'working',
|
||||
turnId: 'user-2',
|
||||
timestamp: Date.parse('2026-07-16T23:46:00.000Z')
|
||||
})
|
||||
})
|
||||
|
||||
it('treats Claude interruptedMessageId as terminal instead of a user generation', () => {
|
||||
expect(
|
||||
decodeClaudeTurnLifecycle(
|
||||
JSON.stringify({
|
||||
type: 'user',
|
||||
uuid: 'interrupt-row',
|
||||
interruptedMessageId: 'assistant-request-1',
|
||||
timestamp: '2026-07-16T23:46:01.000Z',
|
||||
message: {
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: '[Request interrupted by user]' }]
|
||||
}
|
||||
}),
|
||||
'fallback'
|
||||
)
|
||||
).toEqual({
|
||||
state: 'interrupted',
|
||||
turnId: 'assistant-request-1',
|
||||
timestamp: Date.parse('2026-07-16T23:46:01.000Z')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,169 @@
|
|||
import type { AgentType, NativeChatTurnLifecycle } from '../../shared/native-chat-types'
|
||||
import { resolveNativeChatTranscriptAgent } from '../../shared/native-chat-agent-support'
|
||||
import { isNoiseMessage } from '../../shared/native-chat-noise'
|
||||
import {
|
||||
asRecord,
|
||||
extractString,
|
||||
parseJsonObject,
|
||||
timestampMs
|
||||
} from '../ai-vault/session-scanner-values'
|
||||
import { decodeClaudeTranscriptLine } from './transcript-line-decoders-claude'
|
||||
import {
|
||||
claudeInterruptedMessageId,
|
||||
CODEX_EVENT_TURN_ABORTED,
|
||||
CODEX_EVENT_TURN_COMPLETE,
|
||||
CODEX_EVENT_TURN_STARTED
|
||||
} from './transcript-turn-markers'
|
||||
|
||||
export type NativeChatTurnLifecycleDecoder = (
|
||||
line: string,
|
||||
fallbackId: string
|
||||
) => NativeChatTurnLifecycle | null
|
||||
|
||||
export function nativeChatTurnLifecycleDecoderForAgent(
|
||||
agent: AgentType
|
||||
): NativeChatTurnLifecycleDecoder | null {
|
||||
const transcriptAgent = resolveNativeChatTranscriptAgent(agent)
|
||||
if (transcriptAgent === 'codex') {
|
||||
return decodeCodexTurnLifecycle
|
||||
}
|
||||
if (transcriptAgent === 'claude') {
|
||||
return decodeClaudeTurnLifecycle
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function decodeCodexTurnLifecycle(
|
||||
line: string,
|
||||
fallbackId: string
|
||||
): NativeChatTurnLifecycle | null {
|
||||
const record = parseJsonObject(line)
|
||||
const payload = asRecord(record?.payload)
|
||||
if (record?.type !== 'event_msg' || !payload) {
|
||||
return null
|
||||
}
|
||||
if (
|
||||
payload.type !== CODEX_EVENT_TURN_STARTED &&
|
||||
payload.type !== CODEX_EVENT_TURN_COMPLETE &&
|
||||
payload.type !== CODEX_EVENT_TURN_ABORTED
|
||||
) {
|
||||
return null
|
||||
}
|
||||
const state =
|
||||
payload.type === CODEX_EVENT_TURN_STARTED
|
||||
? 'working'
|
||||
: payload.type === CODEX_EVENT_TURN_ABORTED
|
||||
? 'interrupted'
|
||||
: 'completed'
|
||||
return {
|
||||
state,
|
||||
turnId: extractString(payload.turn_id) ?? fallbackId,
|
||||
timestamp: lifecycleTimestamp(record.timestamp)
|
||||
}
|
||||
}
|
||||
|
||||
/** Claude stop reasons that end the lead generation (not mid-turn tool_use). */
|
||||
const CLAUDE_TERMINAL_STOP_REASONS = new Set(['end_turn', 'max_tokens', 'stop_sequence', 'refusal'])
|
||||
|
||||
function isClaudeTerminalStopReason(value: unknown): boolean {
|
||||
return typeof value === 'string' && CLAUDE_TERMINAL_STOP_REASONS.has(value)
|
||||
}
|
||||
|
||||
export function decodeClaudeTurnLifecycle(
|
||||
line: string,
|
||||
fallbackId: string
|
||||
): NativeChatTurnLifecycle | null {
|
||||
const record = parseJsonObject(line)
|
||||
if (!record) {
|
||||
return null
|
||||
}
|
||||
const message = asRecord(record.message)
|
||||
const timestamp = lifecycleTimestamp(record.timestamp)
|
||||
const interruptedMessageId = claudeInterruptedMessageId(record)
|
||||
if (interruptedMessageId) {
|
||||
// Why: Claude stores its interrupt notice as an injected user row; it ends
|
||||
// the active generation and must not be mistaken for the next user prompt.
|
||||
return { state: 'interrupted', turnId: interruptedMessageId, timestamp }
|
||||
}
|
||||
if (record.type === 'assistant') {
|
||||
const stopReason = message?.stop_reason
|
||||
// Why: capable hosts rely on explicit terminals (prose is only a backup when
|
||||
// the latest lifecycle is not mid-generation). Emit completed for every real
|
||||
// end marker — including historical/OpenClaude rows that omit stop_reason —
|
||||
// while tool_use stays non-terminal so mid-turn tool loops keep working. The
|
||||
// no-stop_reason backup also excludes rows carrying a tool_use block: a
|
||||
// pre-tool assistant row that omits stop_reason is mid-turn, not done, so it
|
||||
// must not settle the spinner before the tool runs.
|
||||
const isTerminal =
|
||||
isClaudeTerminalStopReason(stopReason) ||
|
||||
(stopReason == null &&
|
||||
assistantHasRenderableContent(message) &&
|
||||
!assistantHasToolUse(message))
|
||||
if (isTerminal) {
|
||||
return {
|
||||
state: 'completed',
|
||||
turnId: extractString(record.uuid) ?? extractString(message?.id) ?? fallbackId,
|
||||
timestamp
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
if (record.type !== 'user') {
|
||||
return null
|
||||
}
|
||||
const decoded = decodeClaudeTranscriptLine(line, fallbackId)
|
||||
if (decoded?.role !== 'user' || decoded.blocks.some((block) => block.type === 'tool-result')) {
|
||||
// Why: Claude can attach text sidecars to tool-result user rows; those are
|
||||
// continuations of the active turn, not a new user-authored generation.
|
||||
return null
|
||||
}
|
||||
// Why: harness noise (task-notification, system-reminder, …) is user-role in
|
||||
// the JSONL but not a new generation. Treating it as working would overwrite
|
||||
// a real terminal marker and re-stick the chat spinner after done/interrupt.
|
||||
if (isNoiseMessage(decoded)) {
|
||||
return null
|
||||
}
|
||||
return { state: 'working', turnId: decoded.id, timestamp }
|
||||
}
|
||||
|
||||
function lifecycleTimestamp(value: unknown): number | null {
|
||||
const parsed = timestampMs(value)
|
||||
return Number.isFinite(parsed) ? parsed : null
|
||||
}
|
||||
|
||||
function assistantHasRenderableContent(message: Record<string, unknown> | null): boolean {
|
||||
const content = message?.content
|
||||
if (typeof content === 'string') {
|
||||
return content.trim().length > 0
|
||||
}
|
||||
if (!Array.isArray(content)) {
|
||||
return false
|
||||
}
|
||||
return content.some((block) => {
|
||||
const record = asRecord(block)
|
||||
if (!record) {
|
||||
return false
|
||||
}
|
||||
if (
|
||||
record.type === 'text' &&
|
||||
typeof record.text === 'string' &&
|
||||
record.text.trim().length > 0
|
||||
) {
|
||||
return true
|
||||
}
|
||||
if (record.type === 'thinking' || record.type === 'redacted_thinking') {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
/** True when an assistant row contains a tool_use block — the turn continues to
|
||||
* a tool call, so a missing stop_reason must not be read as completion. */
|
||||
function assistantHasToolUse(message: Record<string, unknown> | null): boolean {
|
||||
const content = message?.content
|
||||
if (!Array.isArray(content)) {
|
||||
return false
|
||||
}
|
||||
return content.some((block) => asRecord(block)?.type === 'tool_use')
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
// Shared detection of provider-authored turn boundaries. The message decoders
|
||||
// render a visible status row from these lines; the lifecycle decoders settle
|
||||
// the chat spinner from the same lines. Keeping the predicates here means the
|
||||
// two consumers can never disagree about which JSONL line is an interrupt/abort
|
||||
// or a turn boundary — updating a provider's format touches one place, so a
|
||||
// rename can't leave a visible "interrupted" row that never settles (or vice
|
||||
// versa).
|
||||
|
||||
import { extractString } from '../ai-vault/session-scanner-values'
|
||||
|
||||
/**
|
||||
* The `interruptedMessageId` on a Claude user row when that row is Claude's
|
||||
* injected interrupt notice rather than a real user prompt. Returns undefined
|
||||
* for genuine user turns.
|
||||
*/
|
||||
export function claudeInterruptedMessageId(record: Record<string, unknown>): string | undefined {
|
||||
if (record.type !== 'user') {
|
||||
return undefined
|
||||
}
|
||||
return extractString(record.interruptedMessageId) ?? undefined
|
||||
}
|
||||
|
||||
/** Codex `event_msg` payload types that bound a turn's lifecycle. */
|
||||
export const CODEX_EVENT_TURN_STARTED = 'task_started'
|
||||
export const CODEX_EVENT_TURN_COMPLETE = 'task_complete'
|
||||
export const CODEX_EVENT_TURN_ABORTED = 'turn_aborted'
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -1,42 +1,18 @@
|
|||
import { watch, type FSWatcher } from 'node:fs'
|
||||
import { open, stat } from 'node:fs/promises'
|
||||
import { basename, dirname } from 'node:path'
|
||||
import type { AgentType, NativeChatMessage } from '../../shared/native-chat-types'
|
||||
import type { ResolveSessionFileOptions } from './session-file-resolver'
|
||||
import type { NativeChatMessage, NativeChatTurnLifecycle } from '../../shared/native-chat-types'
|
||||
import {
|
||||
readIncrementalTranscriptMessages,
|
||||
resetIncrementalTranscriptState,
|
||||
type IncrementalTranscriptState
|
||||
} from './transcript-incremental-reader'
|
||||
import { readNativeChatTranscriptTailFile } from './transcript-tail-reader'
|
||||
|
||||
export type SubscribeNativeChatTranscriptArgs = ResolveSessionFileOptions & {
|
||||
agent: AgentType
|
||||
sessionId: string
|
||||
onAppend: (messages: NativeChatMessage[]) => void
|
||||
onInitialSnapshot?: (
|
||||
messages: NativeChatMessage[],
|
||||
hasMore: boolean,
|
||||
beforeOffset: number,
|
||||
/** Set when the initial drain could not deliver a transcript; the subscriber
|
||||
* surfaces it as an error snapshot so a watching client never sticks on
|
||||
* 'loading'. Empty messages accompany it. */
|
||||
error?: string
|
||||
) => void
|
||||
onReplace?: (messages: NativeChatMessage[], hasMore: boolean, beforeOffset: number) => void
|
||||
initialLimit?: number
|
||||
filePath?: string
|
||||
debounceMs?: number
|
||||
/** Overrides the resolve-poll interval (see subscribeViaResolvePoll) so tests
|
||||
* don't wait out the production backoff. Production ignores this and backs
|
||||
* off from 500ms to a 5s cap. */
|
||||
resolvePollIntervalMs?: number
|
||||
}
|
||||
|
||||
export type NativeChatTranscriptSubscription = {
|
||||
unsubscribe: () => void
|
||||
watching: boolean
|
||||
}
|
||||
import { nativeChatTurnLifecycleDecoderForAgent } from './transcript-turn-lifecycle'
|
||||
import type {
|
||||
NativeChatTranscriptSubscription,
|
||||
SubscribeNativeChatTranscriptArgs
|
||||
} from './transcript-watch-contract'
|
||||
|
||||
const DEFAULT_DEBOUNCE_MS = 40
|
||||
const ROTATION_RETRY_MS = 25
|
||||
|
|
@ -87,6 +63,7 @@ export async function installTranscriptWatcher(
|
|||
return null
|
||||
}
|
||||
const { onAppend, onInitialSnapshot, onReplace, initialLimit, debounceMs } = args
|
||||
const decodeLifecycle = nativeChatTurnLifecycleDecoderForAgent(args.agent)
|
||||
|
||||
const state: IncrementalTranscriptState = {
|
||||
offset: 0,
|
||||
|
|
@ -138,6 +115,7 @@ export async function installTranscriptWatcher(
|
|||
}
|
||||
|
||||
async function readAndEmitAppends(): Promise<void> {
|
||||
let lifecycle: NativeChatTurnLifecycle | undefined
|
||||
const remaining = await readIncrementalTranscriptMessages(
|
||||
filePath,
|
||||
state,
|
||||
|
|
@ -146,10 +124,14 @@ export async function installTranscriptWatcher(
|
|||
if (!closed) {
|
||||
onAppend(messages)
|
||||
}
|
||||
},
|
||||
decodeLifecycle ?? undefined,
|
||||
(nextLifecycle) => {
|
||||
lifecycle = nextLifecycle
|
||||
}
|
||||
)
|
||||
if (!closed && remaining.length > 0) {
|
||||
onAppend(remaining)
|
||||
if (!closed && (remaining.length > 0 || lifecycle)) {
|
||||
onAppend(remaining, lifecycle)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -176,7 +158,14 @@ export async function installTranscriptWatcher(
|
|||
// Why: 0 is a valid window — an explicit undefined check keeps an empty
|
||||
// snapshot empty instead of falling back to an unbounded incremental read.
|
||||
contentReplaced && !initialDrain && onReplace && initialLimit !== undefined
|
||||
? await readNativeChatTranscriptTailFile(filePath, initialLimit, decode)
|
||||
? await readNativeChatTranscriptTailFile(
|
||||
filePath,
|
||||
initialLimit,
|
||||
decode,
|
||||
false,
|
||||
undefined,
|
||||
decodeLifecycle
|
||||
)
|
||||
: null
|
||||
if (closed) {
|
||||
return
|
||||
|
|
@ -187,7 +176,8 @@ export async function installTranscriptWatcher(
|
|||
onReplace(
|
||||
replacementSnapshot.messages,
|
||||
replacementSnapshot.hasMore,
|
||||
replacementSnapshot.beforeOffset
|
||||
replacementSnapshot.beforeOffset,
|
||||
replacementSnapshot.lifecycle
|
||||
)
|
||||
await readAndEmitAppends()
|
||||
watchedBoundary = await boundaryFingerprint(filePath, state.offset)
|
||||
|
|
@ -197,7 +187,14 @@ export async function installTranscriptWatcher(
|
|||
|
||||
const initialSnapshot =
|
||||
initialDrain && onInitialSnapshot && initialLimit !== undefined
|
||||
? await readNativeChatTranscriptTailFile(filePath, initialLimit, decode)
|
||||
? await readNativeChatTranscriptTailFile(
|
||||
filePath,
|
||||
initialLimit,
|
||||
decode,
|
||||
false,
|
||||
undefined,
|
||||
decodeLifecycle
|
||||
)
|
||||
: null
|
||||
if (closed) {
|
||||
return
|
||||
|
|
@ -210,12 +207,24 @@ export async function installTranscriptWatcher(
|
|||
onInitialSnapshot(
|
||||
initialSnapshot.messages,
|
||||
initialSnapshot.hasMore,
|
||||
initialSnapshot.beforeOffset
|
||||
initialSnapshot.beforeOffset,
|
||||
undefined,
|
||||
initialSnapshot.lifecycle
|
||||
)
|
||||
await readAndEmitAppends()
|
||||
} else {
|
||||
const messages = await readIncrementalTranscriptMessages(filePath, state, decode)
|
||||
onInitialSnapshot(messages, false, 0)
|
||||
let lifecycle: NativeChatTurnLifecycle | undefined
|
||||
const messages = await readIncrementalTranscriptMessages(
|
||||
filePath,
|
||||
state,
|
||||
decode,
|
||||
undefined,
|
||||
decodeLifecycle ?? undefined,
|
||||
(nextLifecycle) => {
|
||||
lifecycle = nextLifecycle
|
||||
}
|
||||
)
|
||||
onInitialSnapshot(messages, false, 0, undefined, lifecycle)
|
||||
}
|
||||
} else {
|
||||
initialDrain = false
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { appendFile, mkdtemp, rename, rm, writeFile } from 'node:fs/promises'
|
|||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import type { NativeChatMessage } from '../../shared/native-chat-types'
|
||||
import type { NativeChatMessage, NativeChatTurnLifecycle } from '../../shared/native-chat-types'
|
||||
import {
|
||||
getActiveNativeChatWatcherCount,
|
||||
readNativeChatTranscriptTail,
|
||||
|
|
@ -45,6 +45,30 @@ function claudeLine(uuid: string, role: 'user' | 'assistant', text: string): str
|
|||
})}\n`
|
||||
}
|
||||
|
||||
function claudeEndTurnLine(uuid: string, text: string): string {
|
||||
return `${JSON.stringify({
|
||||
type: 'assistant',
|
||||
uuid,
|
||||
timestamp: '2026-06-01T10:00:01.000Z',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
stop_reason: 'end_turn',
|
||||
content: [{ type: 'text', text }]
|
||||
}
|
||||
})}\n`
|
||||
}
|
||||
|
||||
function codexLifecycleLine(
|
||||
state: 'task_started' | 'task_complete' | 'turn_aborted',
|
||||
turnId = 'turn-1'
|
||||
): string {
|
||||
return `${JSON.stringify({
|
||||
type: 'event_msg',
|
||||
timestamp: state === 'task_started' ? '2026-06-01T10:00:00.000Z' : '2026-06-01T10:00:01.000Z',
|
||||
payload: { type: state, turn_id: turnId }
|
||||
})}\n`
|
||||
}
|
||||
|
||||
async function waitFor(predicate: () => boolean, timeoutMs = 2000): Promise<void> {
|
||||
const start = Date.now()
|
||||
while (!predicate()) {
|
||||
|
|
@ -99,6 +123,144 @@ describe('subscribeNativeChatTranscript', () => {
|
|||
expect(snapshots).toEqual([[]])
|
||||
})
|
||||
|
||||
it('replays and appends provider-authored turn lifecycle markers', async () => {
|
||||
const filePath = await tempFile(claudeLine('u-1', 'user', 'first'))
|
||||
const lifecycles: NativeChatTurnLifecycle[] = []
|
||||
const sub = await subscribeNativeChatTranscript({
|
||||
agent: 'claude',
|
||||
sessionId: 'ignored',
|
||||
filePath,
|
||||
onInitialSnapshot: (_messages, _hasMore, _beforeOffset, _error, lifecycle) => {
|
||||
if (lifecycle) {
|
||||
lifecycles.push(lifecycle)
|
||||
}
|
||||
},
|
||||
onAppend: (_messages, lifecycle) => {
|
||||
if (lifecycle) {
|
||||
lifecycles.push(lifecycle)
|
||||
}
|
||||
},
|
||||
debounceMs: 5
|
||||
})
|
||||
|
||||
await waitFor(() => lifecycles.length === 1)
|
||||
await appendFile(filePath, claudeEndTurnLine('a-1', 'done'))
|
||||
await waitFor(() => lifecycles.length === 2)
|
||||
sub.unsubscribe()
|
||||
|
||||
expect(lifecycles.map((lifecycle) => lifecycle.state)).toEqual(['working', 'completed'])
|
||||
expect(lifecycles.map((lifecycle) => lifecycle.turnId)).toEqual(['u-1', 'a-1'])
|
||||
})
|
||||
|
||||
it('emits Codex task_complete even when the frame has no visible messages', async () => {
|
||||
const filePath = await tempFile(codexLifecycleLine('task_started'))
|
||||
const lifecycles: NativeChatTurnLifecycle[] = []
|
||||
const sub = await subscribeNativeChatTranscript({
|
||||
agent: 'codex',
|
||||
sessionId: 'ignored',
|
||||
filePath,
|
||||
onInitialSnapshot: (_messages, _hasMore, _beforeOffset, _error, lifecycle) => {
|
||||
if (lifecycle) {
|
||||
lifecycles.push(lifecycle)
|
||||
}
|
||||
},
|
||||
onAppend: (messages, lifecycle) => {
|
||||
expect(messages).toEqual([])
|
||||
if (lifecycle) {
|
||||
lifecycles.push(lifecycle)
|
||||
}
|
||||
},
|
||||
debounceMs: 5
|
||||
})
|
||||
|
||||
await waitFor(() => lifecycles.length === 1)
|
||||
await appendFile(filePath, codexLifecycleLine('task_complete'))
|
||||
await waitFor(() => lifecycles.length === 2)
|
||||
sub.unsubscribe()
|
||||
|
||||
expect(lifecycles).toMatchObject([
|
||||
{ state: 'working', turnId: 'turn-1' },
|
||||
{ state: 'completed', turnId: 'turn-1' }
|
||||
])
|
||||
})
|
||||
|
||||
it('replays Codex interruption as a terminal lifecycle and visible status row', async () => {
|
||||
const filePath = await tempFile(
|
||||
codexLifecycleLine('task_started') + codexLifecycleLine('turn_aborted')
|
||||
)
|
||||
let snapshot:
|
||||
| { messages: NativeChatMessage[]; lifecycle: NativeChatTurnLifecycle | undefined }
|
||||
| undefined
|
||||
const sub = await subscribeNativeChatTranscript({
|
||||
agent: 'codex',
|
||||
sessionId: 'ignored',
|
||||
filePath,
|
||||
initialLimit: 40,
|
||||
onInitialSnapshot: (messages, _hasMore, _beforeOffset, _error, lifecycle) => {
|
||||
snapshot = { messages, lifecycle }
|
||||
},
|
||||
onAppend: () => {},
|
||||
debounceMs: 5
|
||||
})
|
||||
|
||||
await waitFor(() => snapshot !== undefined)
|
||||
sub.unsubscribe()
|
||||
|
||||
expect(snapshot?.lifecycle).toMatchObject({ state: 'interrupted', turnId: 'turn-1' })
|
||||
expect(snapshot?.messages).toMatchObject([
|
||||
{ role: 'system', blocks: [{ type: 'text', text: 'Conversation interrupted' }] }
|
||||
])
|
||||
})
|
||||
|
||||
it('does not replay an older interruption over a newer working turn', async () => {
|
||||
const filePath = await tempFile(
|
||||
codexLifecycleLine('task_started', 'turn-1') +
|
||||
codexLifecycleLine('turn_aborted', 'turn-1') +
|
||||
codexLifecycleLine('task_started', 'turn-2')
|
||||
)
|
||||
const result = await readNativeChatTranscriptTail({
|
||||
agent: 'codex',
|
||||
sessionId: 'ignored',
|
||||
filePath,
|
||||
limit: 40
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({ lifecycle: { state: 'working', turnId: 'turn-2' } })
|
||||
})
|
||||
|
||||
it('recovers a completion marker even when trailing non-boundary rows follow it', async () => {
|
||||
// The lifecycle scan walks newest-first; rows that decode to no boundary
|
||||
// (tool-results, harness noise) must not hide an earlier real completion
|
||||
// within the window, or a reconnect snapshot would fail to settle.
|
||||
const toolResult = `${JSON.stringify({
|
||||
type: 'user',
|
||||
uuid: 'tool-result-1',
|
||||
timestamp: '2026-06-01T10:00:02.000Z',
|
||||
message: {
|
||||
role: 'user',
|
||||
content: [{ type: 'tool_result', tool_use_id: 'tool-1', content: 'ok' }]
|
||||
}
|
||||
})}\n`
|
||||
const noise = `${JSON.stringify({
|
||||
type: 'user',
|
||||
uuid: 'note-1',
|
||||
timestamp: '2026-06-01T10:00:03.000Z',
|
||||
message: {
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: '<system-reminder>continue</system-reminder>' }]
|
||||
}
|
||||
})}\n`
|
||||
const filePath = await tempFile(claudeEndTurnLine('a-1', 'done') + toolResult + noise)
|
||||
const result = await readNativeChatTranscriptTail({
|
||||
agent: 'claude',
|
||||
sessionId: 'ignored',
|
||||
filePath,
|
||||
limit: 40
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({ lifecycle: { state: 'completed', turnId: 'a-1' } })
|
||||
})
|
||||
|
||||
it('emits a bulk append in bounded ordered batches', async () => {
|
||||
const filePath = await tempFile('')
|
||||
const batches: NativeChatMessage[][] = []
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -5,16 +5,53 @@ import type { RpcContext } from '../core'
|
|||
// Stub the bounded tail reader so the handler returns a deterministic transcript with
|
||||
// one oversized tool-result block; the test then asserts clip behavior per client.
|
||||
const OVERSIZED = 'x'.repeat(5000)
|
||||
const cachedResult = vi.hoisted(() => ({ value: { messages: [] as NativeChatMessage[] } }))
|
||||
const cachedResult = vi.hoisted(() => ({
|
||||
value: {
|
||||
messages: [] as NativeChatMessage[],
|
||||
// Optional so truncation-gating fixtures can omit it; lifecycle tests set it explicitly.
|
||||
lifecycle: undefined as
|
||||
| { state: 'working' | 'completed' | 'interrupted'; turnId: string; timestamp: number | null }
|
||||
| undefined
|
||||
} as {
|
||||
messages: NativeChatMessage[]
|
||||
lifecycle?: {
|
||||
state: 'working' | 'completed' | 'interrupted'
|
||||
turnId: string
|
||||
timestamp: number | null
|
||||
}
|
||||
}
|
||||
}))
|
||||
const watcher = vi.hoisted(() => ({
|
||||
args: null as null | {
|
||||
onInitialSnapshot?: (
|
||||
messages: NativeChatMessage[],
|
||||
hasMore: boolean,
|
||||
beforeOffset: number,
|
||||
error?: string
|
||||
error?: string,
|
||||
lifecycle?: {
|
||||
state: 'working' | 'completed' | 'interrupted'
|
||||
turnId: string
|
||||
timestamp: number | null
|
||||
}
|
||||
) => void
|
||||
onReplace?: (
|
||||
messages: NativeChatMessage[],
|
||||
hasMore: boolean,
|
||||
beforeOffset: number,
|
||||
lifecycle?: {
|
||||
state: 'working' | 'completed' | 'interrupted'
|
||||
turnId: string
|
||||
timestamp: number | null
|
||||
}
|
||||
) => void
|
||||
onAppend: (
|
||||
messages: NativeChatMessage[],
|
||||
lifecycle?: {
|
||||
state: 'working' | 'completed' | 'interrupted'
|
||||
turnId: string
|
||||
timestamp: number | null
|
||||
}
|
||||
) => void
|
||||
onAppend: (messages: NativeChatMessage[]) => void
|
||||
},
|
||||
watching: true
|
||||
}))
|
||||
|
|
@ -24,7 +61,8 @@ vi.mock('../../../native-chat/transcript-watch', () => ({
|
|||
return Promise.resolve({
|
||||
messages: messages.slice(-limit),
|
||||
hasMore: messages.length > limit,
|
||||
beforeOffset: 123
|
||||
beforeOffset: 123,
|
||||
...(cachedResult.value.lifecycle ? { lifecycle: cachedResult.value.lifecycle } : {})
|
||||
})
|
||||
},
|
||||
subscribeNativeChatTranscript: (args: NonNullable<typeof watcher.args>) => {
|
||||
|
|
@ -331,7 +369,12 @@ describe('nativeChat.subscribe initial snapshot', () => {
|
|||
)
|
||||
|
||||
expect(emitted).toEqual([
|
||||
{ type: 'snapshot', messages: [], hasMore: false, error: 'Transcript unavailable' }
|
||||
{
|
||||
type: 'snapshot',
|
||||
messages: [],
|
||||
hasMore: false,
|
||||
error: 'Transcript unavailable'
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
|
|
@ -379,4 +422,68 @@ describe('nativeChat.subscribe initial snapshot', () => {
|
|||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('forwards lifecycle on snapshot, append, and replacement frames', async () => {
|
||||
watcher.watching = true
|
||||
watcher.args = null
|
||||
const emitted: unknown[] = []
|
||||
await subscribeHandler()(
|
||||
{ agent: 'claude', sessionId: 's' },
|
||||
streamingContext('runtime'),
|
||||
(value) => emitted.push(value)
|
||||
)
|
||||
|
||||
const completed = {
|
||||
state: 'completed' as const,
|
||||
turnId: 'turn-rpc-1',
|
||||
timestamp: 1_720_000_000_000
|
||||
}
|
||||
const callbacks = activeWatcherArgs()
|
||||
callbacks.onInitialSnapshot?.([makeMessage('snap')], false, 3, undefined, completed)
|
||||
callbacks.onAppend([], completed)
|
||||
callbacks.onReplace?.([makeMessage('repl')], false, 9, completed)
|
||||
|
||||
expect(emitted).toEqual([
|
||||
{
|
||||
type: 'snapshot',
|
||||
messages: [expect.objectContaining({ id: 'a-1' })],
|
||||
hasMore: false,
|
||||
beforeOffset: 3,
|
||||
lifecycle: completed
|
||||
},
|
||||
{
|
||||
type: 'appended',
|
||||
messages: [],
|
||||
lifecycle: completed
|
||||
},
|
||||
{
|
||||
type: 'replacement',
|
||||
messages: [expect.objectContaining({ id: 'a-1' })],
|
||||
hasMore: false,
|
||||
beforeOffset: 9,
|
||||
lifecycle: completed
|
||||
}
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('nativeChat.readSession lifecycle payload', () => {
|
||||
it('forwards lifecycle from the tail reader on success', async () => {
|
||||
const lifecycle = {
|
||||
state: 'interrupted' as const,
|
||||
turnId: 'turn-read-1',
|
||||
timestamp: 1_720_000_000_100
|
||||
}
|
||||
cachedResult.value = { messages: [makeMessage('done')], lifecycle }
|
||||
const result = await readSessionHandler()(
|
||||
{ agent: 'claude', sessionId: 's' },
|
||||
ctxWith('runtime')
|
||||
)
|
||||
expect(result).toMatchObject({
|
||||
hasMore: false,
|
||||
beforeOffset: 123,
|
||||
lifecycle
|
||||
})
|
||||
expect((result as { messages: NativeChatMessage[] }).messages).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -156,18 +156,23 @@ function NativeChatResolvedView({
|
|||
const launchPrompt = useAppStore((s) => s.nativeChatLaunchPromptByTabId[terminalTabId] ?? null)
|
||||
const clearNativeChatLaunchPrompt = useAppStore((s) => s.clearNativeChatLaunchPrompt)
|
||||
const paneLaunchPrompt = launchPrompt?.agent === agent ? launchPrompt : null
|
||||
// Live hook state for this pane, selected directly so the working indicator
|
||||
// flips the instant the agent reports 'working' — even when switching to chat
|
||||
// mid-turn before the transcript merge has caught up.
|
||||
const hookWorking = useAppStore((s) => s.agentStatusByPaneKey[paneKey]?.state === 'working')
|
||||
// The live-session merge reconciles hooks with replayable transcript turn
|
||||
// boundaries; all working consumers must use that one lifecycle decision.
|
||||
const liveWorking = session.status === 'working'
|
||||
// The agent's in-progress reply preview (hook), shown as a live streaming
|
||||
// bubble while it works — before the completed turn flushes to the transcript.
|
||||
const hookPreview = useAppStore((s) => s.agentStatusByPaneKey[paneKey]?.lastAssistantMessage)
|
||||
// Why: Stop suppression must clear on a newer working epoch even when status
|
||||
// never leaves 'working' (interrupt + immediate next turn coalesced).
|
||||
const hookWorkingEpoch = useAppStore(
|
||||
(s) => s.agentStatusByPaneKey[paneKey]?.stateStartedAt ?? null
|
||||
)
|
||||
const canSend = useNativeChatCanSend(targetPtyId)
|
||||
// Reuse the verified composer send path for interactive cards and composer
|
||||
// stop (Stop sends ESC, the agent-TUI interrupt key).
|
||||
const interactiveSend = useNativeChatInteractiveSend(terminalTabId, paneKey, targetPtyId, agent)
|
||||
const [workingInterrupted, setWorkingInterrupted] = useState(false)
|
||||
const previousWorkingEpochRef = useRef<number | null>(null)
|
||||
// True while a question card owns the input region, so the composer is hidden.
|
||||
const [questionActive, setQuestionActive] = useState(false)
|
||||
const rootRef = useRef<HTMLDivElement>(null)
|
||||
|
|
@ -308,9 +313,9 @@ function NativeChatResolvedView({
|
|||
? [...sessionAfterCommandBoundaries.messages, ...pendingMessages]
|
||||
: sessionAfterCommandBoundaries.messages,
|
||||
previewText: hookPreview,
|
||||
working: hookWorking
|
||||
working: liveWorking
|
||||
})
|
||||
}, [sessionAfterCommandBoundaries.messages, pendingMessages, hookPreview, hookWorking])
|
||||
}, [sessionAfterCommandBoundaries.messages, pendingMessages, hookPreview, liveWorking])
|
||||
const sessionWithPending = useMemo<typeof session>(() => {
|
||||
if (pending.length === 0 && commandMarkers.length === 0 && !streamingText) {
|
||||
return sessionAfterCommandBoundaries
|
||||
|
|
@ -331,26 +336,38 @@ function NativeChatResolvedView({
|
|||
const viewState = selectNativeChatViewState(sessionWithPending)
|
||||
|
||||
const isConversation = viewState.kind === 'ready'
|
||||
// Drive "working" from the live hook state too: when toggling to chat while the
|
||||
// agent is mid-turn, the merged transcript may not yet reflect the in-flight
|
||||
// turn, but the hook already says 'working' — show the indicator immediately.
|
||||
const viewWorking = viewState.kind === 'ready' && viewState.isWorking
|
||||
useEffect(() => {
|
||||
if (shouldClearNativeChatWorkingSuppression({ viewWorking, hookWorking })) {
|
||||
if (
|
||||
shouldClearNativeChatWorkingSuppression({
|
||||
working: liveWorking,
|
||||
interrupted: workingInterrupted,
|
||||
workingEpoch: hookWorkingEpoch,
|
||||
previousWorkingEpoch: previousWorkingEpochRef.current
|
||||
})
|
||||
) {
|
||||
setWorkingInterrupted(false)
|
||||
}
|
||||
}, [viewWorking, hookWorking])
|
||||
if (liveWorking && hookWorkingEpoch != null) {
|
||||
previousWorkingEpochRef.current = hookWorkingEpoch
|
||||
}
|
||||
if (!liveWorking) {
|
||||
previousWorkingEpochRef.current = null
|
||||
}
|
||||
}, [liveWorking, workingInterrupted, hookWorkingEpoch])
|
||||
const isWorking = shouldShowNativeChatWorking({
|
||||
isConversation,
|
||||
viewWorking,
|
||||
hookWorking,
|
||||
working: liveWorking,
|
||||
interrupted: workingInterrupted
|
||||
})
|
||||
|
||||
const stopAgent = useCallback(() => {
|
||||
setWorkingInterrupted(true)
|
||||
// Why: Stop after a submitted turn drops the delayed-write handle once it
|
||||
// settles, so cancelPendingSends no longer sees the optimistic id. Clear
|
||||
// the echo cache here so a cancelled prompt cannot stick as a ghost bubble.
|
||||
setPending(writePendingSendCache(pendingScope, []))
|
||||
interactiveSend.cancel()
|
||||
}, [interactiveSend])
|
||||
}, [interactiveSend, pendingScope])
|
||||
const nativeChatFileLinkClick = useNativeChatFileLinkClick(fileLinkContext)
|
||||
|
||||
// Chat-only font zoom via Cmd/Ctrl +/-/0, gated to the live conversation so
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ describe('isNoiseMessage', () => {
|
|||
|
||||
it('keeps assistant and tool turns', () => {
|
||||
expect(isNoiseMessage(msg('assistant', '<system-reminder> in prose'))).toBe(false)
|
||||
expect(isNoiseMessage(msg('system', 'Conversation interrupted'))).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps a user turn that carries tool results', () => {
|
||||
|
|
|
|||
|
|
@ -80,6 +80,125 @@ export function advancedNativeChatUserContentCounts(
|
|||
return advanced
|
||||
}
|
||||
|
||||
function nativeChatUserMessageNormalizedText(message: NativeChatMessage): string | null {
|
||||
if (message.role !== 'user') {
|
||||
return null
|
||||
}
|
||||
const text = normalizeNativeChatPendingText(
|
||||
message.blocks
|
||||
.filter(isTextBlock)
|
||||
.map((block) => block.text)
|
||||
.join(' ')
|
||||
)
|
||||
return text.length > 0 ? text : null
|
||||
}
|
||||
|
||||
/** User texts that already have a later non-user turn (ready to prune echoes). */
|
||||
export function advancedNativeChatUserTexts(
|
||||
messages: readonly NativeChatMessage[]
|
||||
): readonly string[] {
|
||||
const advanced: string[] = []
|
||||
const waiting: string[] = []
|
||||
for (const message of messages) {
|
||||
if (message.role === 'user') {
|
||||
const text = nativeChatUserMessageNormalizedText(message)
|
||||
if (text) {
|
||||
waiting.push(text)
|
||||
}
|
||||
continue
|
||||
}
|
||||
advanced.push(...waiting)
|
||||
waiting.length = 0
|
||||
}
|
||||
return advanced
|
||||
}
|
||||
|
||||
/** All user texts (for hiding optimistic echoes once the turn exists). */
|
||||
export function matchingNativeChatUserTexts(
|
||||
messages: readonly NativeChatMessage[]
|
||||
): readonly string[] {
|
||||
const texts: string[] = []
|
||||
for (const message of messages) {
|
||||
const text = nativeChatUserMessageNormalizedText(message)
|
||||
if (text) {
|
||||
texts.push(text)
|
||||
}
|
||||
}
|
||||
return texts
|
||||
}
|
||||
|
||||
/**
|
||||
* How many leading pending texts concatenate exactly to `userText`.
|
||||
* Covers rapid-send glue ("joke"+"continue" → "jokecontinue") without matching
|
||||
* unrelated prefixes ("hi" ↛ "history").
|
||||
*/
|
||||
export function countLeadingPendingTextsGluedToUserText(
|
||||
pendingTexts: readonly string[],
|
||||
userText: string
|
||||
): number {
|
||||
if (pendingTexts.length === 0 || userText.length === 0) {
|
||||
return 0
|
||||
}
|
||||
let combined = ''
|
||||
for (let index = 0; index < pendingTexts.length; index += 1) {
|
||||
const piece = pendingTexts[index]
|
||||
if (!piece) {
|
||||
return 0
|
||||
}
|
||||
combined += piece
|
||||
if (combined === userText) {
|
||||
return index + 1
|
||||
}
|
||||
if (!userText.startsWith(combined)) {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark pending entries represented only by multi-send glue (2+ consecutive
|
||||
* optimistic texts concatenated into one transcript user row). Exact single
|
||||
* matches stay in the content-key/occurrence path so repeated prompts and
|
||||
* send boundaries keep their existing semantics.
|
||||
*/
|
||||
export function selectPendingIndicesRepresentedByUserTexts(
|
||||
pending: readonly NativeChatPendingOccurrence[],
|
||||
userTexts: readonly string[]
|
||||
): Set<number> {
|
||||
const represented = new Set<number>()
|
||||
if (pending.length < 2 || userTexts.length === 0) {
|
||||
return represented
|
||||
}
|
||||
const remaining = pending.map((entry, index) => ({
|
||||
index,
|
||||
text: normalizeNativeChatPendingText(entry.text)
|
||||
}))
|
||||
for (const userText of userTexts) {
|
||||
const open = remaining.filter((entry) => !represented.has(entry.index) && entry.text.length > 0)
|
||||
const gluedCount = countLeadingPendingTextsGluedToUserText(
|
||||
open.map((entry) => entry.text),
|
||||
userText
|
||||
)
|
||||
// Why: gluedCount === 1 is an exact match — leave it to occurrence counting.
|
||||
if (gluedCount < 2) {
|
||||
continue
|
||||
}
|
||||
for (let i = 0; i < gluedCount; i += 1) {
|
||||
const entry = open[i]
|
||||
if (!entry) {
|
||||
continue
|
||||
}
|
||||
represented.add(entry.index)
|
||||
const at = remaining.findIndex((candidate) => candidate.index === entry.index)
|
||||
if (at >= 0) {
|
||||
remaining.splice(at, 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
return represented
|
||||
}
|
||||
|
||||
export function nativeChatPendingMatchKey(pending: NativeChatPendingOccurrence): string {
|
||||
return `${String(pending.afterMessageId)}\0${nativeChatPendingContentKey(pending)}`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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' }]
|
||||
|
|
|
|||
|
|
@ -8,12 +8,15 @@ import { setBoundedScopeCacheEntry } from './native-chat-composer-scope-cache'
|
|||
import type { NativeChatLaunchPrompt } from '@/lib/native-chat-launch-prompt'
|
||||
import {
|
||||
advancedNativeChatUserContentCounts,
|
||||
advancedNativeChatUserTexts,
|
||||
assignNativeChatPendingOccurrence,
|
||||
matchingNativeChatUserContentCounts,
|
||||
matchingNativeChatUserTexts,
|
||||
nativeChatPendingContentKey,
|
||||
nativeChatPendingMatchKey,
|
||||
nativeChatPendingMatchingAfter,
|
||||
nativeChatPendingOccurrence
|
||||
nativeChatPendingOccurrence,
|
||||
selectPendingIndicesRepresentedByUserTexts
|
||||
} from './native-chat-pending-occurrence'
|
||||
|
||||
/** An optimistic, not-yet-confirmed composer send. */
|
||||
|
|
@ -133,7 +136,7 @@ export function prunePendingSends(
|
|||
return pending
|
||||
}
|
||||
const consumed = new Map<string, number>()
|
||||
const next = pending.filter((entry) => {
|
||||
const exactKeep = pending.map((entry) => {
|
||||
const contentKey = nativeChatPendingContentKey(entry)
|
||||
const key = nativeChatPendingMatchKey(entry)
|
||||
const available =
|
||||
|
|
@ -143,10 +146,22 @@ export function prunePendingSends(
|
|||
const used = consumed.get(key) ?? 0
|
||||
const occurrence = nativeChatPendingOccurrence(entry, used)
|
||||
consumed.set(key, Math.max(used, occurrence))
|
||||
if (occurrence > available) {
|
||||
return true
|
||||
return occurrence > available
|
||||
})
|
||||
// Why: when rapid body writes glued two optimistic sends into one transcript
|
||||
// user row ("joke"+"continue"→"jokecontinue"), exact keys never match. Drop
|
||||
// those echoes once an assistant turn advances past the glued user text.
|
||||
const stillOpen = pending.filter((_, index) => exactKeep[index])
|
||||
const gluedRepresented = selectPendingIndicesRepresentedByUserTexts(
|
||||
stillOpen,
|
||||
advancedNativeChatUserTexts(messages)
|
||||
)
|
||||
const next = pending.filter((entry, index) => {
|
||||
if (!exactKeep[index]) {
|
||||
return false
|
||||
}
|
||||
return false
|
||||
const openIndex = stillOpen.indexOf(entry)
|
||||
return openIndex < 0 || !gluedRepresented.has(openIndex)
|
||||
})
|
||||
return next.length === pending.length ? pending : next
|
||||
}
|
||||
|
|
@ -162,21 +177,32 @@ export function pendingSendsAsMessages(
|
|||
existingMessages: NativeChatMessage[] = []
|
||||
): NativeChatMessage[] {
|
||||
const consumed = new Map<string, number>()
|
||||
const exactVisible = pending.map((entry) => {
|
||||
const contentKey = nativeChatPendingContentKey(entry)
|
||||
const key = nativeChatPendingMatchKey(entry)
|
||||
const represented =
|
||||
matchingNativeChatUserContentCounts(
|
||||
messagesAfterPendingBoundary(existingMessages, entry)
|
||||
).get(contentKey) ?? 0
|
||||
const used = consumed.get(key) ?? 0
|
||||
const occurrence = nativeChatPendingOccurrence(entry, used)
|
||||
consumed.set(key, Math.max(used, occurrence))
|
||||
return occurrence > represented
|
||||
})
|
||||
// Hide optimistic echoes that were glued into a single transcript user row
|
||||
// even before the assistant reply lands (matching, not advanced).
|
||||
const stillVisible = pending.filter((_, index) => exactVisible[index])
|
||||
const gluedRepresented = selectPendingIndicesRepresentedByUserTexts(
|
||||
stillVisible,
|
||||
matchingNativeChatUserTexts(existingMessages)
|
||||
)
|
||||
return pending
|
||||
.filter((entry) => {
|
||||
const contentKey = nativeChatPendingContentKey(entry)
|
||||
const key = nativeChatPendingMatchKey(entry)
|
||||
const represented =
|
||||
matchingNativeChatUserContentCounts(
|
||||
messagesAfterPendingBoundary(existingMessages, entry)
|
||||
).get(contentKey) ?? 0
|
||||
const used = consumed.get(key) ?? 0
|
||||
const occurrence = nativeChatPendingOccurrence(entry, used)
|
||||
consumed.set(key, Math.max(used, occurrence))
|
||||
if (occurrence > represented) {
|
||||
return true
|
||||
.filter((entry, index) => {
|
||||
if (!exactVisible[index]) {
|
||||
return false
|
||||
}
|
||||
return false
|
||||
const openIndex = stillVisible.indexOf(entry)
|
||||
return openIndex < 0 || !gluedRepresented.has(openIndex)
|
||||
})
|
||||
.map((entry) => ({
|
||||
id: `pending:${entry.id}`,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,191 @@
|
|||
// Per-PTY serialization for native-chat clear+body+Enter sequences.
|
||||
// Why: Enter is delayed (busy-agent safety). Without a queue, a second send's
|
||||
// clear/body can interleave with the first Enter window and still glue or race
|
||||
// the agent composer. Each sequence owns the line until its Enter fires.
|
||||
//
|
||||
// Option commands (model switch) cancel/await this queue first so a delayed chat
|
||||
// Enter cannot land on Claude's model confirmation dialog.
|
||||
|
||||
export type NativeChatPtySendQueueHandle = {
|
||||
cancel: () => void
|
||||
settleAfterMs: number
|
||||
bodyStarted: () => boolean
|
||||
finished: () => boolean
|
||||
}
|
||||
|
||||
export type EnqueueNativeChatPtySendOptions = {
|
||||
/**
|
||||
* Called when cancel aborts after `start` began but before Enter was marked
|
||||
* submitted. Used to clear leftover body text from the agent TUI.
|
||||
*/
|
||||
onCancelUnsubmitted?: () => void
|
||||
}
|
||||
|
||||
type PtyQueueState = {
|
||||
tail: Promise<void>
|
||||
freeAt: number
|
||||
depth: number
|
||||
handles: Set<NativeChatPtySendQueueHandle>
|
||||
}
|
||||
|
||||
const ptyQueues = new Map<string, PtyQueueState>()
|
||||
|
||||
function getOrCreateState(ptyId: string): PtyQueueState {
|
||||
let state = ptyQueues.get(ptyId)
|
||||
if (!state) {
|
||||
state = { tail: Promise.resolve(), freeAt: Date.now(), depth: 0, handles: new Set() }
|
||||
ptyQueues.set(ptyId, state)
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
export function resetNativeChatPtySendQueuesForTests(): void {
|
||||
for (const state of ptyQueues.values()) {
|
||||
for (const handle of state.handles) {
|
||||
handle.cancel()
|
||||
}
|
||||
}
|
||||
ptyQueues.clear()
|
||||
}
|
||||
|
||||
/** Abort every in-flight/queued chat send on this PTY (clears delayed Enter). */
|
||||
export function cancelNativeChatPtySends(ptyId: string): void {
|
||||
const state = ptyQueues.get(ptyId)
|
||||
if (!state) {
|
||||
return
|
||||
}
|
||||
for (const handle of state.handles) {
|
||||
handle.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
/** Wait until every chat sequence on this PTY has finished or been cancelled. */
|
||||
export async function waitForNativeChatPtyIdle(ptyId: string): Promise<void> {
|
||||
const state = ptyQueues.get(ptyId)
|
||||
if (!state) {
|
||||
return
|
||||
}
|
||||
await state.tail
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `start` only after prior sequences for `ptyId` finish. When the queue is
|
||||
* idle, `start` runs synchronously so the body write is not deferred a tick.
|
||||
*/
|
||||
export function enqueueNativeChatPtySend(
|
||||
ptyId: string,
|
||||
durationMs: number,
|
||||
start: (ctx: {
|
||||
isCancelled: () => boolean
|
||||
delay: (ms: number, fn: () => void) => void
|
||||
/** Call when Enter (or the terminal write that completes the send) fires. */
|
||||
markSubmitted: () => void
|
||||
}) => void,
|
||||
options?: EnqueueNativeChatPtySendOptions
|
||||
): NativeChatPtySendQueueHandle {
|
||||
const now = Date.now()
|
||||
const state = getOrCreateState(ptyId)
|
||||
const waitMs = Math.max(0, state.freeAt - now)
|
||||
const settleAfterMs = waitMs + Math.max(0, durationMs)
|
||||
state.freeAt = Math.max(now, state.freeAt) + Math.max(0, durationMs)
|
||||
state.depth += 1
|
||||
|
||||
let cancelled = false
|
||||
let bodyStarted = false
|
||||
let finished = false
|
||||
let submitted = false
|
||||
const timers: ReturnType<typeof setTimeout>[] = []
|
||||
let release: (() => void) | null = null
|
||||
|
||||
const delay = (ms: number, fn: () => void): void => {
|
||||
const timer = setTimeout(() => {
|
||||
if (!cancelled) {
|
||||
fn()
|
||||
}
|
||||
}, ms)
|
||||
timers.push(timer)
|
||||
}
|
||||
|
||||
const markFinished = (): void => {
|
||||
finished = true
|
||||
}
|
||||
|
||||
const markSubmitted = (): void => {
|
||||
submitted = true
|
||||
}
|
||||
|
||||
const execute = (): Promise<void> =>
|
||||
new Promise<void>((resolve) => {
|
||||
release = resolve
|
||||
if (cancelled) {
|
||||
markFinished()
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
bodyStarted = true
|
||||
start({ isCancelled: () => cancelled, delay, markSubmitted })
|
||||
if (durationMs <= 0) {
|
||||
markSubmitted()
|
||||
markFinished()
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
// Why: always release after the declared duration so a cancel mid-flight
|
||||
// cannot stall the per-pty queue forever.
|
||||
const done = setTimeout(() => {
|
||||
markFinished()
|
||||
resolve()
|
||||
}, durationMs)
|
||||
timers.push(done)
|
||||
})
|
||||
|
||||
const runPromise =
|
||||
state.depth === 1 && waitMs === 0 ? execute() : state.tail.then(() => execute())
|
||||
|
||||
const dropHandle = (): void => {
|
||||
state.handles.delete(handle)
|
||||
}
|
||||
|
||||
const settleQueueEntry = (): void => {
|
||||
state.depth = Math.max(0, state.depth - 1)
|
||||
markFinished()
|
||||
dropHandle()
|
||||
// Why: drop the per-pty record once nothing is in flight so the map does not
|
||||
// accumulate one permanent entry per pty over a long, multi-pane session.
|
||||
if (state.depth === 0 && state.handles.size === 0 && ptyQueues.get(ptyId) === state) {
|
||||
ptyQueues.delete(ptyId)
|
||||
}
|
||||
}
|
||||
|
||||
state.tail = runPromise.then(settleQueueEntry, settleQueueEntry)
|
||||
|
||||
const handle: NativeChatPtySendQueueHandle = {
|
||||
cancel: () => {
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
cancelled = true
|
||||
for (const timer of timers) {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
const shouldClear = bodyStarted && !submitted
|
||||
markFinished()
|
||||
// Why: refund only THIS sequence's charged window rather than collapsing
|
||||
// freeAt to now — later queued sends still hold the line, so a blanket
|
||||
// reset would understate the next enqueue's settle time and let a send
|
||||
// card drop while a queued Enter is still pending.
|
||||
state.freeAt = Math.max(Date.now(), state.freeAt - Math.max(0, durationMs))
|
||||
release?.()
|
||||
release = null
|
||||
dropHandle()
|
||||
if (shouldClear) {
|
||||
options?.onCancelUnsubmitted?.()
|
||||
}
|
||||
},
|
||||
settleAfterMs,
|
||||
bodyStarted: () => bodyStarted,
|
||||
finished: () => finished
|
||||
}
|
||||
state.handles.add(handle)
|
||||
return handle
|
||||
}
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
import type {
|
||||
NativeChatAppendedMessages,
|
||||
NativeChatReadSessionResult
|
||||
} from '../../../../preload/api-types'
|
||||
import type { NativeChatTurnLifecycle } from '../../../../shared/native-chat-types'
|
||||
|
||||
export const RUNTIME_NATIVE_CHAT_READ_ERROR = "Couldn't read agent chat from the remote runtime."
|
||||
|
||||
export function parseRuntimeNativeChatTurnLifecycle(
|
||||
value: unknown
|
||||
): NativeChatTurnLifecycle | undefined {
|
||||
if (typeof value !== 'object' || value === null) {
|
||||
return undefined
|
||||
}
|
||||
const record = value as Record<string, unknown>
|
||||
if (
|
||||
(record.state !== 'working' &&
|
||||
record.state !== 'completed' &&
|
||||
record.state !== 'interrupted') ||
|
||||
typeof record.turnId !== 'string' ||
|
||||
record.turnId.trim().length === 0 ||
|
||||
(record.timestamp !== null &&
|
||||
record.timestamp !== undefined &&
|
||||
(typeof record.timestamp !== 'number' ||
|
||||
!Number.isFinite(record.timestamp) ||
|
||||
record.timestamp <= 0))
|
||||
) {
|
||||
return undefined
|
||||
}
|
||||
return {
|
||||
state: record.state,
|
||||
turnId: record.turnId.trim(),
|
||||
// Why: an omitted timestamp is a valid payload; normalize it to null rather
|
||||
// than dropping the whole lifecycle record.
|
||||
timestamp: record.timestamp ?? null
|
||||
}
|
||||
}
|
||||
|
||||
export function parseRuntimeNativeChatReadSessionResult(
|
||||
value: unknown
|
||||
): NativeChatReadSessionResult {
|
||||
if (typeof value !== 'object' || value === null) {
|
||||
return { error: RUNTIME_NATIVE_CHAT_READ_ERROR }
|
||||
}
|
||||
const record = value as Record<string, unknown>
|
||||
if (Array.isArray(record.messages)) {
|
||||
const lifecycle = parseRuntimeNativeChatTurnLifecycle(record.lifecycle)
|
||||
return {
|
||||
messages: record.messages as NativeChatAppendedMessages,
|
||||
...(lifecycle ? { lifecycle } : {})
|
||||
}
|
||||
}
|
||||
if (typeof record.error === 'string') {
|
||||
return {
|
||||
error: record.error,
|
||||
...(record.notFound === true ? { notFound: true } : {})
|
||||
}
|
||||
}
|
||||
return { error: RUNTIME_NATIVE_CHAT_READ_ERROR }
|
||||
}
|
||||
|
|
@ -15,10 +15,12 @@ import {
|
|||
sendNativeChatMessageWithImageAttachments,
|
||||
submitNativeChatPrompt,
|
||||
sendNativeChatAskAnswer,
|
||||
resetNativeChatPtySendQueuesForTests,
|
||||
NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS,
|
||||
NATIVE_CHAT_SUBMIT_DELAY_MS,
|
||||
NATIVE_CHAT_QUESTION_STEP_MS,
|
||||
NATIVE_CHAT_ADVANCE_BUFFER_MS
|
||||
NATIVE_CHAT_ADVANCE_BUFFER_MS,
|
||||
NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT
|
||||
} from './native-chat-runtime-send'
|
||||
import {
|
||||
buildNativeChatImagePasteBytes,
|
||||
|
|
@ -29,25 +31,28 @@ import {
|
|||
const SETTINGS = {} as Parameters<typeof sendNativeChatMessage>[0]
|
||||
const PTY = 'pty-1'
|
||||
|
||||
function expectWriteOrder(calls: unknown[][], expected: string[]): void {
|
||||
expect(calls.map((call) => call[2])).toEqual(expected)
|
||||
}
|
||||
|
||||
describe('sendNativeChatMessage', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
sendRuntimePtyInput.mockClear()
|
||||
resetNativeChatPtySendQueuesForTests()
|
||||
sendRuntimePtyInput.mockReturnValue(true)
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
resetNativeChatPtySendQueuesForTests()
|
||||
})
|
||||
|
||||
it('writes the framed body immediately, before the Enter', () => {
|
||||
it('clears the TUI line, then writes the framed body, before the Enter', () => {
|
||||
const handle = sendNativeChatMessage(SETTINGS, PTY, 'hello world')
|
||||
// Body lands synchronously; Enter is still pending on the timer.
|
||||
expect(sendRuntimePtyInput).toHaveBeenCalledTimes(1)
|
||||
expect(sendRuntimePtyInput).toHaveBeenCalledWith(
|
||||
SETTINGS,
|
||||
PTY,
|
||||
expectWriteOrder(sendRuntimePtyInput.mock.calls, [
|
||||
NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT,
|
||||
buildNativeChatPasteBytes('hello world')
|
||||
)
|
||||
])
|
||||
expect(handle.settleAfterMs).toBe(NATIVE_CHAT_SUBMIT_DELAY_MS)
|
||||
})
|
||||
|
||||
|
|
@ -56,43 +61,119 @@ describe('sendNativeChatMessage', () => {
|
|||
// A short gap would fire Enter while a busy Codex has not yet landed the
|
||||
// paste, submitting an empty box — so nothing must happen before 500ms.
|
||||
vi.advanceTimersByTime(NATIVE_CHAT_SUBMIT_DELAY_MS - 1)
|
||||
expect(sendRuntimePtyInput).toHaveBeenCalledTimes(1)
|
||||
expect(sendRuntimePtyInput).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('writes the bare carriage-return Enter as a separate delayed write', () => {
|
||||
sendNativeChatMessage(SETTINGS, PTY, 'hi')
|
||||
vi.advanceTimersByTime(NATIVE_CHAT_SUBMIT_DELAY_MS)
|
||||
expect(sendRuntimePtyInput).toHaveBeenCalledTimes(2)
|
||||
expect(sendRuntimePtyInput).toHaveBeenLastCalledWith(SETTINGS, PTY, NATIVE_CHAT_SUBMIT)
|
||||
expectWriteOrder(sendRuntimePtyInput.mock.calls, [
|
||||
NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT,
|
||||
buildNativeChatPasteBytes('hi'),
|
||||
NATIVE_CHAT_SUBMIT
|
||||
])
|
||||
})
|
||||
|
||||
it('cancels the delayed Enter when its owning composer is detached', () => {
|
||||
it('cancels the delayed Enter and re-clears an unsubmitted body', () => {
|
||||
const handle = sendNativeChatMessage(SETTINGS, PTY, 'hi')
|
||||
handle.cancel()
|
||||
vi.advanceTimersByTime(NATIVE_CHAT_SUBMIT_DELAY_MS)
|
||||
|
||||
expect(sendRuntimePtyInput).toHaveBeenCalledTimes(1)
|
||||
// Pre-send clear + body + cancel clear; Enter must not fire.
|
||||
expectWriteOrder(sendRuntimePtyInput.mock.calls, [
|
||||
NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT,
|
||||
buildNativeChatPasteBytes('hi'),
|
||||
NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT
|
||||
])
|
||||
})
|
||||
|
||||
it('clears leftover unsubmitted body on cancel so the next send cannot glue', async () => {
|
||||
const handle = sendNativeChatMessage(SETTINGS, PTY, 'tell me a joke')
|
||||
handle.cancel()
|
||||
|
||||
sendNativeChatMessage(SETTINGS, PTY, 'continue')
|
||||
// Queue release after cancel is promise-chained; flush so the next body runs.
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(sendRuntimePtyInput.mock.calls.map((call) => call[2])).toEqual([
|
||||
NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT,
|
||||
buildNativeChatPasteBytes('tell me a joke'),
|
||||
NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT, // cancel cleanup
|
||||
NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT, // next send pre-clear
|
||||
buildNativeChatPasteBytes('continue')
|
||||
])
|
||||
})
|
||||
|
||||
it('does not clear the TUI input when cancel runs after Enter already fired', () => {
|
||||
const handle = sendNativeChatMessage(SETTINGS, PTY, 'already submitted')
|
||||
vi.advanceTimersByTime(NATIVE_CHAT_SUBMIT_DELAY_MS)
|
||||
sendRuntimePtyInput.mockClear()
|
||||
handle.cancel()
|
||||
|
||||
expect(sendRuntimePtyInput).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('matches orca-runtime writeTerminalAction Enter gap (500ms)', () => {
|
||||
expect(NATIVE_CHAT_SUBMIT_DELAY_MS).toBe(500)
|
||||
})
|
||||
|
||||
it('serializes rapid sends on the same PTY so bodies cannot glue before Enter', async () => {
|
||||
sendNativeChatMessage(SETTINGS, PTY, 'tell me a joke')
|
||||
sendNativeChatMessage(SETTINGS, PTY, 'continue')
|
||||
|
||||
// First clear+body are immediate; second sequence waits for the first Enter.
|
||||
expectWriteOrder(sendRuntimePtyInput.mock.calls, [
|
||||
NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT,
|
||||
buildNativeChatPasteBytes('tell me a joke')
|
||||
])
|
||||
|
||||
await vi.advanceTimersByTimeAsync(NATIVE_CHAT_SUBMIT_DELAY_MS)
|
||||
expectWriteOrder(sendRuntimePtyInput.mock.calls, [
|
||||
NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT,
|
||||
buildNativeChatPasteBytes('tell me a joke'),
|
||||
NATIVE_CHAT_SUBMIT,
|
||||
NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT,
|
||||
buildNativeChatPasteBytes('continue')
|
||||
])
|
||||
|
||||
await vi.advanceTimersByTimeAsync(NATIVE_CHAT_SUBMIT_DELAY_MS)
|
||||
expect(sendRuntimePtyInput).toHaveBeenLastCalledWith(SETTINGS, PTY, NATIVE_CHAT_SUBMIT)
|
||||
expect(sendRuntimePtyInput).toHaveBeenCalledTimes(6)
|
||||
})
|
||||
|
||||
it('does not serialize sends across different PTYs', () => {
|
||||
sendNativeChatMessage(SETTINGS, 'pty-a', 'one')
|
||||
sendNativeChatMessage(SETTINGS, 'pty-b', 'two')
|
||||
expectWriteOrder(sendRuntimePtyInput.mock.calls, [
|
||||
NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT,
|
||||
buildNativeChatPasteBytes('one'),
|
||||
NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT,
|
||||
buildNativeChatPasteBytes('two')
|
||||
])
|
||||
expect(sendRuntimePtyInput.mock.calls[1]?.[1]).toBe('pty-a')
|
||||
expect(sendRuntimePtyInput.mock.calls[3]?.[1]).toBe('pty-b')
|
||||
})
|
||||
})
|
||||
|
||||
describe('sendNativeChatMessageVerified', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
sendRuntimePtyInputVerified.mockReset().mockResolvedValue(true)
|
||||
resetNativeChatPtySendQueuesForTests()
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
resetNativeChatPtySendQueuesForTests()
|
||||
})
|
||||
|
||||
it('awaits body acceptance before the delayed Enter write', async () => {
|
||||
it('awaits body acceptance before the delayed Enter write (no pre-clear)', async () => {
|
||||
// Why: model-switch confirmation watches the PTY while this send runs;
|
||||
// verified option commands must not inject Ctrl+U noise.
|
||||
const result = sendNativeChatMessageVerified(SETTINGS, PTY, '/model sonnet')
|
||||
await Promise.resolve()
|
||||
|
||||
expect(sendRuntimePtyInputVerified).toHaveBeenCalledTimes(1)
|
||||
await vi.waitFor(() => {
|
||||
expect(sendRuntimePtyInputVerified).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
expect(sendRuntimePtyInputVerified).toHaveBeenCalledWith(
|
||||
SETTINGS,
|
||||
PTY,
|
||||
|
|
@ -103,6 +184,11 @@ describe('sendNativeChatMessageVerified', () => {
|
|||
|
||||
expect(await result).toBe(true)
|
||||
expect(sendRuntimePtyInputVerified).toHaveBeenLastCalledWith(SETTINGS, PTY, NATIVE_CHAT_SUBMIT)
|
||||
expect(
|
||||
sendRuntimePtyInputVerified.mock.calls.some(
|
||||
(call) => call[2] === NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('does not send Enter when the body is rejected', async () => {
|
||||
|
|
@ -112,16 +198,40 @@ describe('sendNativeChatMessageVerified', () => {
|
|||
await vi.runAllTimersAsync()
|
||||
|
||||
expect(sendRuntimePtyInputVerified).toHaveBeenCalledTimes(1)
|
||||
expect(
|
||||
sendRuntimePtyInputVerified.mock.calls.some((call) => call[2] === NATIVE_CHAT_SUBMIT)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('cancels the pending Enter when its composer detaches', async () => {
|
||||
it('cancels an in-flight chat Enter before delivering a verified option command', async () => {
|
||||
sendNativeChatMessage(SETTINGS, PTY, 'hello')
|
||||
expect(sendRuntimePtyInput).toHaveBeenCalled()
|
||||
|
||||
const result = sendNativeChatMessageVerified(SETTINGS, PTY, '/model haiku')
|
||||
// Chat cancel may Ctrl+U the unsubmitted body; Enter from chat must not fire.
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
await vi.advanceTimersByTimeAsync(NATIVE_CHAT_SUBMIT_DELAY_MS)
|
||||
|
||||
expect(await result).toBe(true)
|
||||
const submits = sendRuntimePtyInput.mock.calls.filter((call) => call[2] === NATIVE_CHAT_SUBMIT)
|
||||
// Only the verified path's Enter — chat's delayed Enter was cancelled.
|
||||
expect(submits).toHaveLength(0)
|
||||
expect(sendRuntimePtyInputVerified).toHaveBeenCalledWith(SETTINGS, PTY, NATIVE_CHAT_SUBMIT)
|
||||
})
|
||||
|
||||
it('returns false when the delayed Enter wait is aborted', async () => {
|
||||
const controller = new AbortController()
|
||||
const result = sendNativeChatMessageVerified(SETTINGS, PTY, '/model sonnet', controller.signal)
|
||||
await Promise.resolve()
|
||||
await vi.waitFor(() => {
|
||||
expect(sendRuntimePtyInputVerified).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
controller.abort()
|
||||
|
||||
expect(await result).toBe(false)
|
||||
expect(sendRuntimePtyInputVerified).toHaveBeenCalledTimes(1)
|
||||
expect(
|
||||
sendRuntimePtyInputVerified.mock.calls.some((call) => call[2] === NATIVE_CHAT_SUBMIT)
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -129,12 +239,14 @@ describe('sendNativeChatMessageWithImageAttachments', () => {
|
|||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
sendRuntimePtyInput.mockClear()
|
||||
resetNativeChatPtySendQueuesForTests()
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
resetNativeChatPtySendQueuesForTests()
|
||||
})
|
||||
|
||||
it('bracket-pastes image paths before prompt text so the TUI creates image chips', () => {
|
||||
it('clears the line, then bracket-pastes image paths before prompt text', () => {
|
||||
const handle = sendNativeChatMessageWithImageAttachments(SETTINGS, PTY, 'what do you see?', [
|
||||
'/tmp/orca-paste-image.png'
|
||||
])
|
||||
|
|
@ -143,15 +255,12 @@ describe('sendNativeChatMessageWithImageAttachments', () => {
|
|||
NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS + NATIVE_CHAT_SUBMIT_DELAY_MS
|
||||
)
|
||||
|
||||
expect(sendRuntimePtyInput).toHaveBeenCalledTimes(1)
|
||||
expect(sendRuntimePtyInput).toHaveBeenLastCalledWith(
|
||||
SETTINGS,
|
||||
PTY,
|
||||
expectWriteOrder(sendRuntimePtyInput.mock.calls, [
|
||||
NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT,
|
||||
buildNativeChatImagePasteBytes('/tmp/orca-paste-image.png')
|
||||
)
|
||||
])
|
||||
|
||||
vi.advanceTimersByTime(NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS)
|
||||
expect(sendRuntimePtyInput).toHaveBeenCalledTimes(2)
|
||||
expect(sendRuntimePtyInput).toHaveBeenLastCalledWith(
|
||||
SETTINGS,
|
||||
PTY,
|
||||
|
|
@ -159,8 +268,8 @@ describe('sendNativeChatMessageWithImageAttachments', () => {
|
|||
)
|
||||
|
||||
vi.advanceTimersByTime(NATIVE_CHAT_SUBMIT_DELAY_MS)
|
||||
expect(sendRuntimePtyInput).toHaveBeenCalledTimes(3)
|
||||
expect(sendRuntimePtyInput).toHaveBeenLastCalledWith(SETTINGS, PTY, NATIVE_CHAT_SUBMIT)
|
||||
expect(sendRuntimePtyInput).toHaveBeenCalledTimes(4)
|
||||
})
|
||||
|
||||
it('waits the normal submit gap for an attachment-only send', () => {
|
||||
|
|
@ -170,11 +279,16 @@ describe('sendNativeChatMessageWithImageAttachments', () => {
|
|||
|
||||
expect(handle.settleAfterMs).toBe(NATIVE_CHAT_SUBMIT_DELAY_MS)
|
||||
|
||||
expectWriteOrder(sendRuntimePtyInput.mock.calls, [
|
||||
NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT,
|
||||
buildNativeChatImagePasteBytes('/tmp/orca-paste-image.png')
|
||||
])
|
||||
|
||||
vi.advanceTimersByTime(NATIVE_CHAT_SUBMIT_DELAY_MS - 1)
|
||||
expect(sendRuntimePtyInput).toHaveBeenCalledTimes(1)
|
||||
expect(sendRuntimePtyInput).toHaveBeenCalledTimes(2)
|
||||
|
||||
vi.advanceTimersByTime(1)
|
||||
expect(sendRuntimePtyInput).toHaveBeenCalledTimes(2)
|
||||
expect(sendRuntimePtyInput).toHaveBeenCalledTimes(3)
|
||||
expect(sendRuntimePtyInput).toHaveBeenLastCalledWith(SETTINGS, PTY, NATIVE_CHAT_SUBMIT)
|
||||
})
|
||||
|
||||
|
|
@ -185,7 +299,15 @@ describe('sendNativeChatMessageWithImageAttachments', () => {
|
|||
handle.cancel()
|
||||
vi.runAllTimers()
|
||||
|
||||
expect(sendRuntimePtyInput).toHaveBeenCalledTimes(1)
|
||||
// Pre-clear + image body + cancel clear; no Enter.
|
||||
expectWriteOrder(sendRuntimePtyInput.mock.calls, [
|
||||
NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT,
|
||||
buildNativeChatImagePasteBytes('/tmp/orca-paste-image.png'),
|
||||
NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT
|
||||
])
|
||||
expect(sendRuntimePtyInput.mock.calls.some((call) => call[2] === NATIVE_CHAT_SUBMIT)).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -196,9 +318,8 @@ describe('empty prompt submit', () => {
|
|||
|
||||
it('submits an empty prompt with a bare Enter', () => {
|
||||
submitNativeChatPrompt(SETTINGS, PTY)
|
||||
|
||||
expect(sendRuntimePtyInput).toHaveBeenCalledTimes(1)
|
||||
expect(sendRuntimePtyInput).toHaveBeenLastCalledWith(SETTINGS, PTY, NATIVE_CHAT_SUBMIT)
|
||||
expect(sendRuntimePtyInput).toHaveBeenCalledOnce()
|
||||
expect(sendRuntimePtyInput).toHaveBeenCalledWith(SETTINGS, PTY, NATIVE_CHAT_SUBMIT)
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -213,72 +334,43 @@ describe('sendNativeChatAskAnswer', () => {
|
|||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('paces each keystroke a full step apart (the proven submit gap + advance buffer)', () => {
|
||||
expect(NATIVE_CHAT_QUESTION_STEP_MS).toBe(1000)
|
||||
expect(NATIVE_CHAT_ADVANCE_BUFFER_MS).toBe(500)
|
||||
})
|
||||
|
||||
it('no writes and 0 settle for an empty keystroke list', () => {
|
||||
it('returns a no-op handle for an empty key group list', () => {
|
||||
const handle = sendNativeChatAskAnswer(SETTINGS, PTY, [])
|
||||
expect(handle.settleAfterMs).toBe(0)
|
||||
vi.runAllTimers()
|
||||
expect(sendRuntimePtyInput).toHaveBeenCalledTimes(0)
|
||||
handle.cancel()
|
||||
expect(sendRuntimePtyInput).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('single option-number keystroke: fires at t=0, settles a submit gap later', () => {
|
||||
// The STA-1860 fix: a lone single-select pick is delivered as the option
|
||||
// NUMBER, with no trailing Enter (the number both selects and commits).
|
||||
const handle = sendNativeChatAskAnswer(SETTINGS, PTY, [{ raw: '2' }])
|
||||
expect(handle.settleAfterMs).toBe(NATIVE_CHAT_SUBMIT_DELAY_MS)
|
||||
|
||||
// Scheduled at t=0 (setTimeout 0), not written synchronously.
|
||||
expect(sendRuntimePtyInput).toHaveBeenCalledTimes(0)
|
||||
vi.advanceTimersByTime(0)
|
||||
expect(sendRuntimePtyInput).toHaveBeenCalledTimes(1)
|
||||
expect(sendRuntimePtyInput).toHaveBeenLastCalledWith(SETTINGS, PTY, '2')
|
||||
vi.runAllTimers()
|
||||
expect(sendRuntimePtyInput).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('writes each group a step apart; text groups go through the paste framer', () => {
|
||||
const groups = [{ raw: '3' }, { text: 'custom answer' }, { raw: '\r' }]
|
||||
const handle = sendNativeChatAskAnswer(SETTINGS, PTY, groups)
|
||||
it('paces key groups so selector steps render before the next write', () => {
|
||||
const handle = sendNativeChatAskAnswer(SETTINGS, PTY, [
|
||||
{ raw: '1' },
|
||||
{ raw: '2' },
|
||||
{ text: 'custom answer' }
|
||||
])
|
||||
expect(handle.settleAfterMs).toBe(
|
||||
2 * NATIVE_CHAT_QUESTION_STEP_MS + NATIVE_CHAT_SUBMIT_DELAY_MS
|
||||
)
|
||||
|
||||
vi.advanceTimersByTime(0)
|
||||
expect(sendRuntimePtyInput).toHaveBeenLastCalledWith(SETTINGS, PTY, '3')
|
||||
expect(sendRuntimePtyInput).toHaveBeenCalledWith(SETTINGS, PTY, '1')
|
||||
|
||||
// The next group must wait a full step so the "Type something" row renders.
|
||||
vi.advanceTimersByTime(NATIVE_CHAT_QUESTION_STEP_MS - 1)
|
||||
expect(sendRuntimePtyInput).toHaveBeenCalledTimes(1)
|
||||
vi.advanceTimersByTime(1)
|
||||
vi.advanceTimersByTime(NATIVE_CHAT_QUESTION_STEP_MS)
|
||||
expect(sendRuntimePtyInput).toHaveBeenCalledWith(SETTINGS, PTY, '2')
|
||||
|
||||
vi.advanceTimersByTime(NATIVE_CHAT_QUESTION_STEP_MS)
|
||||
expect(sendRuntimePtyInput).toHaveBeenLastCalledWith(
|
||||
SETTINGS,
|
||||
PTY,
|
||||
buildNativeChatPasteBytes('custom answer')
|
||||
)
|
||||
|
||||
vi.advanceTimersByTime(NATIVE_CHAT_QUESTION_STEP_MS)
|
||||
expect(sendRuntimePtyInput).toHaveBeenLastCalledWith(SETTINGS, PTY, NATIVE_CHAT_SUBMIT)
|
||||
|
||||
vi.runAllTimers()
|
||||
const calls = sendRuntimePtyInput.mock.calls.map((c) => c[2])
|
||||
expect(calls).toEqual(['3', buildNativeChatPasteBytes('custom answer'), NATIVE_CHAT_SUBMIT])
|
||||
})
|
||||
|
||||
it('cancel clears every pending keystroke', () => {
|
||||
const handle = sendNativeChatAskAnswer(SETTINGS, PTY, [
|
||||
{ raw: '1' },
|
||||
{ raw: '\x1b[C' },
|
||||
{ raw: '\r' }
|
||||
])
|
||||
it('cancels remaining key group timers', () => {
|
||||
const handle = sendNativeChatAskAnswer(SETTINGS, PTY, [{ raw: '1' }, { raw: '2' }])
|
||||
vi.advanceTimersByTime(0)
|
||||
expect(sendRuntimePtyInput).toHaveBeenCalledTimes(1)
|
||||
handle.cancel()
|
||||
vi.runAllTimers()
|
||||
// Only the first keystroke landed; the rest were cancelled.
|
||||
vi.advanceTimersByTime(NATIVE_CHAT_QUESTION_STEP_MS * 2)
|
||||
expect(sendRuntimePtyInput).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
|
|
@ -317,3 +409,9 @@ describe('sendNativeChatAskAnswer', () => {
|
|||
await vi.waitFor(() => expect(onSettled).toHaveBeenCalledExactlyOnceWith(true))
|
||||
})
|
||||
})
|
||||
|
||||
describe('constants', () => {
|
||||
it('exports the ask-answer advance buffer used by interactive cards', () => {
|
||||
expect(NATIVE_CHAT_ADVANCE_BUFFER_MS).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
// Runtime send for native chat: writes the framed message body, then the Enter
|
||||
// as a SEPARATE delayed pty write. Kept apart from the pure byte builders in
|
||||
// native-chat-send.ts so those stay IO-free and unit-testable without aliases.
|
||||
// Runtime send for native chat: clear any unsubmitted TUI line, write the framed
|
||||
// body, then Enter as a SEPARATE delayed pty write. Kept apart from the pure
|
||||
// byte builders in native-chat-send.ts so those stay IO-free and unit-testable.
|
||||
|
||||
import {
|
||||
sendRuntimePtyInput,
|
||||
|
|
@ -18,11 +18,24 @@ import {
|
|||
buildNativeChatPasteBytes,
|
||||
NATIVE_CHAT_SUBMIT
|
||||
} from './native-chat-send'
|
||||
import {
|
||||
cancelNativeChatPtySends,
|
||||
enqueueNativeChatPtySend,
|
||||
resetNativeChatPtySendQueuesForTests,
|
||||
waitForNativeChatPtyIdle
|
||||
} from './native-chat-pty-send-queue'
|
||||
|
||||
export { NATIVE_CHAT_ADVANCE_BUFFER_MS, NATIVE_CHAT_QUESTION_STEP_MS, NATIVE_CHAT_SUBMIT_DELAY_MS }
|
||||
export { resetNativeChatPtySendQueuesForTests }
|
||||
|
||||
export const NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS = 300
|
||||
|
||||
// Why: agent TUI composers treat Ctrl+U as kill-to-start-of-line. Chat sends
|
||||
// start from an empty line so a prior cancelled paste cannot glue onto the next
|
||||
// prompt. Not used on verified option commands — model-switch confirmation
|
||||
// observes the PTY and Ctrl+U can miss confirmation markers.
|
||||
export const NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT = '\x15'
|
||||
|
||||
/** Cancels an in-flight send's pending pty writes (the delayed Enter, and any
|
||||
* later question bodies/Enters). Safe to call after the send completes. */
|
||||
export type NativeChatSendHandle = {
|
||||
|
|
@ -31,22 +44,46 @@ export type NativeChatSendHandle = {
|
|||
settleAfterMs: number
|
||||
}
|
||||
|
||||
type RuntimeSettings = ReturnType<typeof getSettingsForAgentTabRuntimeOwner>
|
||||
|
||||
function clearUnsubmittedAgentInput(settings: RuntimeSettings, ptyId: string): void {
|
||||
sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT)
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a native-chat message through the verified runtime pty path: framed body
|
||||
* first, then a separate delayed Enter. `sendRuntimePtyInput` branches local
|
||||
* pty:write vs remote runtime RPC, so this works for SSH panes too. Returns a
|
||||
* cancel handle so callers can drop the still-pending Enter on unmount/stop.
|
||||
* Chat message path:
|
||||
* 1. clear any unsubmitted TUI line
|
||||
* 2. write framed body
|
||||
* 3. delayed Enter (separate write — same-write CR can be swallowed by paste)
|
||||
*
|
||||
* Serialized per PTY so rapid sends cannot glue before Enter.
|
||||
*/
|
||||
export function sendNativeChatMessage(
|
||||
settings: ReturnType<typeof getSettingsForAgentTabRuntimeOwner>,
|
||||
settings: RuntimeSettings,
|
||||
ptyId: string,
|
||||
text: string
|
||||
): NativeChatSendHandle {
|
||||
sendRuntimePtyInput(settings, ptyId, buildNativeChatPasteBytes(text))
|
||||
const timer = setTimeout(() => {
|
||||
sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_SUBMIT)
|
||||
}, NATIVE_CHAT_SUBMIT_DELAY_MS)
|
||||
return { cancel: () => clearTimeout(timer), settleAfterMs: NATIVE_CHAT_SUBMIT_DELAY_MS }
|
||||
return enqueueNativeChatPtySend(
|
||||
ptyId,
|
||||
NATIVE_CHAT_SUBMIT_DELAY_MS,
|
||||
({ isCancelled, delay, markSubmitted }) => {
|
||||
if (isCancelled()) {
|
||||
return
|
||||
}
|
||||
clearUnsubmittedAgentInput(settings, ptyId)
|
||||
if (isCancelled()) {
|
||||
return
|
||||
}
|
||||
sendRuntimePtyInput(settings, ptyId, buildNativeChatPasteBytes(text))
|
||||
delay(NATIVE_CHAT_SUBMIT_DELAY_MS, () => {
|
||||
sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_SUBMIT)
|
||||
markSubmitted()
|
||||
})
|
||||
},
|
||||
{
|
||||
onCancelUnsubmitted: () => clearUnsubmittedAgentInput(settings, ptyId)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
function waitForNativeChatSubmit(signal?: AbortSignal): Promise<boolean> {
|
||||
|
|
@ -70,12 +107,28 @@ function waitForNativeChatSubmit(signal?: AbortSignal): Promise<boolean> {
|
|||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Session-option / slash command path (model switch, /effort, …).
|
||||
*
|
||||
* Does not pre-clear the line (model-switch confirmation watches the PTY).
|
||||
* Cancels any in-flight chat clear/body/Enter on this PTY first so a delayed
|
||||
* chat Enter cannot dismiss Claude's "Switch model?" dialog.
|
||||
*/
|
||||
export async function sendNativeChatMessageVerified(
|
||||
settings: ReturnType<typeof getSettingsForAgentTabRuntimeOwner>,
|
||||
settings: RuntimeSettings,
|
||||
ptyId: string,
|
||||
text: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<boolean> {
|
||||
// Why: chat sends hold a delayed Enter for 500ms. Opening the model picker in
|
||||
// that window used to let that Enter hit Claude's confirmation UI, so
|
||||
// verification timed out with "Could not verify the model change".
|
||||
cancelNativeChatPtySends(ptyId)
|
||||
await waitForNativeChatPtyIdle(ptyId)
|
||||
if (signal?.aborted) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Why: option commands await remote/SSH acceptance so the Enter cannot race
|
||||
// ahead of the body while a model-change observer is already armed.
|
||||
const bodyAccepted = await sendRuntimePtyInputVerified(
|
||||
|
|
@ -90,7 +143,7 @@ export async function sendNativeChatMessageVerified(
|
|||
}
|
||||
|
||||
export function sendNativeChatMessageWithImageAttachments(
|
||||
settings: ReturnType<typeof getSettingsForAgentTabRuntimeOwner>,
|
||||
settings: RuntimeSettings,
|
||||
ptyId: string,
|
||||
text: string,
|
||||
imagePaths: readonly string[]
|
||||
|
|
@ -98,62 +151,59 @@ export function sendNativeChatMessageWithImageAttachments(
|
|||
if (imagePaths.length === 0) {
|
||||
return sendNativeChatMessage(settings, ptyId, text)
|
||||
}
|
||||
const timers: ReturnType<typeof setTimeout>[] = []
|
||||
for (const imagePath of imagePaths) {
|
||||
sendRuntimePtyInput(settings, ptyId, buildNativeChatImagePasteBytes(imagePath))
|
||||
}
|
||||
const trimmedText = text.trim()
|
||||
if (trimmedText.length > 0) {
|
||||
timers.push(
|
||||
setTimeout(() => {
|
||||
sendRuntimePtyInput(settings, ptyId, buildNativeChatPasteBytes(text))
|
||||
}, NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS)
|
||||
)
|
||||
}
|
||||
timers.push(
|
||||
setTimeout(
|
||||
() => {
|
||||
sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_SUBMIT)
|
||||
},
|
||||
trimmedText.length > 0
|
||||
? NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS + NATIVE_CHAT_SUBMIT_DELAY_MS
|
||||
: NATIVE_CHAT_SUBMIT_DELAY_MS
|
||||
)
|
||||
)
|
||||
return {
|
||||
cancel: () => {
|
||||
for (const timer of timers) {
|
||||
clearTimeout(timer)
|
||||
const durationMs =
|
||||
trimmedText.length > 0
|
||||
? NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS + NATIVE_CHAT_SUBMIT_DELAY_MS
|
||||
: NATIVE_CHAT_SUBMIT_DELAY_MS
|
||||
return enqueueNativeChatPtySend(
|
||||
ptyId,
|
||||
durationMs,
|
||||
({ isCancelled, delay, markSubmitted }) => {
|
||||
if (isCancelled()) {
|
||||
return
|
||||
}
|
||||
clearUnsubmittedAgentInput(settings, ptyId)
|
||||
if (isCancelled()) {
|
||||
return
|
||||
}
|
||||
for (const imagePath of imagePaths) {
|
||||
sendRuntimePtyInput(settings, ptyId, buildNativeChatImagePasteBytes(imagePath))
|
||||
}
|
||||
if (trimmedText.length > 0) {
|
||||
delay(NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS, () => {
|
||||
sendRuntimePtyInput(settings, ptyId, buildNativeChatPasteBytes(text))
|
||||
})
|
||||
delay(NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS + NATIVE_CHAT_SUBMIT_DELAY_MS, () => {
|
||||
sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_SUBMIT)
|
||||
markSubmitted()
|
||||
})
|
||||
return
|
||||
}
|
||||
delay(NATIVE_CHAT_SUBMIT_DELAY_MS, () => {
|
||||
sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_SUBMIT)
|
||||
markSubmitted()
|
||||
})
|
||||
},
|
||||
settleAfterMs:
|
||||
trimmedText.length > 0
|
||||
? NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS + NATIVE_CHAT_SUBMIT_DELAY_MS
|
||||
: NATIVE_CHAT_SUBMIT_DELAY_MS
|
||||
}
|
||||
{
|
||||
onCancelUnsubmitted: () => clearUnsubmittedAgentInput(settings, ptyId)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/** Submit a TUI prompt with no body (Enter only) — e.g. a plain submit when the
|
||||
* composer is empty. */
|
||||
export function submitNativeChatPrompt(
|
||||
settings: ReturnType<typeof getSettingsForAgentTabRuntimeOwner>,
|
||||
ptyId: string
|
||||
): void {
|
||||
export function submitNativeChatPrompt(settings: RuntimeSettings, ptyId: string): void {
|
||||
sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_SUBMIT)
|
||||
}
|
||||
|
||||
/**
|
||||
* Answer Claude's AskUserQuestion by writing its keystroke groups (built by
|
||||
* `buildAskAnswerKeys`) to the PTY, one group per `NATIVE_CHAT_QUESTION_STEP_MS`
|
||||
* step so the arrow-navigate selector applies each before the next — a
|
||||
* navigation/number keystroke batched with the Enter that follows would commit
|
||||
* the wrong (default) option. `raw` groups are written verbatim as keystrokes;
|
||||
* `text` groups (a free-text answer) go through the composer's paste framing.
|
||||
* Returns a cancel handle clearing every pending timer so a detached sequence
|
||||
* can't keep writing PTY bytes after unmount/stop.
|
||||
* step so the arrow-navigate selector applies each before the next.
|
||||
*/
|
||||
export function sendNativeChatAskAnswer(
|
||||
settings: ReturnType<typeof getSettingsForAgentTabRuntimeOwner>,
|
||||
settings: RuntimeSettings,
|
||||
ptyId: string,
|
||||
groups: AskAnswerKeyGroup[],
|
||||
onSettled?: (delivered: boolean) => void
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -1,8 +1,4 @@
|
|||
import type {
|
||||
NativeChatApi,
|
||||
NativeChatAppendedMessages,
|
||||
NativeChatReadSessionResult
|
||||
} from '../../../../preload/api-types'
|
||||
import type { NativeChatApi, NativeChatAppendedMessages } from '../../../../preload/api-types'
|
||||
import { isWebClientLocation } from '@/lib/web-client-location'
|
||||
import {
|
||||
callRuntimeRpc,
|
||||
|
|
@ -10,6 +6,11 @@ import {
|
|||
type RuntimeClientTarget
|
||||
} from '@/runtime/runtime-rpc-client'
|
||||
import { isRuntimeCompatBlockError } from '@/runtime/runtime-protocol-compat'
|
||||
import {
|
||||
parseRuntimeNativeChatReadSessionResult,
|
||||
parseRuntimeNativeChatTurnLifecycle,
|
||||
RUNTIME_NATIVE_CHAT_READ_ERROR
|
||||
} from './native-chat-runtime-contract'
|
||||
|
||||
/** The read/subscribe surface the live-session hook needs, decoupled from where
|
||||
* the transcript actually lives. Same shape as `window.api.nativeChat`, so the
|
||||
|
|
@ -34,7 +35,7 @@ export function toRuntimeNativeChatErrorMessage(err: unknown): string {
|
|||
if (isRuntimeCompatBlockError(err)) {
|
||||
return RUNTIME_TOO_OLD
|
||||
}
|
||||
return "Couldn't read agent chat from the remote runtime."
|
||||
return RUNTIME_NATIVE_CHAT_READ_ERROR
|
||||
}
|
||||
|
||||
/** Delegates straight to the local Electron IPC bridge. On the web client
|
||||
|
|
@ -53,12 +54,13 @@ function createRuntimeNativeChatTransport(environmentId: string): NativeChatSess
|
|||
return {
|
||||
readSession: async (agent, sessionId, limit, transcriptPath) => {
|
||||
try {
|
||||
return await callRuntimeRpc<NativeChatReadSessionResult>(
|
||||
const result = await callRuntimeRpc<unknown>(
|
||||
target,
|
||||
'nativeChat.readSession',
|
||||
{ agent, sessionId, limit, transcriptPath },
|
||||
{ timeoutMs: 15_000 }
|
||||
)
|
||||
return parseRuntimeNativeChatReadSessionResult(result)
|
||||
} catch (err) {
|
||||
return { error: toRuntimeNativeChatErrorMessage(err) }
|
||||
}
|
||||
|
|
@ -131,7 +133,9 @@ function createRuntimeNativeChatTransport(environmentId: string): NativeChatSess
|
|||
messages?: NativeChatAppendedMessages
|
||||
hasMore?: boolean
|
||||
error?: string
|
||||
lifecycle?: unknown
|
||||
}
|
||||
const lifecycle = parseRuntimeNativeChatTurnLifecycle(frame?.lifecycle)
|
||||
if (
|
||||
(frame?.type === 'appended' ||
|
||||
frame?.type === 'snapshot' ||
|
||||
|
|
@ -144,14 +148,16 @@ function createRuntimeNativeChatTransport(environmentId: string): NativeChatSess
|
|||
type: 'snapshot',
|
||||
messages: frame.messages,
|
||||
hasMore: frame.hasMore ?? frame.messages.length >= (limit ?? 300),
|
||||
...(frame.error ? { error: frame.error } : {})
|
||||
...(frame.error ? { error: frame.error } : {}),
|
||||
...(lifecycle ? { lifecycle } : {})
|
||||
})
|
||||
} else if (frame.type === 'snapshot') {
|
||||
onFrame({
|
||||
type: 'snapshot',
|
||||
messages: frame.messages,
|
||||
hasMore: frame.hasMore ?? false,
|
||||
...(frame.error ? { error: frame.error } : {})
|
||||
...(frame.error ? { error: frame.error } : {}),
|
||||
...(lifecycle ? { lifecycle } : {})
|
||||
})
|
||||
} else {
|
||||
onFrame(
|
||||
|
|
@ -159,9 +165,14 @@ function createRuntimeNativeChatTransport(environmentId: string): NativeChatSess
|
|||
? {
|
||||
type: 'replacement',
|
||||
messages: frame.messages,
|
||||
hasMore: frame.hasMore ?? false
|
||||
hasMore: frame.hasMore ?? false,
|
||||
...(lifecycle ? { lifecycle } : {})
|
||||
}
|
||||
: {
|
||||
type: 'appended',
|
||||
messages: frame.messages,
|
||||
...(lifecycle ? { lifecycle } : {})
|
||||
}
|
||||
: { type: 'appended', messages: frame.messages }
|
||||
)
|
||||
}
|
||||
} else if (!receivedInitial) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
}
|
||||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useAppStore } from '../../store'
|
||||
import {
|
||||
NATIVE_CHAT_SOURCE_PRIORITY,
|
||||
type AgentType,
|
||||
|
|
@ -23,6 +22,8 @@ import {
|
|||
nextNativeChatLimit
|
||||
} from './native-chat-pagination'
|
||||
import { getNativeChatSessionTransport } from './native-chat-session-transport'
|
||||
import { useNativeChatTranscriptLifecycle } from './use-native-chat-transcript-lifecycle'
|
||||
import { useNativeChatHookStatus } from './use-native-chat-hook-status'
|
||||
|
||||
export type UseNativeChatLiveSessionArgs = {
|
||||
/** Composite `${tabId}:${leafId}` key — selects the live hook entry. */
|
||||
|
|
@ -126,6 +127,7 @@ export function useNativeChatLiveSession(
|
|||
const [read, setRead] = useState<ReadState>({ phase: 'loading' })
|
||||
const [hasMore, setHasMore] = useState(false)
|
||||
const [loadingEarlier, setLoadingEarlier] = useState(false)
|
||||
const [transcriptLifecycle, transcriptLifecycleControl] = useNativeChatTranscriptLifecycle()
|
||||
// The active read window; raised by loadEarlier to page in older history.
|
||||
const limitRef = useRef(NATIVE_CHAT_INITIAL_LIMIT)
|
||||
|
||||
|
|
@ -139,14 +141,7 @@ export function useNativeChatLiveSession(
|
|||
// live frame costs O(incoming), not O(existing) (#18 parity for desktop).
|
||||
const appendMergerRef = useRef(createNativeChatMerger(NATIVE_CHAT_SOURCE_PRIORITY))
|
||||
|
||||
// Live hook state for this pane, selected narrowly so unrelated status churn
|
||||
// doesn't re-render the chat view.
|
||||
const hookState = useAppStore((s) => s.agentStatusByPaneKey[paneKey]?.state ?? null)
|
||||
// When that state began (epoch ms). A separate primitive selector so it doesn't
|
||||
// churn renders; lets a stale 'working' self-heal once this turn's reply lands.
|
||||
const hookStateStartedAt = useAppStore(
|
||||
(s) => s.agentStatusByPaneKey[paneKey]?.stateStartedAt ?? null
|
||||
)
|
||||
const [hookState, hookStateStartedAt, hookHasWorkingSubagents] = useNativeChatHookStatus(paneKey)
|
||||
|
||||
const latestSessionId = useRef<string | null>(sessionId)
|
||||
latestSessionId.current = sessionId
|
||||
|
|
@ -170,6 +165,7 @@ export function useNativeChatLiveSession(
|
|||
// every source generation must invalidate pagination captured before it.
|
||||
transcriptEpochRef.current += 1
|
||||
setLoadingEarlier(false)
|
||||
transcriptLifecycleControl.reset()
|
||||
if (!sessionId) {
|
||||
// No session id yet: nothing to read or tail. Surface live hook state on
|
||||
// an empty transcript; backfills once the id arrives (effect re-runs).
|
||||
|
|
@ -225,6 +221,7 @@ export function useNativeChatLiveSession(
|
|||
return
|
||||
}
|
||||
const messages = result?.messages ?? []
|
||||
transcriptLifecycleControl.replace(result?.lifecycle)
|
||||
setRead({ phase: 'ready', messages })
|
||||
setHasMore(hasMoreNativeChatHistory(messages.length, limitRef.current))
|
||||
})
|
||||
|
|
@ -258,12 +255,14 @@ export function useNativeChatLiveSession(
|
|||
setRead({ phase: 'error', error: frame.error })
|
||||
return
|
||||
}
|
||||
transcriptLifecycleControl.replace(frame.lifecycle)
|
||||
replaceList(appendMergerRef.current, frame.messages)
|
||||
setAppended([])
|
||||
setRead({ phase: 'ready', messages: appendMergerRef.current.list })
|
||||
setHasMore(frame.hasMore)
|
||||
return
|
||||
}
|
||||
transcriptLifecycleControl.append(frame.lifecycle)
|
||||
// Merge by id (re-emits replace in place) then bound to the window so
|
||||
// the bucket can't grow without limit. The base read still holds older
|
||||
// turns, and the assembler re-dedups the concat, so trimming the recent
|
||||
|
|
@ -296,7 +295,7 @@ export function useNativeChatLiveSession(
|
|||
}
|
||||
// `transport` identity changes on an owner flip, re-running this effect to
|
||||
// tear down the old host's subscription and open one against the new host.
|
||||
}, [agent, sessionId, transcriptPath, transport])
|
||||
}, [agent, sessionId, transcriptPath, transport, transcriptLifecycleControl])
|
||||
|
||||
const loadEarlier = useCallback(() => {
|
||||
if (!sessionId || loadingEarlier || !hasMore || read.phase !== 'ready') {
|
||||
|
|
@ -304,6 +303,7 @@ export function useNativeChatLiveSession(
|
|||
}
|
||||
const nextLimit = nextNativeChatLimit(limitRef.current)
|
||||
const requestEpoch = transcriptEpochRef.current
|
||||
const lifecycleRevision = transcriptLifecycleControl.revision()
|
||||
setLoadingEarlier(true)
|
||||
void transport
|
||||
.readSession(agent, sessionId, nextLimit, transcriptPath ?? undefined)
|
||||
|
|
@ -324,6 +324,7 @@ export function useNativeChatLiveSession(
|
|||
// Read results are an ordered tail — replace the base list so the older
|
||||
// page prepends in order; live appends stay in their separate bucket.
|
||||
setRead({ phase: 'ready', messages: result.messages })
|
||||
transcriptLifecycleControl.replaceFromPagination(result.lifecycle, lifecycleRevision)
|
||||
setHasMore(hasMoreNativeChatHistory(result.messages.length, nextLimit))
|
||||
})
|
||||
.catch(() => {
|
||||
|
|
@ -339,7 +340,16 @@ export function useNativeChatLiveSession(
|
|||
setLoadingEarlier(false)
|
||||
}
|
||||
})
|
||||
}, [agent, sessionId, transcriptPath, transport, hasMore, loadingEarlier, read.phase])
|
||||
}, [
|
||||
agent,
|
||||
sessionId,
|
||||
transcriptPath,
|
||||
transport,
|
||||
hasMore,
|
||||
loadingEarlier,
|
||||
read.phase,
|
||||
transcriptLifecycleControl
|
||||
])
|
||||
|
||||
// Assembled messages reuse the incremental assembler across appends. Computed
|
||||
// outside the status memo: hookState changes only the status override, not the
|
||||
|
|
@ -383,6 +393,8 @@ export function useNativeChatLiveSession(
|
|||
agent,
|
||||
hookState,
|
||||
stateStartedAt: hookStateStartedAt,
|
||||
transcriptLifecycle,
|
||||
hookHasWorkingSubagents,
|
||||
// Why: a watcher append (fix for #8401) can land content while the read is
|
||||
// still retrying ('loading') or after it settled into 'error' — in both
|
||||
// cases showing the live content beats a spinner or a stale error, so each
|
||||
|
|
@ -398,6 +410,8 @@ export function useNativeChatLiveSession(
|
|||
agent,
|
||||
hookState,
|
||||
hookStateStartedAt,
|
||||
transcriptLifecycle,
|
||||
hookHasWorkingSubagents,
|
||||
hasMore,
|
||||
loadingEarlier,
|
||||
loadEarlier,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,54 @@
|
|||
import { useCallback, useMemo, useRef, useState } from 'react'
|
||||
import type { NativeChatTurnLifecycle } from '../../../../shared/native-chat-types'
|
||||
|
||||
type TranscriptLifecycleState = {
|
||||
lifecycle?: NativeChatTurnLifecycle
|
||||
}
|
||||
|
||||
type TranscriptLifecycleControl = {
|
||||
reset: () => void
|
||||
replace: (lifecycle: NativeChatTurnLifecycle | undefined) => void
|
||||
append: (lifecycle: NativeChatTurnLifecycle | undefined) => void
|
||||
revision: () => number
|
||||
replaceFromPagination: (lifecycle: NativeChatTurnLifecycle | undefined, revision: number) => void
|
||||
}
|
||||
|
||||
export function useNativeChatTranscriptLifecycle(): readonly [
|
||||
NativeChatTurnLifecycle | undefined,
|
||||
TranscriptLifecycleControl
|
||||
] {
|
||||
const [state, setState] = useState<TranscriptLifecycleState>({})
|
||||
// Why: pagination may resolve after a live completion; its older boundary
|
||||
// can update history only when no live lifecycle write won the race.
|
||||
const revisionRef = useRef(0)
|
||||
|
||||
const replace = useCallback((lifecycle: NativeChatTurnLifecycle | undefined): void => {
|
||||
revisionRef.current += 1
|
||||
setState({ lifecycle })
|
||||
}, [])
|
||||
const reset = useCallback((): void => replace(undefined), [replace])
|
||||
const append = useCallback((lifecycle: NativeChatTurnLifecycle | undefined): void => {
|
||||
if (!lifecycle) {
|
||||
return
|
||||
}
|
||||
revisionRef.current += 1
|
||||
setState({ lifecycle })
|
||||
}, [])
|
||||
const revision = useCallback((): number => revisionRef.current, [])
|
||||
const replaceFromPagination = useCallback(
|
||||
(lifecycle: NativeChatTurnLifecycle | undefined, expectedRevision: number): void => {
|
||||
if (!lifecycle || revisionRef.current !== expectedRevision) {
|
||||
return
|
||||
}
|
||||
revisionRef.current += 1
|
||||
setState((current) => ({ ...current, lifecycle }))
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const control = useMemo<TranscriptLifecycleControl>(
|
||||
() => ({ reset, replace, append, revision, replaceFromPagination }),
|
||||
[append, replace, replaceFromPagination, reset, revision]
|
||||
)
|
||||
return [state.lifecycle, control]
|
||||
}
|
||||
|
|
@ -329,7 +329,9 @@ export function AppearanceWindowSidebarSection({
|
|||
checked={settings.showPinnedWorktreesInGroups === true}
|
||||
onChange={() =>
|
||||
updateSettings({
|
||||
showPinnedWorktreesInGroups: !(settings.showPinnedWorktreesInGroups === true)
|
||||
showPinnedWorktreesInGroups: !(
|
||||
settings.showPinnedWorktreesInGroups === true
|
||||
)
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -702,6 +702,88 @@ describe('web settings preload API', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('web native chat preload API', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
vi.doUnmock('./web-runtime-client')
|
||||
})
|
||||
|
||||
it('forwards validated lifecycle metadata from reads and stream frames', async () => {
|
||||
const lifecycle = { state: 'completed', turnId: 'turn-1', timestamp: 42 } as const
|
||||
const message = {
|
||||
id: 'a-1',
|
||||
role: 'assistant' as const,
|
||||
blocks: [{ type: 'text' as const, text: 'done' }],
|
||||
timestamp: 42,
|
||||
source: 'transcript' as const
|
||||
}
|
||||
vi.doMock('./web-runtime-client', () => ({
|
||||
WebRuntimeClient: class {
|
||||
call(): Promise<RuntimeRpcResponse<unknown>> {
|
||||
return Promise.resolve({
|
||||
id: 'read-1',
|
||||
ok: true,
|
||||
result: { messages: [message], lifecycle },
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
})
|
||||
}
|
||||
|
||||
subscribe(
|
||||
_method: string,
|
||||
_params: unknown,
|
||||
callbacks: { onResponse: (response: RuntimeRpcResponse<unknown>) => void }
|
||||
): Promise<{ unsubscribe: () => void }> {
|
||||
callbacks.onResponse({
|
||||
id: 'stream-1',
|
||||
ok: true,
|
||||
result: {
|
||||
type: 'snapshot',
|
||||
messages: [message],
|
||||
hasMore: false,
|
||||
lifecycle
|
||||
},
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
})
|
||||
return Promise.resolve({ unsubscribe: vi.fn() })
|
||||
}
|
||||
|
||||
close(): void {}
|
||||
}
|
||||
}))
|
||||
|
||||
const globals = installBrowserGlobals('Linux')
|
||||
writeStoredRuntimeEnvironment(globals.storage)
|
||||
const { installWebPreloadApi } = await import('./web-preload-api')
|
||||
installWebPreloadApi()
|
||||
|
||||
await expect(globals.window.api.nativeChat.readSession('claude', 'session-1')).resolves.toEqual(
|
||||
{
|
||||
messages: [message],
|
||||
lifecycle
|
||||
}
|
||||
)
|
||||
const frames: unknown[] = []
|
||||
globals.window.api.nativeChat.subscribe(
|
||||
{ subscriptionId: 'sub-1', agent: 'claude', sessionId: 'session-1' },
|
||||
(frame) => frames.push(frame)
|
||||
)
|
||||
await Promise.resolve()
|
||||
|
||||
expect(frames).toEqual([
|
||||
{
|
||||
type: 'snapshot',
|
||||
messages: [message],
|
||||
hasMore: false,
|
||||
lifecycle
|
||||
}
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('web MiniMax preload API', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import type {
|
|||
PreflightStatus,
|
||||
RefreshAgentsResult,
|
||||
NativeChatApi,
|
||||
NativeChatReadSessionResult,
|
||||
NativeChatAppendedMessages
|
||||
} from '../../../preload/api-types'
|
||||
import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope'
|
||||
|
|
@ -127,6 +126,10 @@ import {
|
|||
import { normalizeContextualTourIds, type ContextualTourId } from '../../../shared/contextual-tours'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { getDefaultCreateProjectParent } from '@/components/sidebar/create-project-defaults'
|
||||
import {
|
||||
parseRuntimeNativeChatReadSessionResult,
|
||||
parseRuntimeNativeChatTurnLifecycle
|
||||
} from '@/components/native-chat/native-chat-runtime-contract'
|
||||
|
||||
const SETTINGS_STORAGE_KEY = 'orca.web.settings.v1'
|
||||
const UI_STORAGE_KEY = 'orca.web.ui.v1'
|
||||
|
|
@ -1085,13 +1088,15 @@ function createWebKeybindingsApi(): WebKeybindingsApi {
|
|||
// undefined on web and the chat view showed no messages.
|
||||
function createNativeChatApi(): NativeChatApi {
|
||||
return {
|
||||
readSession: (agent, sessionId, limit, transcriptPath) =>
|
||||
callRuntimeResult<NativeChatReadSessionResult>('nativeChat.readSession', {
|
||||
agent,
|
||||
sessionId,
|
||||
limit,
|
||||
transcriptPath
|
||||
}),
|
||||
readSession: async (agent, sessionId, limit, transcriptPath) =>
|
||||
parseRuntimeNativeChatReadSessionResult(
|
||||
await callRuntimeResult<unknown>('nativeChat.readSession', {
|
||||
agent,
|
||||
sessionId,
|
||||
limit,
|
||||
transcriptPath
|
||||
})
|
||||
),
|
||||
subscribe: (args, onFrame) => {
|
||||
// No paired runtime yet: nothing to subscribe to, and
|
||||
// requireActiveEnvironment() would throw. Return a no-op teardown so the
|
||||
|
|
@ -1145,7 +1150,9 @@ function createNativeChatApi(): NativeChatApi {
|
|||
messages?: NativeChatAppendedMessages
|
||||
hasMore?: boolean
|
||||
error?: string
|
||||
lifecycle?: unknown
|
||||
}
|
||||
const lifecycle = parseRuntimeNativeChatTurnLifecycle(result?.lifecycle)
|
||||
if (
|
||||
(result?.type === 'appended' ||
|
||||
result?.type === 'snapshot' ||
|
||||
|
|
@ -1158,14 +1165,16 @@ function createNativeChatApi(): NativeChatApi {
|
|||
type: 'snapshot',
|
||||
messages: result.messages,
|
||||
hasMore: result.hasMore ?? result.messages.length >= (args.limit ?? 300),
|
||||
...(result.error ? { error: result.error } : {})
|
||||
...(result.error ? { error: result.error } : {}),
|
||||
...(lifecycle ? { lifecycle } : {})
|
||||
})
|
||||
} else if (result.type === 'snapshot') {
|
||||
onFrame({
|
||||
type: 'snapshot',
|
||||
messages: result.messages,
|
||||
hasMore: result.hasMore ?? false,
|
||||
...(result.error ? { error: result.error } : {})
|
||||
...(result.error ? { error: result.error } : {}),
|
||||
...(lifecycle ? { lifecycle } : {})
|
||||
})
|
||||
} else {
|
||||
onFrame(
|
||||
|
|
@ -1173,9 +1182,14 @@ function createNativeChatApi(): NativeChatApi {
|
|||
? {
|
||||
type: 'replacement',
|
||||
messages: result.messages,
|
||||
hasMore: result.hasMore ?? false
|
||||
hasMore: result.hasMore ?? false,
|
||||
...(lifecycle ? { lifecycle } : {})
|
||||
}
|
||||
: {
|
||||
type: 'appended',
|
||||
messages: result.messages,
|
||||
...(lifecycle ? { lifecycle } : {})
|
||||
}
|
||||
: { type: 'appended', messages: result.messages }
|
||||
)
|
||||
}
|
||||
} else if (!receivedInitial) {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Reference in New Issue