mirror of
https://github.com/stablyai/orca.git
synced 2026-10-18 16:03:17 +00:00
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:
@@ -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
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
])
|
||||
|
||||
@@ -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 } : {})
|
||||
}
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user