fix(native-chat): settle loading spinner on explicit turn boundaries

Explicit transcript turn-lifecycle markers now fully replace the prose-fallback
settlement path. Remove the now-unused `turnLifecycleCapable` flag and wire
lifecycle to suppress spinner even when hook status lingers. Refine Claude
lifecycle detection to distinguish terminal stops from mid-turn tool_use rows,
exclude harness noise from new-generation detection, and apply clock-skew slack
over SSH/relay. Serialize PTY sends per line to prevent rapid prompts from gluing
before Enter, clearing unsubmitted input on cancel. Update working suppression to
detect epoch rollovers so interrupt+next-turn without a ready gap resets the
spinner correctly.
This commit is contained in:
Jinjing
2026-07-16 23:06:35 -07:00
parent d1d069a6d2
commit 2df2e195a5
32 changed files with 1145 additions and 364 deletions
@@ -262,7 +262,6 @@ describe('nativeChat subscribe lifecycle', () => {
type: 'snapshot',
messages: [],
hasMore: false,
turnLifecycleCapable: true,
error: 'Transcript unavailable'
}
})
@@ -281,8 +280,7 @@ describe('nativeChat subscribe lifecycle', () => {
frame: {
type: 'snapshot',
messages: [],
hasMore: false,
turnLifecycleCapable: true
hasMore: false
}
})
})
@@ -302,7 +300,6 @@ describe('nativeChat subscribe lifecycle', () => {
type: 'snapshot',
messages: [],
hasMore: false,
turnLifecycleCapable: true,
lifecycle
}
})
+1 -16
View File
@@ -11,7 +11,6 @@ import {
readNativeChatTranscriptTail,
type NativeChatTranscriptSubscription
} from '../native-chat/transcript-watch'
import { supportsNativeChatTranscriptTurnLifecycle } from '../native-chat/transcript-turn-lifecycle'
// Re-export so existing test imports of `clearNativeChatTranscriptCache` from
// this module keep working after the cache moved to transcript-read-cache.ts.
@@ -36,18 +35,12 @@ async function readSession(args: NativeChatReadSessionArgs): Promise<ReadTranscr
const { agent, sessionId } = args
// Clamp to a positive window; default to the desktop window for the first page.
const limit = args.limit && args.limit > 0 ? Math.floor(args.limit) : DESKTOP_READ_WINDOW
const result = await readNativeChatTranscriptTail({
return readNativeChatTranscriptTail({
agent,
sessionId,
transcriptPath: args.transcriptPath,
limit
})
return 'messages' in result
? {
...result,
turnLifecycleCapable: supportsNativeChatTranscriptTurnLifecycle(agent)
}
: result
}
export type NativeChatSubscribeArgs = {
@@ -70,20 +63,17 @@ export type NativeChatAppendedPayload = {
hasMore: boolean
error?: string
lifecycle?: NativeChatTurnLifecycle
turnLifecycleCapable: boolean
}
| {
type: 'replacement'
messages: NativeChatMessage[]
hasMore: boolean
lifecycle?: NativeChatTurnLifecycle
turnLifecycleCapable: boolean
}
| {
type: 'appended'
messages: NativeChatMessage[]
lifecycle?: NativeChatTurnLifecycle
turnLifecycleCapable: boolean
}
}
@@ -168,7 +158,6 @@ async function handleSubscribe(event: IpcMainEvent, args: NativeChatSubscribeArg
return
}
const { subscriptionId, agent, sessionId, transcriptPath } = args
const turnLifecycleCapable = supportsNativeChatTranscriptTurnLifecycle(agent)
const limit = args.limit && args.limit > 0 ? Math.floor(args.limit) : DESKTOP_READ_WINDOW
// Replace any prior subscription under the same id (session change/resubscribe).
const pendingToken = beginPendingSubscription(sender.id, subscriptionId)
@@ -193,7 +182,6 @@ async function handleSubscribe(event: IpcMainEvent, args: NativeChatSubscribeArg
type: 'snapshot',
messages,
hasMore,
turnLifecycleCapable,
...(error ? { error } : {}),
...(lifecycle ? { lifecycle } : {})
}
@@ -210,7 +198,6 @@ async function handleSubscribe(event: IpcMainEvent, args: NativeChatSubscribeArg
type: 'replacement',
messages,
hasMore,
turnLifecycleCapable,
...(lifecycle ? { lifecycle } : {})
}
} satisfies NativeChatAppendedPayload)
@@ -224,7 +211,6 @@ async function handleSubscribe(event: IpcMainEvent, args: NativeChatSubscribeArg
frame: {
type: 'appended',
messages,
turnLifecycleCapable,
...(lifecycle ? { lifecycle } : {})
}
}
@@ -258,7 +244,6 @@ async function handleSubscribe(event: IpcMainEvent, args: NativeChatSubscribeArg
type: 'snapshot',
messages: [],
hasMore: false,
turnLifecycleCapable,
error: 'Transcript unavailable'
}
}
@@ -12,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,
@@ -27,7 +28,7 @@ export function decodeClaudeTranscriptLine(
}
const timestamp = parseTimestamp(record.timestamp)
const recordMessageId = extractString(record.uuid) ?? fallbackId
if (role === 'user' && extractString(record.interruptedMessageId)) {
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 {
@@ -12,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,
@@ -91,7 +92,7 @@ function codexEventMessage(
id: string,
timestamp: number | null
): NativeChatMessage | null {
if (payload.type === 'turn_aborted') {
if (payload.type === CODEX_EVENT_TURN_ABORTED) {
return {
id,
role: 'system',
@@ -18,7 +18,6 @@ export type ReadTranscriptResult =
| {
messages: NativeChatMessage[]
lifecycle?: NativeChatTurnLifecycle
turnLifecycleCapable?: boolean
}
// notFound marks a retry-worthy miss (transcript not flushed to disk yet,
// #8401) as opposed to a real parse/IO error callers surface immediately.
@@ -2,15 +2,15 @@ import { describe, expect, it } from 'vitest'
import {
decodeClaudeTurnLifecycle,
decodeCodexTurnLifecycle,
supportsNativeChatTranscriptTurnLifecycle
nativeChatTurnLifecycleDecoderForAgent
} from './transcript-turn-lifecycle'
describe('native chat transcript turn lifecycle', () => {
it('reports which transcript formats have explicit boundaries', () => {
expect(supportsNativeChatTranscriptTurnLifecycle('claude')).toBe(true)
expect(supportsNativeChatTranscriptTurnLifecycle('openclaude')).toBe(true)
expect(supportsNativeChatTranscriptTurnLifecycle('codex')).toBe(true)
expect(supportsNativeChatTranscriptTurnLifecycle('grok')).toBe(false)
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', () => {
@@ -108,30 +108,44 @@ describe('native chat transcript turn lifecycle', () => {
).toBeNull()
})
it('does not treat Claude mid-turn assistant rows as completed', () => {
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: [] }
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: [] }
message: {
role: 'assistant',
stop_reason: null,
content: [{ type: 'text', text: 'final answer' }]
}
}),
'fallback'
)
).toBeNull()
).toEqual({
state: 'completed',
turnId: 'assistant-null-stop',
timestamp: Date.parse('2026-07-16T23:45:37.608Z')
})
expect(
decodeClaudeTurnLifecycle(
@@ -139,7 +153,90 @@ describe('native chat transcript turn lifecycle', () => {
type: 'assistant',
uuid: 'assistant-missing-stop',
timestamp: '2026-07-16T23:45:37.608Z',
message: { role: 'assistant', content: [] }
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'
)
@@ -1,5 +1,6 @@
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,
@@ -7,6 +8,12 @@ import {
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,
@@ -26,10 +33,6 @@ export function nativeChatTurnLifecycleDecoderForAgent(
return null
}
export function supportsNativeChatTranscriptTurnLifecycle(agent: AgentType): boolean {
return nativeChatTurnLifecycleDecoderForAgent(agent) !== null
}
export function decodeCodexTurnLifecycle(
line: string,
fallbackId: string
@@ -40,16 +43,16 @@ export function decodeCodexTurnLifecycle(
return null
}
if (
payload.type !== 'task_started' &&
payload.type !== 'task_complete' &&
payload.type !== 'turn_aborted'
payload.type !== CODEX_EVENT_TURN_STARTED &&
payload.type !== CODEX_EVENT_TURN_COMPLETE &&
payload.type !== CODEX_EVENT_TURN_ABORTED
) {
return null
}
const state =
payload.type === 'task_started'
payload.type === CODEX_EVENT_TURN_STARTED
? 'working'
: payload.type === 'turn_aborted'
: payload.type === CODEX_EVENT_TURN_ABORTED
? 'interrupted'
: 'completed'
return {
@@ -76,20 +79,34 @@ export function decodeClaudeTurnLifecycle(
}
const message = asRecord(record.message)
const timestamp = lifecycleTimestamp(record.timestamp)
const interruptedMessageId = extractString(record.interruptedMessageId)
if (record.type === 'user' && interruptedMessageId) {
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 }
}
// Why: capable hosts disable the prose-fallback settlement path, so every
// real terminal stop_reason must emit completed — not only end_turn.
if (record.type === 'assistant' && isClaudeTerminalStopReason(message?.stop_reason)) {
return {
state: 'completed',
turnId: extractString(record.uuid) ?? extractString(message?.id) ?? fallbackId,
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
@@ -100,6 +117,12 @@ export function decodeClaudeTurnLifecycle(
// 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 }
}
@@ -107,3 +130,40 @@ 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'
@@ -228,6 +228,39 @@ describe('subscribeNativeChatTranscript', () => {
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[][] = []
@@ -373,7 +373,6 @@ describe('nativeChat.subscribe initial snapshot', () => {
type: 'snapshot',
messages: [],
hasMore: false,
turnLifecycleCapable: true,
error: 'Transcript unavailable'
}
])
@@ -397,7 +396,6 @@ describe('nativeChat.subscribe initial snapshot', () => {
messages: [],
hasMore: false,
beforeOffset: 0,
turnLifecycleCapable: true,
error: 'Transcript unavailable'
}
])
@@ -420,8 +418,7 @@ describe('nativeChat.subscribe initial snapshot', () => {
type: 'snapshot',
messages: [expect.objectContaining({ id: 'a-1' })],
hasMore: false,
beforeOffset: 7,
turnLifecycleCapable: true
beforeOffset: 7
}
])
})
@@ -452,13 +449,11 @@ describe('nativeChat.subscribe initial snapshot', () => {
messages: [expect.objectContaining({ id: 'a-1' })],
hasMore: false,
beforeOffset: 3,
turnLifecycleCapable: true,
lifecycle: completed
},
{
type: 'appended',
messages: [],
turnLifecycleCapable: true,
lifecycle: completed
},
{
@@ -466,7 +461,6 @@ describe('nativeChat.subscribe initial snapshot', () => {
messages: [expect.objectContaining({ id: 'a-1' })],
hasMore: false,
beforeOffset: 9,
turnLifecycleCapable: true,
lifecycle: completed
}
])
@@ -488,7 +482,6 @@ describe('nativeChat.readSession lifecycle payload', () => {
expect(result).toMatchObject({
hasMore: false,
beforeOffset: 123,
turnLifecycleCapable: true,
lifecycle
})
expect((result as { messages: NativeChatMessage[] }).messages).toHaveLength(1)
@@ -5,7 +5,6 @@ import {
readNativeChatTranscriptTail,
subscribeNativeChatTranscript
} from '../../../native-chat/transcript-watch'
import { supportsNativeChatTranscriptTurnLifecycle } from '../../../native-chat/transcript-turn-lifecycle'
import { defineMethod, defineStreamingMethod, type RpcAnyMethod, type RpcContext } from '../core'
// Why: native chat renders an agent's own transcript (Claude/Codex JSONL). The
@@ -184,7 +183,6 @@ export const NATIVE_CHAT_METHODS: readonly RpcAnyMethod[] = [
params: NativeChatSession,
handler: async (params, { clientKind }) => {
const limit = params.limit ?? MOBILE_NATIVE_CHAT_DEFAULT_WINDOW
const turnLifecycleCapable = supportsNativeChatTranscriptTurnLifecycle(params.agent)
const result = await readNativeChatTranscriptTail({
agent: params.agent,
sessionId: params.sessionId,
@@ -197,7 +195,6 @@ export const NATIVE_CHAT_METHODS: readonly RpcAnyMethod[] = [
messages: windowForClient(result.messages, clientKind, limit),
hasMore: result.hasMore,
beforeOffset: result.beforeOffset,
turnLifecycleCapable,
...(result.lifecycle ? { lifecycle: result.lifecycle } : {})
}
: result
@@ -219,7 +216,6 @@ export const NATIVE_CHAT_METHODS: readonly RpcAnyMethod[] = [
const cleanupToken = params.subscriptionId ?? `${params.agent}:${params.sessionId}`
const subscriptionId = `nativeChat:${connectionId ?? 'local'}:${cleanupToken}`
const limit = params.limit ?? MOBILE_NATIVE_CHAT_DEFAULT_WINDOW
const turnLifecycleCapable = supportsNativeChatTranscriptTurnLifecycle(params.agent)
runtime.registerSubscriptionCleanup(
subscriptionId,
() => {
@@ -248,7 +244,6 @@ export const NATIVE_CHAT_METHODS: readonly RpcAnyMethod[] = [
messages: windowForClient(messages, clientKind, limit),
hasMore,
beforeOffset,
turnLifecycleCapable,
...(error ? { error } : {}),
...(lifecycle ? { lifecycle } : {})
})
@@ -262,7 +257,6 @@ export const NATIVE_CHAT_METHODS: readonly RpcAnyMethod[] = [
messages: windowForClient(messages, clientKind, limit),
hasMore,
beforeOffset,
turnLifecycleCapable,
...(lifecycle ? { lifecycle } : {})
})
},
@@ -273,7 +267,6 @@ export const NATIVE_CHAT_METHODS: readonly RpcAnyMethod[] = [
emit({
type: 'appended',
messages: sanitizeAppendForClient(messages, clientKind),
turnLifecycleCapable,
...(lifecycle ? { lifecycle } : {})
})
}
@@ -288,7 +281,6 @@ export const NATIVE_CHAT_METHODS: readonly RpcAnyMethod[] = [
type: 'snapshot',
messages: [],
hasMore: false,
turnLifecycleCapable,
error: 'Transcript unavailable'
})
}
-5
View File
@@ -843,8 +843,6 @@ export type NativeChatReadSessionResult =
| {
messages: NativeChatMessage[]
lifecycle?: NativeChatTurnLifecycle
/** True when this host can decode authoritative transcript turn boundaries. */
turnLifecycleCapable?: boolean
}
| { error: string; notFound?: true }
@@ -858,20 +856,17 @@ export type NativeChatSubscriptionFrame =
hasMore: boolean
error?: string
lifecycle?: NativeChatTurnLifecycle
turnLifecycleCapable?: boolean
}
| {
type: 'replacement'
messages: NativeChatMessage[]
hasMore: boolean
lifecycle?: NativeChatTurnLifecycle
turnLifecycleCapable?: boolean
}
| {
type: 'appended'
messages: NativeChatMessage[]
lifecycle?: NativeChatTurnLifecycle
turnLifecycleCapable?: boolean
}
/** Wire payload for the `nativeChat:appended` push channel. */
@@ -162,11 +162,17 @@ function NativeChatResolvedView({
// 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, 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)
@@ -331,10 +337,23 @@ function NativeChatResolvedView({
const isConversation = viewState.kind === 'ready'
useEffect(() => {
if (shouldClearNativeChatWorkingSuppression({ working: liveWorking })) {
if (
shouldClearNativeChatWorkingSuppression({
working: liveWorking,
interrupted: workingInterrupted,
workingEpoch: hookWorkingEpoch,
previousWorkingEpoch: previousWorkingEpochRef.current
})
) {
setWorkingInterrupted(false)
}
}, [liveWorking])
if (liveWorking && hookWorkingEpoch != null) {
previousWorkingEpochRef.current = hookWorkingEpoch
}
if (!liveWorking) {
previousWorkingEpochRef.current = null
}
}, [liveWorking, workingInterrupted, hookWorkingEpoch])
const isWorking = shouldShowNativeChatWorking({
isConversation,
working: liveWorking,
@@ -343,8 +362,12 @@ function NativeChatResolvedView({
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
@@ -22,8 +22,6 @@ export type NativeChatLiveMergeInput = {
stateStartedAt?: number | null
/** Latest provider-authored turn boundary recovered from the transcript. */
transcriptLifecycle?: NativeChatTurnLifecycle
/** Whether the serving host can decode explicit transcript turn boundaries. */
turnLifecycleCapable?: boolean
/** Claude can finish its lead turn while background children remain active. */
hookHasWorkingSubagents?: boolean
/** True before the initial snapshot resolves; forces 'loading'. */
@@ -41,6 +39,8 @@ export type NativeChatLiveMergeInput = {
* - 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 {
@@ -50,7 +50,6 @@ export function mergeNativeChatLiveSession(input: NativeChatLiveMergeInput): Nat
hookState,
stateStartedAt,
transcriptLifecycle,
turnLifecycleCapable,
hookHasWorkingSubagents,
loading,
error
@@ -64,7 +63,6 @@ export function mergeNativeChatLiveSession(input: NativeChatLiveMergeInput): Nat
sources,
stateStartedAt,
transcriptLifecycle,
turnLifecycleCapable ?? false,
hookHasWorkingSubagents ?? false
)
if (loading && status !== 'working') {
@@ -78,12 +76,14 @@ 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,
transcriptLifecycle: NativeChatTurnLifecycle | undefined,
turnLifecycleCapable: boolean,
hookHasWorkingSubagents: boolean
): NativeChatSessionStatus | undefined {
// Only 'working' drives a live override; blocked/waiting/done leave the
@@ -98,18 +98,21 @@ function liveStatusOverride(
return undefined
}
// Why: a lead completion does not end Claude's aggregate turn while a
// background child still runs; the hook roster owns that extra lifetime.
// 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: Grok and older remote hosts have no explicit boundary decoder. Keep
// their degraded recovery without letting prose override capable providers.
// 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 (
!turnLifecycleCapable &&
transcriptLifecycle === undefined &&
transcriptLifecycle?.state !== 'working' &&
trailingAssistantPostDates(sources, stateStartedAt)
) {
return undefined
@@ -124,12 +127,22 @@ function lifecycleTerminatesCurrentTurn(
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) {
// Why: without comparable timing, a replayed terminal marker could belong to
// the prior turn and must not hide a newer live working signal.
return false
return true
}
return lifecycle.timestamp >= stateStartedAt
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(
@@ -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
}
@@ -47,7 +47,6 @@ export function parseRuntimeNativeChatReadSessionResult(
const lifecycle = parseRuntimeNativeChatTurnLifecycle(record.lifecycle)
return {
messages: record.messages as NativeChatAppendedMessages,
turnLifecycleCapable: record.turnLifecycleCapable === true,
...(lifecycle ? { lifecycle } : {})
}
}
@@ -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,24 +31,27 @@ 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()
})
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)
})
@@ -55,43 +60,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,
@@ -102,6 +183,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 () => {
@@ -111,16 +197,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)
})
})
@@ -128,12 +238,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'
])
@@ -142,15 +254,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,
@@ -158,8 +267,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', () => {
@@ -169,11 +278,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)
})
@@ -184,7 +298,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
)
})
})
@@ -195,9 +317,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)
})
})
@@ -210,72 +331,49 @@ 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)
})
})
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[]
): NativeChatSendHandle {
@@ -175,7 +225,6 @@ export function sendNativeChatAskAnswer(
clearTimeout(timer)
}
},
// Hold the card until the last keystroke has fired and its submit gap passed.
settleAfterMs: (groups.length - 1) * NATIVE_CHAT_QUESTION_STEP_MS + NATIVE_CHAT_SUBMIT_DELAY_MS
}
}
@@ -68,9 +68,7 @@ describe('getNativeChatSessionTransport — selection', () => {
it('returns the runtime adapter for an owner on the desktop client', async () => {
markRuntimeEnvironmentCompatible(ENV)
runtimeEnvironmentsCall.mockResolvedValue(
okEnvelope({ messages: [], turnLifecycleCapable: true })
)
runtimeEnvironmentsCall.mockResolvedValue(okEnvelope({ messages: [] }))
const transport = getNativeChatSessionTransport(ENV)
await transport.readSession('claude', 'sess-1', 40, '/t/path')
@@ -88,9 +86,7 @@ describe('getNativeChatSessionTransport — selection', () => {
markRuntimeEnvironmentCompatible(ENV)
const lifecycle = { state: 'completed', turnId: 'turn-1', timestamp: 42 } as const
runtimeEnvironmentsCall
.mockResolvedValueOnce(
okEnvelope({ messages: [message('valid')], turnLifecycleCapable: true, lifecycle })
)
.mockResolvedValueOnce(okEnvelope({ messages: [message('valid')], lifecycle }))
.mockResolvedValueOnce(
okEnvelope({
messages: [message('invalid')],
@@ -101,12 +97,11 @@ describe('getNativeChatSessionTransport — selection', () => {
await expect(transport.readSession('claude', 'sess-1')).resolves.toEqual({
messages: [message('valid')],
turnLifecycleCapable: true,
lifecycle
})
// An invalid lifecycle payload is dropped, leaving prose recovery to settle.
await expect(transport.readSession('claude', 'sess-1')).resolves.toEqual({
messages: [message('invalid')],
turnLifecycleCapable: false
messages: [message('invalid')]
})
})
@@ -114,13 +109,12 @@ describe('getNativeChatSessionTransport — selection', () => {
markRuntimeEnvironmentCompatible(ENV)
const lifecycle = { state: 'interrupted', turnId: 'turn-2', timestamp: 43 } as const
runtimeEnvironmentsCall.mockResolvedValueOnce(
okEnvelope({ messages: [message('interrupted')], turnLifecycleCapable: true, lifecycle })
okEnvelope({ messages: [message('interrupted')], lifecycle })
)
const transport = getNativeChatSessionTransport(ENV)
await expect(transport.readSession('codex', 'sess-1')).resolves.toEqual({
messages: [message('interrupted')],
turnLifecycleCapable: true,
lifecycle
})
})
@@ -130,7 +124,6 @@ describe('getNativeChatSessionTransport — selection', () => {
runtimeEnvironmentsCall.mockResolvedValueOnce(
okEnvelope({
messages: [message('no-ts')],
turnLifecycleCapable: true,
lifecycle: { state: 'completed', turnId: 'turn-3' }
})
)
@@ -138,7 +131,6 @@ describe('getNativeChatSessionTransport — selection', () => {
await expect(transport.readSession('claude', 'sess-1')).resolves.toEqual({
messages: [message('no-ts')],
turnLifecycleCapable: true,
lifecycle: { state: 'completed', turnId: 'turn-3', timestamp: null }
})
})
@@ -200,20 +192,17 @@ describe('runtime subscribe', () => {
expect(onFrame).toHaveBeenNthCalledWith(1, {
type: 'snapshot',
messages: [message('m-1')],
hasMore: false,
turnLifecycleCapable: false
hasMore: false
})
expect(onFrame).toHaveBeenNthCalledWith(2, {
type: 'snapshot',
messages: [message('m-snapshot')],
hasMore: false,
turnLifecycleCapable: false
hasMore: false
})
expect(onFrame).toHaveBeenNthCalledWith(3, {
type: 'replacement',
messages: [message('m-replacement')],
hasMore: true,
turnLifecycleCapable: false
hasMore: true
})
})
@@ -229,7 +218,6 @@ describe('runtime subscribe', () => {
deliver({
type: 'snapshot',
messages: [message('valid')],
turnLifecycleCapable: true,
lifecycle
})
deliver({
@@ -242,13 +230,37 @@ describe('runtime subscribe', () => {
type: 'snapshot',
messages: [message('valid')],
hasMore: false,
turnLifecycleCapable: true,
lifecycle
})
expect(onFrame).toHaveBeenNthCalledWith(2, {
type: 'appended',
messages: [message('invalid')],
turnLifecycleCapable: false
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')]
})
})
@@ -291,7 +303,6 @@ describe('runtime subscribe', () => {
type: 'snapshot',
messages: [],
hasMore: false,
turnLifecycleCapable: false,
error: 'runtime too old'
})
})
@@ -134,10 +134,8 @@ function createRuntimeNativeChatTransport(environmentId: string): NativeChatSess
hasMore?: boolean
error?: string
lifecycle?: unknown
turnLifecycleCapable?: unknown
}
const lifecycle = parseRuntimeNativeChatTurnLifecycle(frame?.lifecycle)
const turnLifecycleCapable = frame?.turnLifecycleCapable === true
if (
(frame?.type === 'appended' ||
frame?.type === 'snapshot' ||
@@ -150,7 +148,6 @@ function createRuntimeNativeChatTransport(environmentId: string): NativeChatSess
type: 'snapshot',
messages: frame.messages,
hasMore: frame.hasMore ?? frame.messages.length >= (limit ?? 300),
turnLifecycleCapable,
...(frame.error ? { error: frame.error } : {}),
...(lifecycle ? { lifecycle } : {})
})
@@ -159,7 +156,6 @@ function createRuntimeNativeChatTransport(environmentId: string): NativeChatSess
type: 'snapshot',
messages: frame.messages,
hasMore: frame.hasMore ?? false,
turnLifecycleCapable,
...(frame.error ? { error: frame.error } : {}),
...(lifecycle ? { lifecycle } : {})
})
@@ -170,13 +166,11 @@ function createRuntimeNativeChatTransport(environmentId: string): NativeChatSess
type: 'replacement',
messages: frame.messages,
hasMore: frame.hasMore ?? false,
turnLifecycleCapable,
...(lifecycle ? { lifecycle } : {})
}
: {
type: 'appended',
messages: frame.messages,
turnLifecycleCapable,
...(lifecycle ? { lifecycle } : {})
}
)
@@ -29,4 +29,23 @@ describe('native chat working suppression', () => {
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({
working: true,
interrupted: true,
workingEpoch: 20,
previousWorkingEpoch: 10
})
).toBe(true)
expect(
shouldClearNativeChatWorkingSuppression({
working: true,
interrupted: true,
workingEpoch: 10,
previousWorkingEpoch: 10
})
).toBe(false)
})
})
@@ -6,6 +6,30 @@ export function shouldShowNativeChatWorking(args: {
return args.isConversation && args.working && !args.interrupted
}
export function shouldClearNativeChatWorkingSuppression(args: { working: boolean }): boolean {
return !args.working
/**
* 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: {
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 {
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
}
@@ -10,11 +10,18 @@ export function useNativeChatHookStatus(
const stateStartedAt = useAppStore(
(store) => store.agentStatusByPaneKey[paneKey]?.stateStartedAt ?? null
)
const hasWorkingSubagents = useAppStore(
(store) =>
store.agentStatusByPaneKey[paneKey]?.subagents?.some(
(subagent) => subagent.state === 'working'
// 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,18 +99,29 @@ describe('mergeNativeChatLiveSession', () => {
expect(session.status).toBe('working')
})
it('does not treat assistant prose as turn completion', () => {
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,
turnLifecycleCapable: true
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',
agent: 'claude',
hookState: 'working',
stateStartedAt: 1
})
expect(session.status).toBe('ready')
})
it('settles a dropped working hook from an explicit completion marker', () => {
const session = mergeNativeChatLiveSession({
sources: { transcript: [user('u-1', 'go'), assistant('a-1', 'done')] },
@@ -118,7 +129,6 @@ describe('mergeNativeChatLiveSession', () => {
agent: 'claude',
hookState: 'working',
stateStartedAt: 1,
turnLifecycleCapable: true,
transcriptLifecycle: { state: 'completed', turnId: 'turn-1', timestamp: 2 }
})
expect(session.status).toBe('ready')
@@ -143,7 +153,6 @@ describe('mergeNativeChatLiveSession', () => {
agent: 'claude',
hookState: 'working',
stateStartedAt: 5,
turnLifecycleCapable: true,
transcriptLifecycle: { state: 'completed', turnId: 'turn-1', timestamp: 2 }
})
expect(session.status).toBe('working')
@@ -161,17 +170,33 @@ describe('mergeNativeChatLiveSession', () => {
expect(session.status).toBe('working')
})
it('does not apply an unorderable completion marker to live work', () => {
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,
turnLifecycleCapable: true,
transcriptLifecycle: { state: 'completed', turnId: 'turn-1', timestamp: null }
})
expect(session.status).toBe('working')
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', () => {
@@ -180,8 +205,7 @@ describe('mergeNativeChatLiveSession', () => {
sessionId: 'sess',
agent: 'grok',
hookState: 'working',
stateStartedAt: 1,
turnLifecycleCapable: false
stateStartedAt: 1
})
expect(session.status).toBe('ready')
})
@@ -193,7 +217,6 @@ describe('mergeNativeChatLiveSession', () => {
agent: 'claude',
hookState: 'working',
stateStartedAt: 1,
turnLifecycleCapable: true,
transcriptLifecycle: { state: 'completed', turnId: 'turn-1', timestamp: 2 },
hookHasWorkingSubagents: true
})
@@ -207,7 +230,6 @@ describe('mergeNativeChatLiveSession', () => {
agent: 'claude',
hookState: 'working',
stateStartedAt: 1,
turnLifecycleCapable: true,
transcriptLifecycle: { state: 'interrupted', turnId: 'turn-1', timestamp: 2 },
hookHasWorkingSubagents: true
})
@@ -721,7 +743,6 @@ describe('useNativeChatLiveSession — transport routing', () => {
type: 'snapshot',
messages: [user('u-1', 'go')],
hasMore: false,
turnLifecycleCapable: true,
lifecycle: { state: 'working', turnId: 'turn-1', timestamp: 10 }
})
)
@@ -732,7 +753,6 @@ describe('useNativeChatLiveSession — transport routing', () => {
type: 'snapshot',
messages: [user('u-1', 'go')],
hasMore: false,
turnLifecycleCapable: true,
lifecycle: { state: 'interrupted', turnId: 'turn-1', timestamp: 20 }
})
)
@@ -127,8 +127,7 @@ export function useNativeChatLiveSession(
const [read, setRead] = useState<ReadState>({ phase: 'loading' })
const [hasMore, setHasMore] = useState(false)
const [loadingEarlier, setLoadingEarlier] = useState(false)
const [transcriptLifecycle, turnLifecycleCapable, transcriptLifecycleControl] =
useNativeChatTranscriptLifecycle()
const [transcriptLifecycle, transcriptLifecycleControl] = useNativeChatTranscriptLifecycle()
// The active read window; raised by loadEarlier to page in older history.
const limitRef = useRef(NATIVE_CHAT_INITIAL_LIMIT)
@@ -222,10 +221,7 @@ export function useNativeChatLiveSession(
return
}
const messages = result?.messages ?? []
transcriptLifecycleControl.replace(
result?.lifecycle,
result?.turnLifecycleCapable === true
)
transcriptLifecycleControl.replace(result?.lifecycle)
setRead({ phase: 'ready', messages })
setHasMore(hasMoreNativeChatHistory(messages.length, limitRef.current))
})
@@ -259,14 +255,14 @@ export function useNativeChatLiveSession(
setRead({ phase: 'error', error: frame.error })
return
}
transcriptLifecycleControl.replace(frame.lifecycle, frame.turnLifecycleCapable === true)
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, frame.turnLifecycleCapable === true)
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
@@ -398,7 +394,6 @@ export function useNativeChatLiveSession(
hookState,
stateStartedAt: hookStateStartedAt,
transcriptLifecycle,
turnLifecycleCapable,
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
@@ -416,7 +411,6 @@ export function useNativeChatLiveSession(
hookState,
hookStateStartedAt,
transcriptLifecycle,
turnLifecycleCapable,
hookHasWorkingSubagents,
hasMore,
loadingEarlier,
@@ -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,
@@ -3,49 +3,37 @@ import type { NativeChatTurnLifecycle } from '../../../../shared/native-chat-typ
type TranscriptLifecycleState = {
lifecycle?: NativeChatTurnLifecycle
capable: boolean
}
type TranscriptLifecycleControl = {
reset: () => void
replace: (lifecycle: NativeChatTurnLifecycle | undefined, capable: boolean) => void
append: (lifecycle: NativeChatTurnLifecycle | undefined, capable: boolean) => 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,
boolean,
TranscriptLifecycleControl
] {
const [state, setState] = useState<TranscriptLifecycleState>({ capable: false })
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, capable: boolean): void => {
revisionRef.current += 1
setState({ lifecycle, capable })
},
[]
)
const reset = useCallback((): void => replace(undefined, false), [replace])
const append = useCallback(
(lifecycle: NativeChatTurnLifecycle | undefined, capable: boolean): void => {
if (lifecycle) {
revisionRef.current += 1
}
setState((current) => {
if (!lifecycle && current.capable === capable) {
return current
}
return { lifecycle: lifecycle ?? current.lifecycle, capable }
})
},
[]
)
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 => {
@@ -62,5 +50,5 @@ export function useNativeChatTranscriptLifecycle(): readonly [
() => ({ reset, replace, append, revision, replaceFromPagination }),
[append, replace, replaceFromPagination, reset, revision]
)
return [state.lifecycle, state.capable, control]
return [state.lifecycle, control]
}
+1 -4
View File
@@ -727,7 +727,7 @@ describe('web native chat preload API', () => {
return Promise.resolve({
id: 'read-1',
ok: true,
result: { messages: [message], turnLifecycleCapable: true, lifecycle },
result: { messages: [message], lifecycle },
_meta: { runtimeId: 'runtime-1' }
})
}
@@ -744,7 +744,6 @@ describe('web native chat preload API', () => {
type: 'snapshot',
messages: [message],
hasMore: false,
turnLifecycleCapable: true,
lifecycle
},
_meta: { runtimeId: 'runtime-1' }
@@ -764,7 +763,6 @@ describe('web native chat preload API', () => {
await expect(globals.window.api.nativeChat.readSession('claude', 'session-1')).resolves.toEqual(
{
messages: [message],
turnLifecycleCapable: true,
lifecycle
}
)
@@ -780,7 +778,6 @@ describe('web native chat preload API', () => {
type: 'snapshot',
messages: [message],
hasMore: false,
turnLifecycleCapable: true,
lifecycle
}
])
-6
View File
@@ -1150,10 +1150,8 @@ function createNativeChatApi(): NativeChatApi {
hasMore?: boolean
error?: string
lifecycle?: unknown
turnLifecycleCapable?: unknown
}
const lifecycle = parseRuntimeNativeChatTurnLifecycle(result?.lifecycle)
const turnLifecycleCapable = result?.turnLifecycleCapable === true
if (
(result?.type === 'appended' ||
result?.type === 'snapshot' ||
@@ -1166,7 +1164,6 @@ function createNativeChatApi(): NativeChatApi {
type: 'snapshot',
messages: result.messages,
hasMore: result.hasMore ?? result.messages.length >= (args.limit ?? 300),
turnLifecycleCapable,
...(result.error ? { error: result.error } : {}),
...(lifecycle ? { lifecycle } : {})
})
@@ -1175,7 +1172,6 @@ function createNativeChatApi(): NativeChatApi {
type: 'snapshot',
messages: result.messages,
hasMore: result.hasMore ?? false,
turnLifecycleCapable,
...(result.error ? { error: result.error } : {}),
...(lifecycle ? { lifecycle } : {})
})
@@ -1186,13 +1182,11 @@ function createNativeChatApi(): NativeChatApi {
type: 'replacement',
messages: result.messages,
hasMore: result.hasMore ?? false,
turnLifecycleCapable,
...(lifecycle ? { lifecycle } : {})
}
: {
type: 'appended',
messages: result.messages,
turnLifecycleCapable,
...(lifecycle ? { lifecycle } : {})
}
)