From 60de278ff2442ce9c46887ad62091c98628c974b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 4 Aug 2026 22:52:46 -0700 Subject: [PATCH 1/6] fix(native-chat): guard sends during terminal prompts --- .../mobile-native-chat-image-send.test.ts | 1 + .../session/mobile-native-chat-image-send.ts | 1 + .../session/mobile-native-chat-send.test.ts | 33 +++ mobile/src/session/mobile-native-chat-send.ts | 18 +- ...se-mobile-native-chat-message-send.test.ts | 32 ++- .../use-mobile-native-chat-message-send.ts | 12 +- .../terminal/terminal-send-rpc-response.ts | 9 + src/main/runtime/orca-runtime.ts | 222 ++---------------- src/main/runtime/rpc/methods/terminal.ts | 44 +++- src/main/runtime/rpc/terminal-send.test.ts | 34 +++ .../native-chat/NativeChatComposer.test.tsx | 61 ++++- .../native-chat/NativeChatComposer.tsx | 5 + .../native-chat-terminal-prompt-guard.ts | 22 ++ src/renderer/src/i18n/locales/en.json | 3 +- src/shared/runtime-types.ts | 2 +- src/shared/terminal-wait-blocked-detection.ts | 197 ++++++++++++++++ 16 files changed, 465 insertions(+), 231 deletions(-) create mode 100644 src/renderer/src/components/native-chat/native-chat-terminal-prompt-guard.ts create mode 100644 src/shared/terminal-wait-blocked-detection.ts diff --git a/mobile/src/session/mobile-native-chat-image-send.test.ts b/mobile/src/session/mobile-native-chat-image-send.test.ts index 46ac4730f2e8..71614832e4fb 100644 --- a/mobile/src/session/mobile-native-chat-image-send.test.ts +++ b/mobile/src/session/mobile-native-chat-image-send.test.ts @@ -45,6 +45,7 @@ describe('pasteMobileNativeChatImagePaths', () => { terminal: 'term-1', text: '\x15', enter: false, + nativeChat: true, client: { id: 'device-9', type: 'mobile' } } }) diff --git a/mobile/src/session/mobile-native-chat-image-send.ts b/mobile/src/session/mobile-native-chat-image-send.ts index adb996b76125..7f446f1c159c 100644 --- a/mobile/src/session/mobile-native-chat-image-send.ts +++ b/mobile/src/session/mobile-native-chat-image-send.ts @@ -70,6 +70,7 @@ export async function pasteMobileNativeChatImagePaths({ terminal, text, enter: false, + nativeChat: true, ...clientField }, // The remaining budget covers the reconnect wait too; a fresh post-connect diff --git a/mobile/src/session/mobile-native-chat-send.test.ts b/mobile/src/session/mobile-native-chat-send.test.ts index ccdacfd7b3e1..ff42d38940ac 100644 --- a/mobile/src/session/mobile-native-chat-send.test.ts +++ b/mobile/src/session/mobile-native-chat-send.test.ts @@ -61,6 +61,39 @@ describe('sendMobileNativeChatMessage', () => { ).resolves.toBe(false) }) + it('reports an interactive prompt rejection for guarded native-chat sends', async () => { + const client = clientWithResponse({ + id: 'request', + ok: true, + result: { + send: { + accepted: false, + refusedReason: 'interactive-prompt' + } + }, + _meta: { runtimeId: 'runtime' } + }) + + await expect( + sendMobileNativeChatMessageWithOutcome({ + client, + terminal: 'term', + text: 'hello', + guardInteractivePrompt: true + }) + ).resolves.toBe('interactive-prompt') + expect(client.sendRequest).toHaveBeenCalledWith( + 'terminal.send', + { + terminal: 'term', + text: 'hello', + enter: true, + nativeChat: true + }, + { timeoutMs: MOBILE_NATIVE_CHAT_SEND_TIMEOUT_MS, budgetSpansConnect: true } + ) + }) + it('returns false when the RPC fails', async () => { const client = { sendRequest: vi.fn().mockRejectedValue(new Error('disconnected')) diff --git a/mobile/src/session/mobile-native-chat-send.ts b/mobile/src/session/mobile-native-chat-send.ts index ce432c3efb9f..1b6c0a03002d 100644 --- a/mobile/src/session/mobile-native-chat-send.ts +++ b/mobile/src/session/mobile-native-chat-send.ts @@ -1,7 +1,10 @@ import type { RpcClient } from '../transport/rpc-client' import { isRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity' import { isLogicalClientCutoverError } from '../transport/stable-logical-rpc-client' -import { isTerminalSendRpcAccepted } from '../terminal/terminal-send-rpc-response' +import { + getTerminalSendRpcRefusedReason, + isTerminalSendRpcAccepted +} from '../terminal/terminal-send-rpc-response' type MobileTerminalClient = { id: string @@ -24,6 +27,7 @@ type MobileNativeChatSendArgs = { text: string enter?: boolean clearInputFirst?: boolean + guardInteractivePrompt?: boolean /** Exact host launch draft this submitting write resolves when accepted. */ resolvedLaunchDraft?: { text: string; createdAt: number } mobileClient?: MobileTerminalClient @@ -35,7 +39,7 @@ type MobileNativeChatSendArgs = { /** 'unknown' = the RPC failed without proof the request never reached the * desktop (ack loss after a write, or a cutover that cannot tell whether the * frame was written) — callers must not present it as a definite send failure. */ -export type MobileNativeChatSendOutcome = 'accepted' | 'rejected' | 'unknown' +export type MobileNativeChatSendOutcome = 'accepted' | 'rejected' | 'unknown' | 'interactive-prompt' /** Without an explicit timeout `sendRequest` waits for reconnect indefinitely, and * the composer holds `sending` (send arrow dimmed, no error) for as long as it @@ -66,6 +70,7 @@ export async function sendMobileNativeChatMessageWithOutcome( terminal: args.terminal, text: args.clearInputFirst ? `${CLEAR_UNSUBMITTED_INPUT}${args.text}` : args.text, enter: args.enter ?? true, + ...(args.guardInteractivePrompt ? { nativeChat: true } : {}), ...(args.resolvedLaunchDraft ? { resolvedLaunchDraft: args.resolvedLaunchDraft } : {}), ...(args.mobileClient ? { client: args.mobileClient } : {}) }, @@ -74,7 +79,12 @@ export async function sendMobileNativeChatMessageWithOutcome( // pins the composer for twice as long. { timeoutMs, budgetSpansConnect: true } ) - return isTerminalSendRpcAccepted(response) ? 'accepted' : 'rejected' + if (isTerminalSendRpcAccepted(response)) { + return 'accepted' + } + return getTerminalSendRpcRefusedReason(response) === 'interactive-prompt' + ? 'interactive-prompt' + : 'rejected' } catch (error) { // Why: a logical relay↔direct cutover rejects the in-flight send without // knowing whether its frame reached the wire (the desktop may have delivered @@ -106,6 +116,7 @@ export async function clearMobileNativeChatInput(args: { client: RpcClient terminal: string clearInput: string + guardInteractivePrompt?: boolean mobileClient?: MobileTerminalClient deadline?: number }): Promise { @@ -121,6 +132,7 @@ export async function clearMobileNativeChatInput(args: { terminal: args.terminal, text: args.clearInput, enter: false, + ...(args.guardInteractivePrompt ? { nativeChat: true } : {}), ...(args.mobileClient ? { client: args.mobileClient } : {}) }, { timeoutMs, budgetSpansConnect: true } diff --git a/mobile/src/session/use-mobile-native-chat-message-send.test.ts b/mobile/src/session/use-mobile-native-chat-message-send.test.ts index 0ceda5714daa..1d480c362eea 100644 --- a/mobile/src/session/use-mobile-native-chat-message-send.test.ts +++ b/mobile/src/session/use-mobile-native-chat-message-send.test.ts @@ -35,6 +35,7 @@ describe('useMobileNativeChatMessageSend', () => { let api: Send | null = null const acceptSend = vi.fn() const holdUnconfirmedSend = vi.fn() + const restoreRejectedDraft = vi.fn() const onCommandSend = vi.fn() const commandSendRef = { current: onCommandSend } const agentRef = { current: null as string | null } @@ -56,7 +57,7 @@ describe('useMobileNativeChatMessageSend', () => { captureSendOrigin: () => ({ draftKey: 'k', pendingKey: 'p' }) as never, readSeededLaunchDraftSeed, clearDraftForSend: () => {}, - restoreRejectedDraft: () => {}, + restoreRejectedDraft, acceptSend, holdUnconfirmedSend, onSendError @@ -70,14 +71,19 @@ describe('useMobileNativeChatMessageSend', () => { const sentArgs = (): { clearInputFirst?: boolean + guardInteractivePrompt?: boolean resolvedLaunchDraft?: { text: string; createdAt: number } } => sendWithOutcome.mock.calls[0]![0] as { clearInputFirst?: boolean + guardInteractivePrompt?: boolean resolvedLaunchDraft?: { text: string; createdAt: number } } - const clearArgs = (): { clearInput?: string } => - (clearInputWrite.mock.calls[0]?.[0] ?? {}) as { clearInput?: string } + const clearArgs = (): { clearInput?: string; guardInteractivePrompt?: boolean } => + (clearInputWrite.mock.calls[0]?.[0] ?? {}) as { + clearInput?: string + guardInteractivePrompt?: boolean + } beforeEach(() => { globalThis.IS_REACT_ACT_ENVIRONMENT = true @@ -87,6 +93,7 @@ describe('useMobileNativeChatMessageSend', () => { clearInputWrite.mockResolvedValue(true) acceptSend.mockReset() holdUnconfirmedSend.mockReset() + restoreRejectedDraft.mockReset() onCommandSend.mockReset() commandSendRef.current = onCommandSend onSendError = vi.fn() @@ -106,6 +113,7 @@ describe('useMobileNativeChatMessageSend', () => { await api!.send('hello') }) expect(clearArgs().clearInput).toBe(buildAgentTuiClearInputForText(DRAFT)) + expect(clearArgs().guardInteractivePrompt).toBe(true) }) it('issues the burst as its OWN write, before the body', async () => { @@ -193,6 +201,7 @@ describe('useMobileNativeChatMessageSend', () => { await api!.answerQuestion('1') }) expect(sentArgs().resolvedLaunchDraft).toBeUndefined() + expect(sentArgs().guardInteractivePrompt).toBe(false) }) it('creates an optimistic echo for an ordinary chat send', async () => { @@ -201,9 +210,25 @@ describe('useMobileNativeChatMessageSend', () => { await api!.send('hello') }) expect(acceptSend).toHaveBeenCalledTimes(1) + expect(sentArgs().guardInteractivePrompt).toBe(true) expect(onCommandSend).not.toHaveBeenCalled() }) + it('restores the draft without an optimistic echo when the host finds a prompt', async () => { + sendWithOutcome.mockResolvedValue('interactive-prompt') + mount(() => null) + + let accepted = true + await act(async () => { + accepted = await api!.send('hello') + }) + + expect(accepted).toBe(false) + expect(restoreRejectedDraft).toHaveBeenCalledTimes(1) + expect(acceptSend).not.toHaveBeenCalled() + expect(onSendError).toHaveBeenCalledWith('Finish the terminal prompt before sending') + }) + // The STA-3332 "Queued forever" regression: command sends dispatch into the // agent's TUI and never echo as user turns, so they must not create a pending // bubble that no transcript match can ever retire. @@ -213,6 +238,7 @@ describe('useMobileNativeChatMessageSend', () => { await api!.send('/clear') }) expect(acceptSend).not.toHaveBeenCalled() + expect(sentArgs().guardInteractivePrompt).toBe(false) expect(onCommandSend).toHaveBeenCalledWith('/clear') }) diff --git a/mobile/src/session/use-mobile-native-chat-message-send.ts b/mobile/src/session/use-mobile-native-chat-message-send.ts index 1d59e25431cb..8fb5e47f2a68 100644 --- a/mobile/src/session/use-mobile-native-chat-message-send.ts +++ b/mobile/src/session/use-mobile-native-chat-message-send.ts @@ -89,6 +89,8 @@ export function useMobileNativeChatMessageSend(args: { const origin = captureSendOrigin(text) const agent = agentRef.current const recordCommand = commandSendRef.current + const classification = classifyMobileNativeChatSend(agent, text) + const guardInteractivePrompt = syncComposer && classification === 'chat' // Why: the lease collapses one render after `connState`, so a question-card // answer (which reaches this send directly) would otherwise burn the whole // 15s heal+send budget waiting on a socket that is already gone. @@ -137,6 +139,7 @@ export function useMobileNativeChatMessageSend(args: { client, terminal: handle, clearInput: buildAgentTuiClearInputForText(seededLaunchDraft.text), + guardInteractivePrompt, deadline, ...(deviceTokenRef.current ? { mobileClient: { id: deviceTokenRef.current, type: 'mobile' } } @@ -154,6 +157,7 @@ export function useMobileNativeChatMessageSend(args: { client, terminal: handle, text, + guardInteractivePrompt, // Why: pre-clear only when nothing was deliberately pasted first. The heal // above fires only for terminals a mobile image paste marked, so a desktop // launch-draft prefill parked on the input line would otherwise glue onto @@ -183,7 +187,6 @@ export function useMobileNativeChatMessageSend(args: { // TUI, not the conversation — the transcript never echoes it as a user // turn, so an optimistic bubble would sit at "Queued" forever and the // unconfirmed hold could never observe a landing. - const classification = classifyMobileNativeChatSend(agent, text) if (outcome === 'unknown') { if (classification === 'chat') { // Why: an ack-lost send usually WAS delivered (issue seen on cellular @@ -194,6 +197,13 @@ export function useMobileNativeChatMessageSend(args: { } return 'unknown' } + if (outcome === 'interactive-prompt') { + if (syncComposer) { + restoreRejectedDraft(origin, text) + } + onSendError('Finish the terminal prompt before sending') + return 'rejected' + } if (outcome === 'rejected') { if (syncComposer) { restoreRejectedDraft(origin, text) diff --git a/mobile/src/terminal/terminal-send-rpc-response.ts b/mobile/src/terminal/terminal-send-rpc-response.ts index 454c5e329727..ab1d60e03285 100644 --- a/mobile/src/terminal/terminal-send-rpc-response.ts +++ b/mobile/src/terminal/terminal-send-rpc-response.ts @@ -13,3 +13,12 @@ export function isTerminalSendRpcAccepted(response: RpcResponse): boolean { } return response.result.send.accepted === true } + +export function getTerminalSendRpcRefusedReason(response: RpcResponse): string | null { + if (!response.ok || !isRecord(response.result) || !isRecord(response.result.send)) { + return null + } + return typeof response.result.send.refusedReason === 'string' + ? response.result.send.refusedReason + : null +} diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 0c339475b686..5db1c6980646 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -336,6 +336,12 @@ import { type BrowserTabInfo, type BrowserScreencastResult } from '../../shared/runtime-types' +import { + TERMINAL_WAIT_BLOCKED_SENTINEL_RE, + detectTerminalWaitBlockedReason, + findActionableTerminalWaitBlockedSignal, + isKnownReadyPromptPreview +} from '../../shared/terminal-wait-blocked-detection' import { LINEAR_SEARCH_MAX_LIMIT, LINEAR_WRITE_BODY_CAP, @@ -10676,10 +10682,7 @@ export class OrcaRuntimeService { // A spawn published (or admission pending) this generation already // attaches the provider stream; a replacement under a reused id must not // read as the discovered never-attached session it replaced. - if ( - this.spawnPublishedPtys.has(ptyId) || - this.pendingPtyRegistrationIncarnations.has(ptyId) - ) { + if (this.spawnPublishedPtys.has(ptyId) || this.pendingPtyRegistrationIncarnations.has(ptyId)) { return false } // SSH panes have their own lease/reattach machinery. @@ -16436,6 +16439,13 @@ export class OrcaRuntimeService { return { handle, isRunningAgent, status: null } } + getTerminalWaitBlockedReason(handle: string): RuntimeTerminalWaitBlockedReason | null { + const ptyId = this.getTerminalAgentStatusPtyId(handle) + return detectTerminalWaitBlockedReason( + this.getTerminalAgentStatusSnapshot(handle, ptyId).waitText + ) + } + private getTerminalAgentStatusPtyId(handle: string): string { const pty = this.getLivePtyForHandle(handle) if (pty) { @@ -36178,210 +36188,6 @@ function detectExplicitIdleStatusFromTitle(title: string): AgentStatus | null { return null } -function isKnownReadyPromptPreview(preview: string): boolean { - const normalized = preview.toLowerCase() - const readyIndex = findKnownReadyPromptIndex(normalized) - if (readyIndex === null) { - return false - } - const blockedSignal = findTerminalWaitBlockedSignal(normalized) - if (blockedSignal !== null && blockedSignal.index > readyIndex) { - return false - } - return true -} - -function detectTerminalWaitBlockedReason(preview: string): RuntimeTerminalWaitBlockedReason | null { - const normalized = preview.toLowerCase() - return findActionableTerminalWaitBlockedSignal(normalized)?.reason ?? null -} - -function findActionableTerminalWaitBlockedSignal( - normalized: string -): { reason: RuntimeTerminalWaitBlockedReason; index: number } | null { - const blockedSignal = findTerminalWaitBlockedSignal(normalized) - if (blockedSignal === null) { - return null - } - const dismissedModalIndex = findDismissedStartupModalIndex(normalized) - // Why: a live prompt after the modal means it was dismissed → signal no longer actionable, even mid-run (Cursor never reports idle via OSC title). - return dismissedModalIndex !== null && dismissedModalIndex > blockedSignal.index - ? null - : blockedSignal -} - -// Why: a live prompt (idle OR busy) proves the startup modal was dismissed, so a mid-run Cursor lane stops reporting stale trust hits. -function findDismissedStartupModalIndex(normalized: string): number | null { - const indexes = [ - findCodexReadyPromptIndex(normalized), - findAntigravityReadyPromptIndex(normalized), - findCursorActivePromptIndex(normalized) - ].filter((index): index is number => index !== null) - return indexes.length > 0 ? Math.max(...indexes) : null -} - -function findKnownReadyPromptIndex(normalized: string): number | null { - const indexes = [ - findCodexReadyPromptIndex(normalized), - findAntigravityReadyPromptIndex(normalized), - findCursorReadyPromptIndex(normalized) - ].filter((index): index is number => index !== null) - return indexes.length > 0 ? Math.max(...indexes) : null -} - -// Why: match the banner's last occurrence to skip the trust dialog's own "Cursor Agent" text; "→" is cursor-agent's persistent input prompt. -function findCursorActivePromptIndex(normalized: string): number | null { - const headerIndex = normalized.lastIndexOf('cursor agent') - if (headerIndex === -1) { - return null - } - return normalized.includes('→', headerIndex) ? headerIndex : null -} - -// Why: cursor-agent emits no idle OSC title; infer idle from the tail (braille spinner = busy, its absence = idle). -const CURSOR_BUSY_SPINNER_RE = /[⠁-⣿]/ - -function findCursorReadyPromptIndex(normalized: string): number | null { - const activeIndex = findCursorActivePromptIndex(normalized) - if (activeIndex === null) { - return null - } - return CURSOR_BUSY_SPINNER_RE.test(normalized.slice(activeIndex)) ? null : activeIndex -} - -function findCodexReadyPromptIndex(normalized: string): number | null { - const headerIndex = normalized.lastIndexOf('openai codex') - if (headerIndex === -1) { - return null - } - const readySegment = normalized.slice(headerIndex) - // Why: Codex prints permissions only in YOLO mode; the stable ready header is OpenAI Codex + model + directory. - return readySegment.includes('model:') && readySegment.includes('directory:') ? headerIndex : null -} - -function findAntigravityReadyPromptIndex(normalized: string): number | null { - const headerIndex = normalized.lastIndexOf('antigravity cli') - if (headerIndex === -1) { - return null - } - let lineStart = headerIndex - let modelIndex: number | null = null - let promptIndex: number | null = null - - // Why: ready previews can include echoed paste after the header; scan line bounds directly instead of splitting the whole tail. - for (let cursor = headerIndex; cursor <= normalized.length; cursor += 1) { - if (cursor < normalized.length && normalized.charCodeAt(cursor) !== 10) { - continue - } - let trimmedStart = lineStart - let trimmedEnd = cursor - while (trimmedStart < trimmedEnd && isTerminalWaitWhitespace(normalized, trimmedStart)) { - trimmedStart += 1 - } - while (trimmedEnd > trimmedStart && isTerminalWaitWhitespace(normalized, trimmedEnd - 1)) { - trimmedEnd -= 1 - } - if (lineStart > headerIndex && trimmedStart < trimmedEnd) { - if (modelIndex === null && normalized.startsWith('gemini', trimmedStart)) { - modelIndex = trimmedStart - } - if ( - promptIndex === null && - trimmedEnd - trimmedStart === 1 && - normalized.charCodeAt(trimmedStart) === 62 - ) { - promptIndex = trimmedStart - } - } - lineStart = cursor + 1 - } - - return modelIndex !== null && promptIndex !== null ? Math.max(modelIndex, promptIndex) : null -} - -function isTerminalWaitWhitespace(value: string, index: number): boolean { - const code = value.charCodeAt(index) - return code === 32 || (code >= 9 && code <= 13) -} - -const TERMINAL_WAIT_BLOCKED_SENTINEL_RE = - /update available|choose working directory to|codex just got an upgrade|hooks need review|do you trust|trust this|trusted workspace|press enter to (?:confirm|continue|view|insert)|press t to trust/i - -function findTerminalWaitBlockedSignal( - normalized: string -): { reason: RuntimeTerminalWaitBlockedReason; index: number } | null { - // Why: one combined negative scan over the up-to-256 KiB tail avoids a dozen full-tail searches when no prompt can match. - if (!TERMINAL_WAIT_BLOCKED_SENTINEL_RE.test(normalized)) { - return null - } - const candidates: { reason: RuntimeTerminalWaitBlockedReason; index: number }[] = [] - const updateIndex = normalized.lastIndexOf('update available') - if (updateIndex !== -1 && normalized.includes('press enter to continue', updateIndex)) { - candidates.push({ reason: 'codex-update-prompt', index: updateIndex }) - } - const cwdIndex = normalized.lastIndexOf('choose working directory to') - if (cwdIndex !== -1 && normalized.includes('press enter to continue', cwdIndex)) { - candidates.push({ reason: 'codex-cwd-prompt', index: cwdIndex }) - } - const modelMigrationIndex = normalized.lastIndexOf('codex just got an upgrade') - if ( - modelMigrationIndex !== -1 && - normalized.includes('press enter to continue', modelMigrationIndex) - ) { - candidates.push({ reason: 'codex-model-migration-prompt', index: modelMigrationIndex }) - } - const hooksIndex = normalized.lastIndexOf('hooks need review') - if (hooksIndex !== -1 && normalized.includes('press enter to confirm', hooksIndex)) { - candidates.push({ reason: 'codex-hooks-review-prompt', index: hooksIndex }) - } - const trustIndex = Math.max( - normalized.lastIndexOf('do you trust'), - normalized.lastIndexOf('trust this'), - normalized.lastIndexOf('trusted workspace') - ) - const trustSegment = trustIndex === -1 ? '' : normalized.slice(trustIndex) - if ( - trustIndex !== -1 && - (trustSegment.includes('workspace') || - trustSegment.includes('folder') || - trustSegment.includes('directory') || - trustSegment.includes('repo')) - ) { - candidates.push({ reason: 'codex-trust-workspace', index: trustIndex }) - } - const interactivePromptIndex = Math.max( - normalized.lastIndexOf('press enter to confirm'), - normalized.lastIndexOf('press enter to continue'), - normalized.lastIndexOf('press enter to view'), - normalized.lastIndexOf('press enter to insert'), - normalized.lastIndexOf('press t to trust') - ) - const interactivePromptContext = - interactivePromptIndex === -1 - ? '' - : normalized.slice(Math.max(0, interactivePromptIndex - 600), interactivePromptIndex + 200) - const hasCodexInteractiveContext = - interactivePromptContext.includes('codex') || - interactivePromptContext.includes('permission') || - interactivePromptContext.includes('sandbox') || - interactivePromptContext.includes('trust') || - interactivePromptContext.includes('hook') - if (interactivePromptIndex !== -1 && hasCodexInteractiveContext) { - const contextStart = Math.max(0, interactivePromptIndex - 600) - const hasSpecificPromptInContext = candidates.some( - (candidate) => candidate.index >= contextStart && candidate.index <= interactivePromptIndex - ) - if (!hasSpecificPromptInContext) { - candidates.push({ reason: 'codex-interactive-prompt', index: interactivePromptIndex }) - } - } - return candidates.length > 0 - ? candidates.reduce((latest, candidate) => - candidate.index > latest.index ? candidate : latest - ) - : null -} - function buildTerminalWaitResult( handle: string, condition: RuntimeTerminalWaitCondition, diff --git a/src/main/runtime/rpc/methods/terminal.ts b/src/main/runtime/rpc/methods/terminal.ts index bd6cad49a325..973b3dacafee 100644 --- a/src/main/runtime/rpc/methods/terminal.ts +++ b/src/main/runtime/rpc/methods/terminal.ts @@ -366,8 +366,13 @@ async function commitMobileInputFloorClaim(claim: MobileInputFloorClaimHolder): } } -function getTerminalSendGuardRefusedReason(error: unknown): 'no-agent' | 'permission' | undefined { +function getTerminalSendGuardRefusedReason( + error: unknown +): 'no-agent' | 'permission' | 'interactive-prompt' | undefined { const message = error instanceof Error ? error.message : String(error) + if (message.includes('terminal_guard_interactive_prompt')) { + return 'interactive-prompt' + } if (message.includes('terminal_guard_permission')) { return 'permission' } @@ -869,6 +874,7 @@ const TerminalSend = TerminalHandle.extend({ text: OptionalString, enter: z.unknown().optional(), interrupt: z.unknown().optional(), + nativeChat: z.literal(true).optional(), resolvedLaunchDraft: z .object({ text: z.string(), @@ -1205,6 +1211,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ !isTerminalQueryReply(params.text) || params.enter === true || params.interrupt === true || + params.nativeChat === true || params.requireAgentStatus !== undefined || params.client?.type !== 'mobile' || !queryReplyClientId || @@ -1276,23 +1283,34 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ } } } - // Why: recheck permission/no-agent state immediately before accepting the PTY write. + // Why: guarded sends recheck terminal identity and prompt state immediately before each PTY write. const assertSendPreconditions = - params.requireAgentStatus === 'sendable' + params.requireAgentStatus === 'sendable' || params.nativeChat === true ? async (ptyId?: string): Promise => { - await assertTerminalAgentSendable({ - runtime, - handle: params.terminal, - assertWritable: () => { - assertTerminalSendExactPtyBinding(runtime, params.terminal, ptyId) - if (ptyId && isTerminalInputLockedForClient(runtime, ptyId, params.client)) { - throw new Error('terminal_guard_not_writable') - } + const assertWritable = (): void => { + assertTerminalSendExactPtyBinding(runtime, params.terminal, ptyId) + if (ptyId && isTerminalInputLockedForClient(runtime, ptyId, params.client)) { + throw new Error('terminal_guard_not_writable') } - }) + } + if (params.requireAgentStatus === 'sendable') { + await assertTerminalAgentSendable({ + runtime, + handle: params.terminal, + assertWritable + }) + } else { + assertWritable() + } + if ( + params.nativeChat === true && + runtime.getTerminalWaitBlockedReason(params.terminal) !== null + ) { + throw new Error('terminal_guard_interactive_prompt') + } } : undefined - if (params.requireAgentStatus === 'sendable') { + if (assertSendPreconditions) { try { await assertSendPreconditions?.(leaf?.ptyId ?? undefined) } catch (error) { diff --git a/src/main/runtime/rpc/terminal-send.test.ts b/src/main/runtime/rpc/terminal-send.test.ts index e9fb481aae06..65d047538e3d 100644 --- a/src/main/runtime/rpc/terminal-send.test.ts +++ b/src/main/runtime/rpc/terminal-send.test.ts @@ -615,6 +615,40 @@ describe('terminal send RPC', () => { expect(beginMobileInputFloor).not.toHaveBeenCalled() }) + it('refuses native-chat sends while the terminal has an interactive prompt', async () => { + const runtime = stubRuntime({ + resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + getDriver: vi.fn().mockReturnValue({ kind: 'mobile', clientId: 'mobile-1' }), + getTerminalWaitBlockedReason: vi.fn().mockReturnValue('codex-interactive-prompt'), + sendTerminal: vi.fn() + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('terminal.send', { + terminal: 'terminal-1', + text: 'hello', + enter: true, + nativeChat: true, + client: { id: 'mobile-1', type: 'mobile' } + }) + ) + + expect(response).toMatchObject({ + ok: true, + result: { + send: { + handle: 'terminal-1', + accepted: false, + bytesWritten: 0, + refusedReason: 'interactive-prompt' + } + } + }) + expect(runtime.sendTerminal).not.toHaveBeenCalled() + expect(runtime.beginMobileInputFloor).not.toHaveBeenCalled() + }) + it('allows guarded terminal sends when the agent is sendable', async () => { const runtime = stubRuntime({ resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx index 4eac2b5739cf..06ac0adac228 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx @@ -36,6 +36,8 @@ const mocks = vi.hoisted(() => ({ sendNativeChatMessageVerified: vi.fn(), trackPendingSend: vi.fn(), setDraft: vi.fn(), + draft: 'hello', + toastInfo: vi.fn(), draftScopeKeys: [] as string[], clearNativeChatLaunchDraft: vi.fn(), markNativeChatLaunchDraftAdopted: vi.fn() @@ -61,6 +63,9 @@ vi.mock('@/runtime/runtime-terminal-inspection', () => ({ vi.mock('@/lib/agent-paste-draft', () => ({ getSettingsForAgentTabRuntimeOwner: () => ({}) })) +vi.mock('sonner', () => ({ + toast: { info: (...args: unknown[]) => mocks.toastInfo(...args) } +})) vi.mock('./native-chat-runtime-send', () => ({ sendNativeChatMessage: (...args: unknown[]) => mocks.sendNativeChatMessage(...args), sendNativeChatMessageVerified: (...args: unknown[]) => @@ -85,7 +90,7 @@ vi.mock('@/lib/native-chat-telemetry', () => ({ vi.mock('./use-native-chat-draft', () => ({ useNativeChatDraft: (scopeKey: string) => { mocks.draftScopeKeys.push(scopeKey) - return { draft: 'hello', setDraft: mocks.setDraft } + return { draft: mocks.draft, setDraft: mocks.setDraft } } })) vi.mock('./native-chat-draft-cache', () => ({ @@ -142,6 +147,7 @@ describe('NativeChatComposer', () => { clearNativeChatModelEnrichmentForTests() mocks.fieldProps = null mocks.modelSwitchOutcome = 'applied' + mocks.draft = 'hello' mocks.draftScopeKeys.length = 0 mocks.confirmationObserver = null mocks.createClaudeModelSwitchConfirmationObserver.mockImplementation(() => { @@ -234,6 +240,59 @@ describe('NativeChatComposer', () => { expect(mocks.trackPendingSend).toHaveBeenCalledWith(mocks.sendHandle, 'pending-1') }) + it('keeps a chat draft and reveals an active Codex picker instead of sending', () => { + const onOptimisticSend = vi.fn() + const onSwitchToTerminal = vi.fn() + render( + + [ + 'Select Model and Effort', + 'gpt-5.3-codex-spark', + 'Press enter to confirm or esc to cancel' + ].join('\n') + } + onOptimisticSend={onOptimisticSend} + onSwitchToTerminal={onSwitchToTerminal} + /> + ) + + act(() => mocks.fieldProps?.onSend?.()) + + expect(mocks.sendNativeChatMessage).not.toHaveBeenCalled() + expect(onOptimisticSend).not.toHaveBeenCalled() + expect(mocks.setDraft).not.toHaveBeenCalled() + expect(mocks.clearNativeChatLaunchDraft).not.toHaveBeenCalled() + expect(mocks.toastInfo).toHaveBeenCalledWith('Finish the terminal prompt before sending') + expect(onSwitchToTerminal).toHaveBeenCalledOnce() + }) + + it('allows an intentional slash command while a Codex picker is open', () => { + mocks.draft = '/model' + const onSwitchToTerminal = vi.fn() + render( + + ['gpt-5.3-codex-spark', 'Press enter to confirm or esc to cancel'].join('\n') + } + onSwitchToTerminal={onSwitchToTerminal} + /> + ) + + act(() => mocks.fieldProps?.onSend?.()) + + expect(mocks.sendNativeChatMessage).toHaveBeenCalledWith({}, 'pty-1', '/model', undefined) + expect(onSwitchToTerminal).not.toHaveBeenCalled() + }) + it('retires the launch-draft seed once a send clears the TUI input line', () => { render( string | null) | undefined, + onSwitchToTerminal: (() => void) | undefined +): boolean { + const screen = classification === 'chat' ? readTerminalScreen?.() : null + if (!screen || detectTerminalWaitBlockedReason(screen) === null) { + return false + } + toast.info( + translate( + 'components.native-chat.composer.finishTerminalPrompt', + 'Finish the terminal prompt before sending' + ) + ) + onSwitchToTerminal?.() + return true +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 53c5af930adb..cb0dbd33b263 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -14746,7 +14746,8 @@ "max": "Max", "on": "On", "off": "Off" - } + }, + "finishTerminalPrompt": "Finish the terminal prompt before sending" }, "tool": { "running": "Running…", diff --git a/src/shared/runtime-types.ts b/src/shared/runtime-types.ts index 6e012e60b421..99c22a2e7ef0 100644 --- a/src/shared/runtime-types.ts +++ b/src/shared/runtime-types.ts @@ -604,7 +604,7 @@ export type RuntimeTerminalSend = { handle: string accepted: boolean bytesWritten: number - refusedReason?: 'no-agent' | 'permission' + refusedReason?: 'no-agent' | 'permission' | 'interactive-prompt' } export type RuntimeTerminalAgentStatusState = 'working' | 'permission' | 'idle' | null diff --git a/src/shared/terminal-wait-blocked-detection.ts b/src/shared/terminal-wait-blocked-detection.ts new file mode 100644 index 000000000000..690360cfc5d3 --- /dev/null +++ b/src/shared/terminal-wait-blocked-detection.ts @@ -0,0 +1,197 @@ +import type { RuntimeTerminalWaitBlockedReason } from './runtime-types' + +export const TERMINAL_WAIT_BLOCKED_SENTINEL_RE = + /update available|choose working directory to|codex just got an upgrade|hooks need review|do you trust|trust this|trusted workspace|press enter to (?:confirm|continue|view|insert)|press t to trust/i + +export function detectTerminalWaitBlockedReason( + preview: string +): RuntimeTerminalWaitBlockedReason | null { + return findActionableTerminalWaitBlockedSignal(preview.toLowerCase())?.reason ?? null +} + +export function isKnownReadyPromptPreview(preview: string): boolean { + const normalized = preview.toLowerCase() + const readyIndex = findKnownReadyPromptIndex(normalized) + if (readyIndex === null) { + return false + } + const blockedSignal = findTerminalWaitBlockedSignal(normalized) + return blockedSignal === null || blockedSignal.index <= readyIndex +} + +export function findActionableTerminalWaitBlockedSignal( + normalized: string +): { reason: RuntimeTerminalWaitBlockedReason; index: number } | null { + const blockedSignal = findTerminalWaitBlockedSignal(normalized) + if (blockedSignal === null) { + return null + } + const dismissedModalIndex = findDismissedStartupModalIndex(normalized) + // Why: a live prompt after the modal means it was dismissed, including mid-run Cursor prompts. + return dismissedModalIndex !== null && dismissedModalIndex > blockedSignal.index + ? null + : blockedSignal +} + +function findDismissedStartupModalIndex(normalized: string): number | null { + const indexes = [ + findCodexReadyPromptIndex(normalized), + findAntigravityReadyPromptIndex(normalized), + findCursorActivePromptIndex(normalized) + ].filter((index): index is number => index !== null) + return indexes.length > 0 ? Math.max(...indexes) : null +} + +function findKnownReadyPromptIndex(normalized: string): number | null { + const indexes = [ + findCodexReadyPromptIndex(normalized), + findAntigravityReadyPromptIndex(normalized), + findCursorReadyPromptIndex(normalized) + ].filter((index): index is number => index !== null) + return indexes.length > 0 ? Math.max(...indexes) : null +} + +function findCursorActivePromptIndex(normalized: string): number | null { + const headerIndex = normalized.lastIndexOf('cursor agent') + if (headerIndex === -1) { + return null + } + return normalized.includes('→', headerIndex) ? headerIndex : null +} + +const CURSOR_BUSY_SPINNER_RE = /[⠁-⣿]/ + +function findCursorReadyPromptIndex(normalized: string): number | null { + const activeIndex = findCursorActivePromptIndex(normalized) + if (activeIndex === null) { + return null + } + return CURSOR_BUSY_SPINNER_RE.test(normalized.slice(activeIndex)) ? null : activeIndex +} + +function findCodexReadyPromptIndex(normalized: string): number | null { + const headerIndex = normalized.lastIndexOf('openai codex') + if (headerIndex === -1) { + return null + } + const readySegment = normalized.slice(headerIndex) + return readySegment.includes('model:') && readySegment.includes('directory:') ? headerIndex : null +} + +function findAntigravityReadyPromptIndex(normalized: string): number | null { + const headerIndex = normalized.lastIndexOf('antigravity cli') + if (headerIndex === -1) { + return null + } + let lineStart = headerIndex + let modelIndex: number | null = null + let promptIndex: number | null = null + + for (let cursor = headerIndex; cursor <= normalized.length; cursor += 1) { + if (cursor < normalized.length && normalized.charCodeAt(cursor) !== 10) { + continue + } + let trimmedStart = lineStart + let trimmedEnd = cursor + while (trimmedStart < trimmedEnd && isTerminalWaitWhitespace(normalized, trimmedStart)) { + trimmedStart += 1 + } + while (trimmedEnd > trimmedStart && isTerminalWaitWhitespace(normalized, trimmedEnd - 1)) { + trimmedEnd -= 1 + } + if (lineStart > headerIndex && trimmedStart < trimmedEnd) { + if (modelIndex === null && normalized.startsWith('gemini', trimmedStart)) { + modelIndex = trimmedStart + } + if ( + promptIndex === null && + trimmedEnd - trimmedStart === 1 && + normalized.charCodeAt(trimmedStart) === 62 + ) { + promptIndex = trimmedStart + } + } + lineStart = cursor + 1 + } + + return modelIndex !== null && promptIndex !== null ? Math.max(modelIndex, promptIndex) : null +} + +function isTerminalWaitWhitespace(value: string, index: number): boolean { + const code = value.charCodeAt(index) + return code === 32 || (code >= 9 && code <= 13) +} + +function findTerminalWaitBlockedSignal( + normalized: string +): { reason: RuntimeTerminalWaitBlockedReason; index: number } | null { + if (!TERMINAL_WAIT_BLOCKED_SENTINEL_RE.test(normalized)) { + return null + } + const candidates: { reason: RuntimeTerminalWaitBlockedReason; index: number }[] = [] + const updateIndex = normalized.lastIndexOf('update available') + if (updateIndex !== -1 && normalized.includes('press enter to continue', updateIndex)) { + candidates.push({ reason: 'codex-update-prompt', index: updateIndex }) + } + const cwdIndex = normalized.lastIndexOf('choose working directory to') + if (cwdIndex !== -1 && normalized.includes('press enter to continue', cwdIndex)) { + candidates.push({ reason: 'codex-cwd-prompt', index: cwdIndex }) + } + const modelMigrationIndex = normalized.lastIndexOf('codex just got an upgrade') + if ( + modelMigrationIndex !== -1 && + normalized.includes('press enter to continue', modelMigrationIndex) + ) { + candidates.push({ reason: 'codex-model-migration-prompt', index: modelMigrationIndex }) + } + const hooksIndex = normalized.lastIndexOf('hooks need review') + if (hooksIndex !== -1 && normalized.includes('press enter to confirm', hooksIndex)) { + candidates.push({ reason: 'codex-hooks-review-prompt', index: hooksIndex }) + } + const trustIndex = Math.max( + normalized.lastIndexOf('do you trust'), + normalized.lastIndexOf('trust this'), + normalized.lastIndexOf('trusted workspace') + ) + const trustSegment = trustIndex === -1 ? '' : normalized.slice(trustIndex) + if ( + trustIndex !== -1 && + (trustSegment.includes('workspace') || + trustSegment.includes('folder') || + trustSegment.includes('directory') || + trustSegment.includes('repo')) + ) { + candidates.push({ reason: 'codex-trust-workspace', index: trustIndex }) + } + const interactivePromptIndex = Math.max( + normalized.lastIndexOf('press enter to confirm'), + normalized.lastIndexOf('press enter to continue'), + normalized.lastIndexOf('press enter to view'), + normalized.lastIndexOf('press enter to insert'), + normalized.lastIndexOf('press t to trust') + ) + const interactivePromptContext = + interactivePromptIndex === -1 + ? '' + : normalized.slice(Math.max(0, interactivePromptIndex - 600), interactivePromptIndex + 200) + const hasCodexInteractiveContext = + interactivePromptContext.includes('codex') || + interactivePromptContext.includes('permission') || + interactivePromptContext.includes('sandbox') || + interactivePromptContext.includes('trust') || + interactivePromptContext.includes('hook') + if (interactivePromptIndex !== -1 && hasCodexInteractiveContext) { + const contextStart = Math.max(0, interactivePromptIndex - 600) + const hasSpecificPromptInContext = candidates.some( + (candidate) => candidate.index >= contextStart && candidate.index <= interactivePromptIndex + ) + if (!hasSpecificPromptInContext) { + candidates.push({ reason: 'codex-interactive-prompt', index: interactivePromptIndex }) + } + } + return candidates.length > 0 + ? candidates.reduce((latest, candidate) => + candidate.index > latest.index ? candidate : latest + ) + : null +} From 484bdd425becf9978222714dccc9e9fc2b25c2eb Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 5 Aug 2026 00:01:32 -0700 Subject: [PATCH 2/6] fix(mobile): preserve prompt refusal during draft clear --- .../session/mobile-native-chat-send.test.ts | 25 ++++++++++++++++--- mobile/src/session/mobile-native-chat-send.ts | 14 ++++++++--- ...se-mobile-native-chat-message-send.test.ts | 20 +++++++++++++-- .../use-mobile-native-chat-message-send.ts | 10 +++++--- 4 files changed, 57 insertions(+), 12 deletions(-) diff --git a/mobile/src/session/mobile-native-chat-send.test.ts b/mobile/src/session/mobile-native-chat-send.test.ts index ff42d38940ac..6a5490a1e03f 100644 --- a/mobile/src/session/mobile-native-chat-send.test.ts +++ b/mobile/src/session/mobile-native-chat-send.test.ts @@ -340,7 +340,7 @@ describe('clearMobileNativeChatInput', () => { const clearInput = buildAgentTuiClearInputForText('Linked Linear issue: ABC-123\nhttps://x') await expect( clearMobileNativeChatInput({ client, terminal: 'term', clearInput }) - ).resolves.toBe(true) + ).resolves.toBe('accepted') expect(params(client)).toMatchObject({ text: clearInput, enter: false }) }) @@ -353,7 +353,26 @@ describe('clearMobileNativeChatInput', () => { }) await expect( clearMobileNativeChatInput({ client, terminal: 'term', clearInput: '\x15' }) - ).resolves.toBe(false) + ).resolves.toBe('rejected') + }) + + it('preserves an interactive prompt rejection from a guarded clear', async () => { + const client = clientWithResponse({ + id: 'request', + ok: true, + result: { send: { accepted: false, refusedReason: 'interactive-prompt' } }, + _meta: { runtimeId: 'runtime' } + }) + + await expect( + clearMobileNativeChatInput({ + client, + terminal: 'term', + clearInput: '\x15', + guardInteractivePrompt: true + }) + ).resolves.toBe('interactive-prompt') + expect(params(client)).toMatchObject({ nativeChat: true }) }) it('refuses to start an underfunded clear rather than half-clearing', async () => { @@ -365,7 +384,7 @@ describe('clearMobileNativeChatInput', () => { clearInput: '\x15', deadline: Date.now() + 10 }) - ).resolves.toBe(false) + ).resolves.toBe('rejected') expect(client.sendRequest).not.toHaveBeenCalled() }) }) diff --git a/mobile/src/session/mobile-native-chat-send.ts b/mobile/src/session/mobile-native-chat-send.ts index 1b6c0a03002d..28c3f247cf25 100644 --- a/mobile/src/session/mobile-native-chat-send.ts +++ b/mobile/src/session/mobile-native-chat-send.ts @@ -40,6 +40,7 @@ type MobileNativeChatSendArgs = { * desktop (ack loss after a write, or a cutover that cannot tell whether the * frame was written) — callers must not present it as a definite send failure. */ export type MobileNativeChatSendOutcome = 'accepted' | 'rejected' | 'unknown' | 'interactive-prompt' +export type MobileNativeChatClearOutcome = Exclude /** Without an explicit timeout `sendRequest` waits for reconnect indefinitely, and * the composer holds `sending` (send arrow dimmed, no error) for as long as it @@ -119,11 +120,11 @@ export async function clearMobileNativeChatInput(args: { guardInteractivePrompt?: boolean mobileClient?: MobileTerminalClient deadline?: number -}): Promise { +}): Promise { const timeoutMs = args.deadline === undefined ? MOBILE_NATIVE_CHAT_SEND_TIMEOUT_MS : args.deadline - Date.now() if (timeoutMs < MOBILE_NATIVE_CHAT_MIN_WRITE_TIMEOUT_MS) { - return false + return 'rejected' } try { const response = await args.client.sendRequest( @@ -137,9 +138,14 @@ export async function clearMobileNativeChatInput(args: { }, { timeoutMs, budgetSpansConnect: true } ) - return isTerminalSendRpcAccepted(response) + if (isTerminalSendRpcAccepted(response)) { + return 'accepted' + } + return getTerminalSendRpcRefusedReason(response) === 'interactive-prompt' + ? 'interactive-prompt' + : 'rejected' } catch { // A failed clear must not send the body on top of an uncleared line. - return false + return 'rejected' } } diff --git a/mobile/src/session/use-mobile-native-chat-message-send.test.ts b/mobile/src/session/use-mobile-native-chat-message-send.test.ts index 1d480c362eea..a0473b734898 100644 --- a/mobile/src/session/use-mobile-native-chat-message-send.test.ts +++ b/mobile/src/session/use-mobile-native-chat-message-send.test.ts @@ -90,7 +90,7 @@ describe('useMobileNativeChatMessageSend', () => { sendWithOutcome.mockReset() sendWithOutcome.mockResolvedValue('accepted') clearInputWrite.mockReset() - clearInputWrite.mockResolvedValue(true) + clearInputWrite.mockResolvedValue('accepted') acceptSend.mockReset() holdUnconfirmedSend.mockReset() restoreRejectedDraft.mockReset() @@ -131,7 +131,7 @@ describe('useMobileNativeChatMessageSend', () => { it('aborts without sending the body when the clear is rejected', async () => { // Sending on top of an uncleared line is exactly the concatenation bug. - clearInputWrite.mockResolvedValue(false) + clearInputWrite.mockResolvedValue('rejected') mount(() => ({ text: DRAFT, createdAt: 1 })) let result: boolean | undefined await act(async () => { @@ -141,6 +141,22 @@ describe('useMobileNativeChatMessageSend', () => { expect(sendWithOutcome).not.toHaveBeenCalled() }) + it('reports an interactive prompt rejected while clearing a seeded launch draft', async () => { + clearInputWrite.mockResolvedValue('interactive-prompt') + mount(() => ({ text: DRAFT, createdAt: 1 })) + + let result: boolean | undefined + await act(async () => { + result = await api!.send('hello') + }) + + expect(result).toBe(false) + expect(restoreRejectedDraft).toHaveBeenCalledTimes(1) + expect(sendWithOutcome).not.toHaveBeenCalled() + expect(acceptSend).not.toHaveBeenCalled() + expect(onSendError).toHaveBeenCalledWith('Finish the terminal prompt before sending') + }) + it('drops the body write\u2019s own Ctrl+U prefix once the dedicated clear ran', async () => { // A Ctrl+U written immediately before body text in the SAME write arrives as // a literal control character, so it would head the received message. diff --git a/mobile/src/session/use-mobile-native-chat-message-send.ts b/mobile/src/session/use-mobile-native-chat-message-send.ts index 8fb5e47f2a68..bd06d598c7ad 100644 --- a/mobile/src/session/use-mobile-native-chat-message-send.ts +++ b/mobile/src/session/use-mobile-native-chat-message-send.ts @@ -135,7 +135,7 @@ export function useMobileNativeChatMessageSend(args: { // than pasting on top of an uncleared line. const seededLaunchDraft = readSeededLaunchDraftSeed() if (seededLaunchDraft && !images?.length) { - const cleared = await clearMobileNativeChatInput({ + const clearOutcome = await clearMobileNativeChatInput({ client, terminal: handle, clearInput: buildAgentTuiClearInputForText(seededLaunchDraft.text), @@ -145,11 +145,15 @@ export function useMobileNativeChatMessageSend(args: { ? { mobileClient: { id: deviceTokenRef.current, type: 'mobile' } } : {}) }) - if (!cleared) { + if (clearOutcome !== 'accepted') { if (syncComposer) { restoreRejectedDraft(origin, text) } - onSendError('Message not sent') + onSendError( + clearOutcome === 'interactive-prompt' + ? 'Finish the terminal prompt before sending' + : 'Message not sent' + ) return 'rejected' } } From a18365f4b5242bc6810ba58a3baf80aae319e08d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 5 Aug 2026 19:20:07 -0700 Subject: [PATCH 3/6] fix(native-chat): recover unconfirmed prompt sends --- ...MobileNativeChatDeliveryRecoveryNotice.tsx | 37 +++ .../src/session/MobileNativeChatOverlay.tsx | 2 + .../src/session/MobileNativeChatView.test.ts | 19 ++ mobile/src/session/MobileNativeChatView.tsx | 6 + .../mobile-native-chat-controller-contract.ts | 2 + .../mobile-native-chat-image-send.test.ts | 1 - .../session/mobile-native-chat-image-send.ts | 1 - .../mobile-native-chat-pending-echo.ts | 4 + ...mobile-native-chat-prompt-delivery.test.ts | 117 +++++++++ .../mobile-native-chat-prompt-delivery.ts | 76 ++++++ .../session/mobile-native-chat-send.test.ts | 58 +---- mobile/src/session/mobile-native-chat-send.ts | 32 +-- .../session/mobile-native-chat-view-styles.ts | 48 ++++ .../use-mobile-native-chat-controller.ts | 8 +- ...bile-native-chat-delivery-recovery.test.ts | 139 ++++++++++ ...se-mobile-native-chat-delivery-recovery.ts | 207 +++++++++++++++ .../session/use-mobile-native-chat-drafts.ts | 50 ++-- ...se-mobile-native-chat-message-send.test.ts | 52 +--- .../use-mobile-native-chat-message-send.ts | 22 +- .../terminal/terminal-send-rpc-response.ts | 9 - ...k-status-session-tabs-invalidation.test.ts | 38 +++ .../hook-status-session-tabs-invalidation.ts | 45 +++- src/main/agent-hooks/server.test.ts | 52 ++++ src/main/agent-hooks/server.ts | 43 +++- src/main/index.ts | 4 + ...ntime-hook-agent-status-projection.test.ts | 28 +- src/main/runtime/orca-runtime.ts | 239 ++++++++++++++++-- src/main/runtime/rpc/methods/terminal.ts | 44 +--- src/main/runtime/rpc/terminal-send.test.ts | 34 --- src/main/ssh/ssh-relay-session.ts | 5 + src/relay/agent-hook-server.test.ts | 4 + src/relay/agent-hook-server.ts | 6 +- .../native-chat/NativeChatComposer.test.tsx | 64 +---- .../native-chat/NativeChatComposer.tsx | 80 +++--- .../NativeChatDeliveryRecoveryNotice.test.tsx | 21 ++ .../NativeChatDeliveryRecoveryNotice.tsx | 33 +++ .../components/native-chat/NativeChatView.tsx | 116 ++++----- .../native-chat/native-chat-composer-types.ts | 4 + .../native-chat/native-chat-pending.ts | 3 + .../native-chat-prompt-delivery.test.ts | 110 ++++++++ .../native-chat-prompt-delivery.ts | 95 +++++++ .../native-chat/native-chat-pty-send-queue.ts | 13 +- .../native-chat/native-chat-runtime-send.ts | 4 + .../native-chat-terminal-prompt-guard.ts | 22 -- .../use-native-chat-composer-attachments.ts | 29 ++- .../use-native-chat-composer-target.ts | 32 +++ .../use-native-chat-delivery-recovery.ts | 190 ++++++++++++++ .../use-native-chat-pending-delivery.ts | 117 +++++++++ .../use-native-chat-send-lifecycle.test.tsx | 27 +- .../use-native-chat-send-lifecycle.ts | 52 ++-- src/renderer/src/hooks/useIpcEvents.test.ts | 24 +- src/renderer/src/hooks/useIpcEvents.ts | 7 +- src/renderer/src/i18n/locales/en.json | 7 +- .../slices/agent-prompt-submission-history.ts | 23 ++ .../agent-status-prompt-submission.test.ts | 49 ++++ src/renderer/src/store/slices/agent-status.ts | 8 + src/shared/agent-hook-listener.test.ts | 32 +++ src/shared/agent-hook-listener.ts | 11 + src/shared/agent-hook-relay.ts | 2 + src/shared/agent-status-types.ts | 18 ++ src/shared/runtime-types.ts | 2 +- src/shared/terminal-wait-blocked-detection.ts | 197 --------------- 62 files changed, 2127 insertions(+), 697 deletions(-) create mode 100644 mobile/src/session/MobileNativeChatDeliveryRecoveryNotice.tsx create mode 100644 mobile/src/session/mobile-native-chat-prompt-delivery.test.ts create mode 100644 mobile/src/session/mobile-native-chat-prompt-delivery.ts create mode 100644 mobile/src/session/use-mobile-native-chat-delivery-recovery.test.ts create mode 100644 mobile/src/session/use-mobile-native-chat-delivery-recovery.ts create mode 100644 src/renderer/src/components/native-chat/NativeChatDeliveryRecoveryNotice.test.tsx create mode 100644 src/renderer/src/components/native-chat/NativeChatDeliveryRecoveryNotice.tsx create mode 100644 src/renderer/src/components/native-chat/native-chat-prompt-delivery.test.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-prompt-delivery.ts delete mode 100644 src/renderer/src/components/native-chat/native-chat-terminal-prompt-guard.ts create mode 100644 src/renderer/src/components/native-chat/use-native-chat-composer-target.ts create mode 100644 src/renderer/src/components/native-chat/use-native-chat-delivery-recovery.ts create mode 100644 src/renderer/src/components/native-chat/use-native-chat-pending-delivery.ts create mode 100644 src/renderer/src/store/slices/agent-prompt-submission-history.ts create mode 100644 src/renderer/src/store/slices/agent-status-prompt-submission.test.ts delete mode 100644 src/shared/terminal-wait-blocked-detection.ts diff --git a/mobile/src/session/MobileNativeChatDeliveryRecoveryNotice.tsx b/mobile/src/session/MobileNativeChatDeliveryRecoveryNotice.tsx new file mode 100644 index 000000000000..70328b065eba --- /dev/null +++ b/mobile/src/session/MobileNativeChatDeliveryRecoveryNotice.tsx @@ -0,0 +1,37 @@ +import { Pressable, Text, View } from 'react-native' +import { AlertTriangle } from 'lucide-react-native' +import { colors } from '../theme/mobile-theme' +import { styles } from './mobile-native-chat-view-styles' + +export function MobileNativeChatDeliveryRecoveryNotice({ + onShowTerminal +}: { + onShowTerminal?: () => void +}): React.JSX.Element { + return ( + + + + + + + Message wasn’t sent + + The terminal may be waiting for input. Your message is still in the composer. + + + + [styles.deliveryRecoveryButton, pressed && styles.pressed]} + onPress={onShowTerminal} + accessibilityRole="button" + > + Show terminal + + + ) +} diff --git a/mobile/src/session/MobileNativeChatOverlay.tsx b/mobile/src/session/MobileNativeChatOverlay.tsx index 699f8a1614b9..9e0c1c0178e5 100644 --- a/mobile/src/session/MobileNativeChatOverlay.tsx +++ b/mobile/src/session/MobileNativeChatOverlay.tsx @@ -97,6 +97,8 @@ export function MobileNativeChatOverlay({ inputLockReason={inputLockReason} sendErrorMessage={sendErrorMessage} onClearSendError={onClearSendError} + deliveryFailed={controller.nativeChatDeliveryFailed} + onShowTerminal={controller.showNativeChatTerminal} filePaths={controller.nativeChatFilePaths} onNeedFiles={controller.loadNativeChatFiles} sessionOptions={controller.nativeChatSessionOptions} diff --git a/mobile/src/session/MobileNativeChatView.test.ts b/mobile/src/session/MobileNativeChatView.test.ts index 21a5a7c4aa1e..5bed0029135a 100644 --- a/mobile/src/session/MobileNativeChatView.test.ts +++ b/mobile/src/session/MobileNativeChatView.test.ts @@ -31,6 +31,7 @@ vi.mock('react-native-gesture-handler', () => { }) vi.mock('lucide-react-native', () => ({ + AlertTriangle: 'AlertTriangle', ArrowDown: 'ArrowDown', ChevronsDownUp: 'ChevronsDownUp', ChevronsUpDown: 'ChevronsUpDown', @@ -71,6 +72,8 @@ type Overrides = { onClearSendError?: () => void inputLockReason?: 'disconnected' | 'waiting' | null onSend?: (text: string) => Promise + deliveryFailed?: boolean + onShowTerminal?: () => void } function suppressRendererWarning(): () => void { @@ -170,6 +173,22 @@ describe('MobileNativeChatView', () => { expect(bannerText()).toContain('Permission reply failed') }) + it('shows persistent delivery recovery with a terminal action', async () => { + const onShowTerminal = vi.fn() + await render({ deliveryFailed: true, onShowTerminal }) + + expect(bannerText()).toContain('Message wasn’t sent') + expect(bannerText()).toContain('Your message is still in the composer.') + const action = renderer!.root.find( + (node) => + node.type === 'Pressable' && + node.findAll((child) => child.type === 'Text' && child.props.children === 'Show terminal') + .length > 0 + ) + act(() => action.props.onPress()) + expect(onShowTerminal).toHaveBeenCalledOnce() + }) + it('does not duplicate the route banner when the composer rejects', async () => { const onClearSendError = vi.fn() await render({ diff --git a/mobile/src/session/MobileNativeChatView.tsx b/mobile/src/session/MobileNativeChatView.tsx index 478d36267a70..72aa7c1d5df0 100644 --- a/mobile/src/session/MobileNativeChatView.tsx +++ b/mobile/src/session/MobileNativeChatView.tsx @@ -23,6 +23,7 @@ import { useMobileNativeChatPinchGesture } from './use-mobile-native-chat-pinch- import { MobileAgentWorkingIndicator } from './MobileAgentWorkingIndicator' import type { PendingNativeChatImage } from './mobile-native-chat-image-attachment' import { MobileNativeChatComposer } from './MobileNativeChatComposer' +import { MobileNativeChatDeliveryRecoveryNotice } from './MobileNativeChatDeliveryRecoveryNotice' import type { MobileNativeChatSessionOptionPickersProps } from './MobileNativeChatSessionOptionPickers' import { MobileNativeChatMessage } from './MobileNativeChatMessage' import { MobileNativeChatAsk } from './MobileNativeChatAsk' @@ -85,6 +86,8 @@ type Props = { sendErrorMessage?: string | null /** Clears `sendErrorMessage` once a later send is accepted. */ onClearSendError?: () => void + deliveryFailed?: boolean + onShowTerminal?: () => void filePaths?: string[] onNeedFiles?: (query: string) => void /** Model/session-option pickers for the composer action row (desktop parity). */ @@ -143,6 +146,8 @@ export function MobileNativeChatView({ inputLockReason, sendErrorMessage, onClearSendError, + deliveryFailed, + onShowTerminal, filePaths, onNeedFiles, sessionOptions, @@ -429,6 +434,7 @@ export function MobileNativeChatView({ {sendErrorMessage} ) : null} + {deliveryFailed && } > chatPending: MobileNativeChatPendingMessage[] chatImagePreviewsByMessageId: Record + nativeChatDeliveryFailed: boolean + showNativeChatTerminal: () => void nativeChatSession: ReturnType nativeChatAgentWorking: boolean nativeChatStreamingText?: string diff --git a/mobile/src/session/mobile-native-chat-image-send.test.ts b/mobile/src/session/mobile-native-chat-image-send.test.ts index 2d5f5efaa705..cf1c59adf0fc 100644 --- a/mobile/src/session/mobile-native-chat-image-send.test.ts +++ b/mobile/src/session/mobile-native-chat-image-send.test.ts @@ -50,7 +50,6 @@ describe('pasteMobileNativeChatImagePaths', () => { terminal: 'term-1', text: '\x15', enter: false, - nativeChat: true, client: { id: 'device-9', type: 'mobile' } } }) diff --git a/mobile/src/session/mobile-native-chat-image-send.ts b/mobile/src/session/mobile-native-chat-image-send.ts index 7f446f1c159c..adb996b76125 100644 --- a/mobile/src/session/mobile-native-chat-image-send.ts +++ b/mobile/src/session/mobile-native-chat-image-send.ts @@ -70,7 +70,6 @@ export async function pasteMobileNativeChatImagePaths({ terminal, text, enter: false, - nativeChat: true, ...clientField }, // The remaining budget covers the reconnect wait too; a fresh post-connect diff --git a/mobile/src/session/mobile-native-chat-pending-echo.ts b/mobile/src/session/mobile-native-chat-pending-echo.ts index b18136a9d473..6f16f9e8f6db 100644 --- a/mobile/src/session/mobile-native-chat-pending-echo.ts +++ b/mobile/src/session/mobile-native-chat-pending-echo.ts @@ -1,3 +1,5 @@ +import type { AgentPromptSubmissionOccurrence } from '../../../src/shared/agent-status-types' + export type MobileNativeChatPendingMessage = { id: string text: string @@ -13,6 +15,8 @@ export type MobileNativeChatSendOrigin = { normalizedText: string baselineOccurrences: number baselineTailMessageId: string | null + /** Latest hook occurrence observed before dispatch starts. */ + promptSubmissionBaseline?: AgentPromptSubmissionOccurrence } type PendingByKey = Record diff --git a/mobile/src/session/mobile-native-chat-prompt-delivery.test.ts b/mobile/src/session/mobile-native-chat-prompt-delivery.test.ts new file mode 100644 index 000000000000..592003c29865 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-prompt-delivery.test.ts @@ -0,0 +1,117 @@ +import { createHash } from 'node:crypto' +import { describe, expect, it } from 'vitest' +import { + assignMobileNativeChatPromptSubmissions, + mobileNativeChatPromptDigest, + mobileNativeChatPromptWasAcknowledged, + type MobileNativeChatDeliveryCheck +} from './mobile-native-chat-prompt-delivery' + +describe('mobile native chat prompt delivery', () => { + it('matches the desktop full-prompt digest contract', () => { + const prompt = ` ${'long '.repeat(60)}\nemoji 🚀 ` + expect(mobileNativeChatPromptDigest(prompt)).toBe( + `sha256:${createHash('sha256').update(prompt).digest('hex')}` + ) + }) + + it('requires an exact newer occurrence', () => { + const check: MobileNativeChatDeliveryCheck = { + pendingId: 'pending-1', + draftKey: 'draft', + pendingKey: 'pending', + text: 'hello', + normalizedText: 'hello', + baselineTailMessageId: null, + submittedAt: 20, + expectedDigest: mobileNativeChatPromptDigest('hello'), + baseline: { streamId: 'stream', sequence: 2, digest: 'old', receivedAt: 10 }, + deadline: null + } + expect( + mobileNativeChatPromptWasAcknowledged(check, [ + { streamId: 'stream', sequence: 2, digest: check.expectedDigest, receivedAt: 21 } + ]) + ).toBe(false) + expect( + mobileNativeChatPromptWasAcknowledged(check, [ + { streamId: 'stream', sequence: 3, digest: check.expectedDigest, receivedAt: 21 } + ]) + ).toBe(true) + }) + + it('accepts only a newer host occurrence from a newly observed stream', () => { + const check: MobileNativeChatDeliveryCheck = { + pendingId: 'pending-1', + draftKey: 'draft', + pendingKey: 'pending', + text: 'hello', + normalizedText: 'hello', + baselineTailMessageId: null, + submittedAt: 9_000, + expectedDigest: mobileNativeChatPromptDigest('hello'), + baseline: { streamId: 'old-stream', sequence: 8, digest: 'old', receivedAt: 8_000 }, + deadline: null + } + + expect( + mobileNativeChatPromptWasAcknowledged(check, [ + { + streamId: 'new-stream', + sequence: 1, + digest: check.expectedDigest, + receivedAt: 100 + } + ]) + ).toBe(false) + expect( + mobileNativeChatPromptWasAcknowledged(check, [ + { + streamId: 'new-stream', + sequence: 2, + digest: check.expectedDigest, + receivedAt: 8_001 + } + ]) + ).toBe(true) + }) + + it('assigns one occurrence to only one repeated pending send', () => { + const digest = mobileNativeChatPromptDigest('repeat') + const base = { + draftKey: 'draft', + pendingKey: 'pending', + text: 'repeat', + normalizedText: 'repeat', + baselineTailMessageId: null, + submittedAt: 20, + expectedDigest: digest, + deadline: null + } + const occurrence = { + streamId: 'stream', + sequence: 1, + digest, + receivedAt: 21 + } + + expect([ + ...assignMobileNativeChatPromptSubmissions( + [ + { ...base, pendingId: 'first' }, + { ...base, pendingId: 'second' } + ], + [occurrence] + ).keys() + ]).toEqual(['first']) + expect([ + ...assignMobileNativeChatPromptSubmissions( + [ + { ...base, pendingId: 'first', acknowledgedBy: occurrence }, + { ...base, pendingId: 'second' } + ], + [occurrence] + ).keys() + ]).toEqual(['first']) + }) +}) diff --git a/mobile/src/session/mobile-native-chat-prompt-delivery.ts b/mobile/src/session/mobile-native-chat-prompt-delivery.ts new file mode 100644 index 000000000000..ed143e29d037 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-prompt-delivery.ts @@ -0,0 +1,76 @@ +import { sha256 } from '@noble/hashes/sha256' +import type { AgentPromptSubmissionOccurrence } from '../../../src/shared/agent-status-types' + +export const MOBILE_NATIVE_CHAT_DELIVERY_CONFIRMATION_MS = 8_000 + +export type MobileNativeChatDeliveryCheck = { + pendingId: string + draftKey: string + pendingKey: string | null + text: string + normalizedText: string + baselineTailMessageId: string | null + expectedDigest: string + baseline?: AgentPromptSubmissionOccurrence + deadline: ReturnType | null + acknowledgedBy?: AgentPromptSubmissionOccurrence +} + +function bytesToHex(bytes: Uint8Array): string { + return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('') +} + +export function mobileNativeChatPromptDigest(text: string): string { + return `sha256:${bytesToHex(sha256(new TextEncoder().encode(text)))}` +} + +export function mobileNativeChatPromptWasAcknowledged( + check: MobileNativeChatDeliveryCheck, + occurrences: readonly AgentPromptSubmissionOccurrence[] +): boolean { + return occurrences.some((occurrence) => { + if (occurrence.digest !== check.expectedDigest) { + return false + } + if (!check.baseline) { + return true + } + if (occurrence.streamId === check.baseline.streamId) { + return occurrence.sequence > check.baseline.sequence + } + return occurrence.receivedAt > check.baseline.receivedAt + }) +} + +function occurrenceKey(occurrence: AgentPromptSubmissionOccurrence): string { + return `${occurrence.streamId}\0${occurrence.sequence}` +} + +/** Assign each hook occurrence to at most one pending send. */ +export function assignMobileNativeChatPromptSubmissions( + checks: readonly MobileNativeChatDeliveryCheck[], + occurrences: readonly AgentPromptSubmissionOccurrence[] +): Map { + const assignments = new Map() + const claimed = new Set() + for (const check of checks) { + if (check.acknowledgedBy) { + assignments.set(check.pendingId, check.acknowledgedBy) + claimed.add(occurrenceKey(check.acknowledgedBy)) + } + } + for (const check of checks) { + if (check.acknowledgedBy) { + continue + } + const occurrence = occurrences.find( + (item) => + !claimed.has(occurrenceKey(item)) && mobileNativeChatPromptWasAcknowledged(check, [item]) + ) + if (occurrence) { + assignments.set(check.pendingId, occurrence) + claimed.add(occurrenceKey(occurrence)) + } + } + return assignments +} diff --git a/mobile/src/session/mobile-native-chat-send.test.ts b/mobile/src/session/mobile-native-chat-send.test.ts index 6a5490a1e03f..ccdacfd7b3e1 100644 --- a/mobile/src/session/mobile-native-chat-send.test.ts +++ b/mobile/src/session/mobile-native-chat-send.test.ts @@ -61,39 +61,6 @@ describe('sendMobileNativeChatMessage', () => { ).resolves.toBe(false) }) - it('reports an interactive prompt rejection for guarded native-chat sends', async () => { - const client = clientWithResponse({ - id: 'request', - ok: true, - result: { - send: { - accepted: false, - refusedReason: 'interactive-prompt' - } - }, - _meta: { runtimeId: 'runtime' } - }) - - await expect( - sendMobileNativeChatMessageWithOutcome({ - client, - terminal: 'term', - text: 'hello', - guardInteractivePrompt: true - }) - ).resolves.toBe('interactive-prompt') - expect(client.sendRequest).toHaveBeenCalledWith( - 'terminal.send', - { - terminal: 'term', - text: 'hello', - enter: true, - nativeChat: true - }, - { timeoutMs: MOBILE_NATIVE_CHAT_SEND_TIMEOUT_MS, budgetSpansConnect: true } - ) - }) - it('returns false when the RPC fails', async () => { const client = { sendRequest: vi.fn().mockRejectedValue(new Error('disconnected')) @@ -340,7 +307,7 @@ describe('clearMobileNativeChatInput', () => { const clearInput = buildAgentTuiClearInputForText('Linked Linear issue: ABC-123\nhttps://x') await expect( clearMobileNativeChatInput({ client, terminal: 'term', clearInput }) - ).resolves.toBe('accepted') + ).resolves.toBe(true) expect(params(client)).toMatchObject({ text: clearInput, enter: false }) }) @@ -353,26 +320,7 @@ describe('clearMobileNativeChatInput', () => { }) await expect( clearMobileNativeChatInput({ client, terminal: 'term', clearInput: '\x15' }) - ).resolves.toBe('rejected') - }) - - it('preserves an interactive prompt rejection from a guarded clear', async () => { - const client = clientWithResponse({ - id: 'request', - ok: true, - result: { send: { accepted: false, refusedReason: 'interactive-prompt' } }, - _meta: { runtimeId: 'runtime' } - }) - - await expect( - clearMobileNativeChatInput({ - client, - terminal: 'term', - clearInput: '\x15', - guardInteractivePrompt: true - }) - ).resolves.toBe('interactive-prompt') - expect(params(client)).toMatchObject({ nativeChat: true }) + ).resolves.toBe(false) }) it('refuses to start an underfunded clear rather than half-clearing', async () => { @@ -384,7 +332,7 @@ describe('clearMobileNativeChatInput', () => { clearInput: '\x15', deadline: Date.now() + 10 }) - ).resolves.toBe('rejected') + ).resolves.toBe(false) expect(client.sendRequest).not.toHaveBeenCalled() }) }) diff --git a/mobile/src/session/mobile-native-chat-send.ts b/mobile/src/session/mobile-native-chat-send.ts index 28c3f247cf25..ce432c3efb9f 100644 --- a/mobile/src/session/mobile-native-chat-send.ts +++ b/mobile/src/session/mobile-native-chat-send.ts @@ -1,10 +1,7 @@ import type { RpcClient } from '../transport/rpc-client' import { isRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity' import { isLogicalClientCutoverError } from '../transport/stable-logical-rpc-client' -import { - getTerminalSendRpcRefusedReason, - isTerminalSendRpcAccepted -} from '../terminal/terminal-send-rpc-response' +import { isTerminalSendRpcAccepted } from '../terminal/terminal-send-rpc-response' type MobileTerminalClient = { id: string @@ -27,7 +24,6 @@ type MobileNativeChatSendArgs = { text: string enter?: boolean clearInputFirst?: boolean - guardInteractivePrompt?: boolean /** Exact host launch draft this submitting write resolves when accepted. */ resolvedLaunchDraft?: { text: string; createdAt: number } mobileClient?: MobileTerminalClient @@ -39,8 +35,7 @@ type MobileNativeChatSendArgs = { /** 'unknown' = the RPC failed without proof the request never reached the * desktop (ack loss after a write, or a cutover that cannot tell whether the * frame was written) — callers must not present it as a definite send failure. */ -export type MobileNativeChatSendOutcome = 'accepted' | 'rejected' | 'unknown' | 'interactive-prompt' -export type MobileNativeChatClearOutcome = Exclude +export type MobileNativeChatSendOutcome = 'accepted' | 'rejected' | 'unknown' /** Without an explicit timeout `sendRequest` waits for reconnect indefinitely, and * the composer holds `sending` (send arrow dimmed, no error) for as long as it @@ -71,7 +66,6 @@ export async function sendMobileNativeChatMessageWithOutcome( terminal: args.terminal, text: args.clearInputFirst ? `${CLEAR_UNSUBMITTED_INPUT}${args.text}` : args.text, enter: args.enter ?? true, - ...(args.guardInteractivePrompt ? { nativeChat: true } : {}), ...(args.resolvedLaunchDraft ? { resolvedLaunchDraft: args.resolvedLaunchDraft } : {}), ...(args.mobileClient ? { client: args.mobileClient } : {}) }, @@ -80,12 +74,7 @@ export async function sendMobileNativeChatMessageWithOutcome( // pins the composer for twice as long. { timeoutMs, budgetSpansConnect: true } ) - if (isTerminalSendRpcAccepted(response)) { - return 'accepted' - } - return getTerminalSendRpcRefusedReason(response) === 'interactive-prompt' - ? 'interactive-prompt' - : 'rejected' + return isTerminalSendRpcAccepted(response) ? 'accepted' : 'rejected' } catch (error) { // Why: a logical relay↔direct cutover rejects the in-flight send without // knowing whether its frame reached the wire (the desktop may have delivered @@ -117,14 +106,13 @@ export async function clearMobileNativeChatInput(args: { client: RpcClient terminal: string clearInput: string - guardInteractivePrompt?: boolean mobileClient?: MobileTerminalClient deadline?: number -}): Promise { +}): Promise { const timeoutMs = args.deadline === undefined ? MOBILE_NATIVE_CHAT_SEND_TIMEOUT_MS : args.deadline - Date.now() if (timeoutMs < MOBILE_NATIVE_CHAT_MIN_WRITE_TIMEOUT_MS) { - return 'rejected' + return false } try { const response = await args.client.sendRequest( @@ -133,19 +121,13 @@ export async function clearMobileNativeChatInput(args: { terminal: args.terminal, text: args.clearInput, enter: false, - ...(args.guardInteractivePrompt ? { nativeChat: true } : {}), ...(args.mobileClient ? { client: args.mobileClient } : {}) }, { timeoutMs, budgetSpansConnect: true } ) - if (isTerminalSendRpcAccepted(response)) { - return 'accepted' - } - return getTerminalSendRpcRefusedReason(response) === 'interactive-prompt' - ? 'interactive-prompt' - : 'rejected' + return isTerminalSendRpcAccepted(response) } catch { // A failed clear must not send the body on top of an uncleared line. - return 'rejected' + return false } } diff --git a/mobile/src/session/mobile-native-chat-view-styles.ts b/mobile/src/session/mobile-native-chat-view-styles.ts index 160477cfad80..ad3de7d3f58e 100644 --- a/mobile/src/session/mobile-native-chat-view-styles.ts +++ b/mobile/src/session/mobile-native-chat-view-styles.ts @@ -39,6 +39,54 @@ export const styles = StyleSheet.create({ fontSize: typography.metaSize, fontWeight: '600' }, + deliveryRecovery: { + gap: spacing.sm, + marginHorizontal: spacing.md, + marginBottom: spacing.sm, + padding: spacing.md, + borderRadius: 12, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.borderSubtle, + backgroundColor: colors.bgRaised + }, + deliveryRecoveryHeader: { + flexDirection: 'row', + alignItems: 'flex-start', + gap: spacing.sm + }, + deliveryRecoveryIcon: { + width: 34, + height: 34, + alignItems: 'center', + justifyContent: 'center', + borderRadius: 8, + backgroundColor: colors.bgPanel + }, + deliveryRecoveryCopy: { + flex: 1, + gap: 2 + }, + deliveryRecoveryTitle: { + color: colors.textPrimary, + fontSize: typography.bodySize, + fontWeight: '700' + }, + deliveryRecoveryDescription: { + color: colors.textMuted, + fontSize: typography.metaSize, + lineHeight: 16 + }, + deliveryRecoveryButton: { + paddingHorizontal: spacing.sm, + paddingVertical: spacing.sm, + borderRadius: 8, + backgroundColor: colors.surfaceBright + }, + deliveryRecoveryButtonText: { + color: colors.bgBase, + fontSize: typography.metaSize, + fontWeight: '700' + }, chromeToggle: { flexDirection: 'row', alignItems: 'center', diff --git a/mobile/src/session/use-mobile-native-chat-controller.ts b/mobile/src/session/use-mobile-native-chat-controller.ts index 46012dadbe41..473a523869c3 100644 --- a/mobile/src/session/use-mobile-native-chat-controller.ts +++ b/mobile/src/session/use-mobile-native-chat-controller.ts @@ -98,7 +98,8 @@ export function useMobileNativeChatController(args: { clearDraftForSend, restoreRejectedDraft, acceptSend, - holdUnconfirmedSend + holdUnconfirmedSend, + deliveryFailed } = useMobileNativeChatDrafts({ hostId, worktreeId, @@ -111,7 +112,8 @@ export function useMobileNativeChatController(args: { // a null is indistinguishable from a host retraction, and peeking at the // terminal view would permanently decline the prefill. chatActive: showNativeChat, - transcriptLoading: nativeChatSession.transcriptLoading + transcriptLoading: nativeChatSession.transcriptLoading, + promptSubmissions: activeSessionTab?.agentStatus?.promptSubmissions }) const nativeChatStatus = activeChatResolution ? activeSessionTab?.agentStatus : null @@ -260,6 +262,8 @@ export function useMobileNativeChatController(args: { setChatComposerText, chatPending, chatImagePreviewsByMessageId, + nativeChatDeliveryFailed: deliveryFailed, + showNativeChatTerminal: handleAgentPicker, nativeChatSession, nativeChatAgentWorking, nativeChatStreamingText, diff --git a/mobile/src/session/use-mobile-native-chat-delivery-recovery.test.ts b/mobile/src/session/use-mobile-native-chat-delivery-recovery.test.ts new file mode 100644 index 000000000000..c1cf833868f3 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-delivery-recovery.test.ts @@ -0,0 +1,139 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AgentPromptSubmissionOccurrence } from '../../../src/shared/agent-status-types' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { mobileNativeChatPromptDigest } from './mobile-native-chat-prompt-delivery' +import { useMobileNativeChatDrafts } from './use-mobile-native-chat-drafts' + +type DraftState = ReturnType + +describe('mobile native chat delivery recovery', () => { + let renderer: ReactTestRenderer | null = null + let state: DraftState | null = null + + function Harness({ + messages = [], + promptSubmissions = [] + }: { + messages?: NativeChatMessage[] + promptSubmissions?: AgentPromptSubmissionOccurrence[] + }): null { + state = useMobileNativeChatDrafts({ + hostId: 'host', + worktreeId: 'worktree', + tabId: 'tab', + sessionId: 'session', + messages, + promptSubmissions + }) + return null + } + + async function render(props: Parameters[0] = {}): Promise { + await act(async () => { + const element = createElement(Harness, props) + if (renderer) { + renderer.update(element) + } else { + renderer = create(element) + } + }) + } + + beforeEach(async () => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + vi.useFakeTimers() + const original = console.error + const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { + if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { + return + } + original(...args) + }) + try { + await render() + } finally { + consoleSpy.mockRestore() + } + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + state = null + vi.useRealTimers() + }) + + it('removes an unacknowledged echo and restores it ahead of newer edits', () => { + act(() => state?.setComposerText('ping')) + const origin = state?.captureSendOrigin('ping') + act(() => { + if (origin) { + state?.clearDraftForSend(origin, 'ping') + state?.acceptSend(origin, 'ping') + } + state?.setComposerText('newer edit') + vi.advanceTimersByTime(8_001) + }) + + expect(state?.pending).toEqual([]) + expect(state?.composerText).toBe('ping\n\nnewer edit') + expect(state?.deliveryFailed).toBe(true) + }) + + it('keeps an acknowledged echo pending until the transcript replaces it', async () => { + const origin = state?.captureSendOrigin('ping') + act(() => { + if (origin) { + state?.acceptSend(origin, 'ping') + } + }) + await render({ + promptSubmissions: [ + { + streamId: 'stream', + sequence: 1, + digest: mobileNativeChatPromptDigest('ping'), + receivedAt: 1 + } + ] + }) + + act(() => vi.advanceTimersByTime(8_001)) + + expect(state?.pending.map((entry) => entry.text)).toEqual(['ping']) + expect(state?.deliveryFailed).toBe(false) + }) + + it('uses the pre-dispatch baseline when the acknowledgement beats the RPC', async () => { + const baseline = { + streamId: 'stream', + sequence: 1, + digest: mobileNativeChatPromptDigest('older'), + receivedAt: 1 + } + await render({ promptSubmissions: [baseline] }) + const origin = state?.captureSendOrigin('ping') + await render({ + promptSubmissions: [ + baseline, + { + streamId: 'stream', + sequence: 2, + digest: mobileNativeChatPromptDigest('ping'), + receivedAt: 2 + } + ] + }) + act(() => { + if (origin) { + state?.acceptSend(origin, 'ping') + } + vi.advanceTimersByTime(8_001) + }) + + expect(state?.pending.map((entry) => entry.text)).toEqual(['ping']) + expect(state?.deliveryFailed).toBe(false) + }) +}) diff --git a/mobile/src/session/use-mobile-native-chat-delivery-recovery.ts b/mobile/src/session/use-mobile-native-chat-delivery-recovery.ts new file mode 100644 index 000000000000..8728942d40fe --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-delivery-recovery.ts @@ -0,0 +1,207 @@ +import { useCallback, useEffect, useRef, useState, type Dispatch, type SetStateAction } from 'react' +import type { AgentPromptSubmissionOccurrence } from '../../../src/shared/agent-status-types' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { + assignMobileNativeChatPromptSubmissions, + MOBILE_NATIVE_CHAT_DELIVERY_CONFIRMATION_MS, + mobileNativeChatPromptDigest, + type MobileNativeChatDeliveryCheck +} from './mobile-native-chat-prompt-delivery' +import type { + MobileNativeChatPendingMessage, + MobileNativeChatSendOrigin +} from './mobile-native-chat-pending-echo' +import { appendMobileNativeChatPending } from './mobile-native-chat-pending-echo' +import { findLandedUnconfirmedSends } from './mobile-native-chat-draft-reconcile' + +type PendingByKey = Record + +type DeliveryRecoveryArgs = { + draftKey: string | null + pendingKey: string | null + messages: readonly NativeChatMessage[] + promptSubmissions: readonly AgentPromptSubmissionOccurrence[] + setDrafts: Dispatch>> + setPendingBySession: Dispatch> + setPendingWaitingForSession: Dispatch> +} + +function occurrenceKey(occurrence: AgentPromptSubmissionOccurrence): string { + return `${occurrence.streamId}\0${occurrence.sequence}` +} + +function removePendingCheck( + previous: PendingByKey, + check: MobileNativeChatDeliveryCheck +): PendingByKey { + const key = check.pendingKey ?? check.draftKey + const current = previous[key] ?? [] + const next = current.filter((entry) => entry.id !== check.pendingId) + if (next.length === current.length) { + return previous + } + if (next.length > 0) { + return { ...previous, [key]: next } + } + const remaining = { ...previous } + delete remaining[key] + return remaining +} + +export function useMobileNativeChatDeliveryRecovery({ + draftKey, + pendingKey, + messages, + promptSubmissions, + setDrafts, + setPendingBySession, + setPendingWaitingForSession +}: DeliveryRecoveryArgs): { + deliveryFailed: boolean + acceptSend: (origin: MobileNativeChatSendOrigin, text: string, images?: string[]) => void + readPromptSubmissionBaseline: () => AgentPromptSubmissionOccurrence | undefined +} { + const activeDraftKeyRef = useRef(draftKey) + activeDraftKeyRef.current = draftKey + const activePendingKeyRef = useRef(pendingKey) + activePendingKeyRef.current = pendingKey + const messagesRef = useRef(messages) + messagesRef.current = messages + const promptSubmissionsRef = useRef(promptSubmissions) + promptSubmissionsRef.current = promptSubmissions + const pendingCounterRef = useRef(0) + const deliveryChecksRef = useRef([]) + const [failureDraftKey, setFailureDraftKey] = useState(null) + + const readPromptSubmissionBaseline = useCallback(() => promptSubmissionsRef.current.at(-1), []) + + const trackAcceptedTextSend = useCallback( + (pendingId: string, origin: MobileNativeChatSendOrigin, text: string) => { + const check: MobileNativeChatDeliveryCheck = { + pendingId, + draftKey: origin.draftKey, + pendingKey: origin.pendingKey, + text, + normalizedText: origin.normalizedText, + baselineTailMessageId: origin.baselineTailMessageId, + expectedDigest: mobileNativeChatPromptDigest(text), + ...(origin.promptSubmissionBaseline ? { baseline: origin.promptSubmissionBaseline } : {}), + deadline: null + } + check.deadline = setTimeout(() => { + const isActiveTranscript = + activeDraftKeyRef.current === check.draftKey && + (check.pendingKey === null || activePendingKeyRef.current === check.pendingKey) + const transcriptLanded = + isActiveTranscript && findLandedUnconfirmedSends(messagesRef.current, [check]).length > 0 + if (transcriptLanded) { + deliveryChecksRef.current = deliveryChecksRef.current.filter((held) => held !== check) + return + } + const acknowledgedBy = assignMobileNativeChatPromptSubmissions( + deliveryChecksRef.current, + promptSubmissionsRef.current + ).get(check.pendingId) + if (acknowledgedBy) { + check.deadline = null + check.acknowledgedBy = acknowledgedBy + return + } + deliveryChecksRef.current = deliveryChecksRef.current.filter((held) => held !== check) + if (check.pendingKey) { + setPendingBySession((previous) => removePendingCheck(previous, check)) + } else { + setPendingWaitingForSession((previous) => removePendingCheck(previous, check)) + } + setDrafts((previous) => { + const current = previous[check.draftKey] ?? '' + if (current === check.text) { + return previous + } + const restored = current.length === 0 ? check.text : `${check.text}\n\n${current}` + return { ...previous, [check.draftKey]: restored } + }) + setFailureDraftKey(check.draftKey) + }, MOBILE_NATIVE_CHAT_DELIVERY_CONFIRMATION_MS) + deliveryChecksRef.current = [...deliveryChecksRef.current, check] + }, + [setDrafts, setPendingBySession, setPendingWaitingForSession] + ) + + const acceptSend = useCallback( + (origin: MobileNativeChatSendOrigin, text: string, images?: string[]) => { + pendingCounterRef.current += 1 + const id = `pending-${pendingCounterRef.current}` + setFailureDraftKey(null) + if (origin.pendingKey) { + const key = origin.pendingKey + setPendingBySession((previous) => + appendMobileNativeChatPending(previous, key, id, origin, text, images) + ) + } else if (images?.length) { + setPendingWaitingForSession((previous) => + appendMobileNativeChatPending(previous, origin.draftKey, id, origin, text, images) + ) + } + if (!images?.length && text.trim().length > 0) { + trackAcceptedTextSend(id, origin, text) + } + }, + [setPendingBySession, setPendingWaitingForSession, trackAcceptedTextSend] + ) + + useEffect(() => { + const assignments = assignMobileNativeChatPromptSubmissions( + deliveryChecksRef.current, + promptSubmissions + ) + for (const check of deliveryChecksRef.current) { + const acknowledgedBy = assignments.get(check.pendingId) + if (!check.acknowledgedBy && acknowledgedBy) { + clearTimeout(check.deadline ?? undefined) + check.deadline = null + check.acknowledgedBy = acknowledgedBy + } + } + const visibleOccurrences = new Set(promptSubmissions.map(occurrenceKey)) + deliveryChecksRef.current = deliveryChecksRef.current.filter( + (check) => + !check.acknowledgedBy || visibleOccurrences.has(occurrenceKey(check.acknowledgedBy)) + ) + }, [promptSubmissions]) + + useEffect(() => { + if (!draftKey) { + return + } + const relevant = deliveryChecksRef.current.filter( + (check) => + check.draftKey === draftKey && + (check.pendingKey === null || check.pendingKey === pendingKey) + ) + const landed = new Set(findLandedUnconfirmedSends(messages, relevant)) + if (landed.size === 0) { + return + } + deliveryChecksRef.current = deliveryChecksRef.current.filter((check) => !landed.has(check)) + for (const check of landed) { + clearTimeout(check.deadline ?? undefined) + } + }, [draftKey, messages, pendingKey]) + + useEffect( + () => () => { + for (const check of deliveryChecksRef.current) { + clearTimeout(check.deadline ?? undefined) + } + deliveryChecksRef.current = [] + }, + [] + ) + + return { + deliveryFailed: failureDraftKey === draftKey, + acceptSend, + readPromptSubmissionBaseline + } +} diff --git a/mobile/src/session/use-mobile-native-chat-drafts.ts b/mobile/src/session/use-mobile-native-chat-drafts.ts index 9da4dea6a9ba..97a1a7cd4241 100644 --- a/mobile/src/session/use-mobile-native-chat-drafts.ts +++ b/mobile/src/session/use-mobile-native-chat-drafts.ts @@ -1,5 +1,6 @@ import { useCallback, useEffect, useRef, useState, type Dispatch, type SetStateAction } from 'react' import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import type { AgentPromptSubmissionOccurrence } from '../../../src/shared/agent-status-types' import { countImageSourceTurnsAfter, countUserTextOccurrences, @@ -11,7 +12,6 @@ import { type UnconfirmedSend } from './mobile-native-chat-draft-reconcile' import { - appendMobileNativeChatPending, combineMobileNativeChatPending, mergeWaitingSessionPending, removeWaitingSessionPending, @@ -21,6 +21,7 @@ import { import { mobileNativeChatScopeKey } from './mobile-native-chat-scope-key' import { useMobileNativeChatLaunchDraftSeed } from './use-mobile-native-chat-launch-draft-seed' import type { MobileNativeChatLaunchDraftSeed } from './use-mobile-native-chat-launch-draft-seed' +import { useMobileNativeChatDeliveryRecovery } from './use-mobile-native-chat-delivery-recovery' export type { MobileNativeChatPendingMessage, MobileNativeChatSendOrigin } @@ -47,6 +48,7 @@ export function useMobileNativeChatDrafts(args: { * transcript still belongs to the previously active tab), so it cannot be * trusted to decline or retire the seed. */ transcriptLoading?: boolean + promptSubmissions?: readonly AgentPromptSubmissionOccurrence[] }): { composerText: string setComposerText: Dispatch> @@ -70,6 +72,7 @@ export function useMobileNativeChatDrafts(args: { text: string, onUnconfirmed: () => void ) => void + deliveryFailed: boolean } { const { hostId, @@ -80,7 +83,8 @@ export function useMobileNativeChatDrafts(args: { launchDraft, launchDraftCreatedAt, chatActive = true, - transcriptLoading + transcriptLoading, + promptSubmissions = [] } = args const draftKey = mobileNativeChatScopeKey(hostId, worktreeId, tabId) const pendingKey = draftKey && sessionId ? `${draftKey}\0${sessionId}` : null @@ -94,7 +98,6 @@ export function useMobileNativeChatDrafts(args: { const [imagePreviewsBySession, setImagePreviewsBySession] = useState< Record> >({}) - const pendingCounterRef = useRef(0) const messagesRef = useRef(messages) messagesRef.current = messages const activeDraftKeyRef = useRef(draftKey) @@ -103,6 +106,17 @@ export function useMobileNativeChatDrafts(args: { activePendingKeyRef.current = pendingKey const mountedRef = useRef(false) + const { deliveryFailed, acceptSend, readPromptSubmissionBaseline } = + useMobileNativeChatDeliveryRecovery({ + draftKey, + pendingKey, + messages, + promptSubmissions, + setDrafts, + setPendingBySession, + setPendingWaitingForSession + }) + const { readSeededLaunchDraft, readSeededLaunchDraftSeed } = useMobileNativeChatLaunchDraftSeed({ draftKey, messages, @@ -134,15 +148,17 @@ export function useMobileNativeChatDrafts(args: { } const normalizedText = text.trim() const currentMessages = messagesRef.current + const promptSubmissionBaseline = readPromptSubmissionBaseline() return { draftKey, pendingKey, normalizedText, baselineOccurrences: countUserTextOccurrences(currentMessages, normalizedText), - baselineTailMessageId: currentMessages[currentMessages.length - 1]?.id ?? null + baselineTailMessageId: currentMessages[currentMessages.length - 1]?.id ?? null, + ...(promptSubmissionBaseline ? { promptSubmissionBaseline } : {}) } }, - [draftKey, pendingKey] + [draftKey, pendingKey, readPromptSubmissionBaseline] ) // Why: over relay the send RPC can take seconds (or lose only its ack), and a @@ -163,27 +179,6 @@ export function useMobileNativeChatDrafts(args: { ) }, []) - const acceptSend = useCallback( - (origin: MobileNativeChatSendOrigin, text: string, images?: string[]) => { - if (!origin.pendingKey && !images?.length) { - return - } - pendingCounterRef.current += 1 - const id = `pending-${pendingCounterRef.current}` - const key = origin.pendingKey - if (key) { - setPendingBySession((previous) => - appendMobileNativeChatPending(previous, key, id, origin, text, images) - ) - } else { - setPendingWaitingForSession((previous) => - appendMobileNativeChatPending(previous, origin.draftKey, id, origin, text, images) - ) - } - }, - [] - ) - // Why: a relay drop mid-send loses only the ack in the common case — the // desktop already delivered the message. Hold the send instead of claiming // failure (which baits a duplicate): stay quiet when the transcript echo @@ -345,6 +340,7 @@ export function useMobileNativeChatDrafts(args: { clearDraftForSend, restoreRejectedDraft, acceptSend, - holdUnconfirmedSend + holdUnconfirmedSend, + deliveryFailed } } diff --git a/mobile/src/session/use-mobile-native-chat-message-send.test.ts b/mobile/src/session/use-mobile-native-chat-message-send.test.ts index a0473b734898..0ceda5714daa 100644 --- a/mobile/src/session/use-mobile-native-chat-message-send.test.ts +++ b/mobile/src/session/use-mobile-native-chat-message-send.test.ts @@ -35,7 +35,6 @@ describe('useMobileNativeChatMessageSend', () => { let api: Send | null = null const acceptSend = vi.fn() const holdUnconfirmedSend = vi.fn() - const restoreRejectedDraft = vi.fn() const onCommandSend = vi.fn() const commandSendRef = { current: onCommandSend } const agentRef = { current: null as string | null } @@ -57,7 +56,7 @@ describe('useMobileNativeChatMessageSend', () => { captureSendOrigin: () => ({ draftKey: 'k', pendingKey: 'p' }) as never, readSeededLaunchDraftSeed, clearDraftForSend: () => {}, - restoreRejectedDraft, + restoreRejectedDraft: () => {}, acceptSend, holdUnconfirmedSend, onSendError @@ -71,29 +70,23 @@ describe('useMobileNativeChatMessageSend', () => { const sentArgs = (): { clearInputFirst?: boolean - guardInteractivePrompt?: boolean resolvedLaunchDraft?: { text: string; createdAt: number } } => sendWithOutcome.mock.calls[0]![0] as { clearInputFirst?: boolean - guardInteractivePrompt?: boolean resolvedLaunchDraft?: { text: string; createdAt: number } } - const clearArgs = (): { clearInput?: string; guardInteractivePrompt?: boolean } => - (clearInputWrite.mock.calls[0]?.[0] ?? {}) as { - clearInput?: string - guardInteractivePrompt?: boolean - } + const clearArgs = (): { clearInput?: string } => + (clearInputWrite.mock.calls[0]?.[0] ?? {}) as { clearInput?: string } beforeEach(() => { globalThis.IS_REACT_ACT_ENVIRONMENT = true sendWithOutcome.mockReset() sendWithOutcome.mockResolvedValue('accepted') clearInputWrite.mockReset() - clearInputWrite.mockResolvedValue('accepted') + clearInputWrite.mockResolvedValue(true) acceptSend.mockReset() holdUnconfirmedSend.mockReset() - restoreRejectedDraft.mockReset() onCommandSend.mockReset() commandSendRef.current = onCommandSend onSendError = vi.fn() @@ -113,7 +106,6 @@ describe('useMobileNativeChatMessageSend', () => { await api!.send('hello') }) expect(clearArgs().clearInput).toBe(buildAgentTuiClearInputForText(DRAFT)) - expect(clearArgs().guardInteractivePrompt).toBe(true) }) it('issues the burst as its OWN write, before the body', async () => { @@ -131,7 +123,7 @@ describe('useMobileNativeChatMessageSend', () => { it('aborts without sending the body when the clear is rejected', async () => { // Sending on top of an uncleared line is exactly the concatenation bug. - clearInputWrite.mockResolvedValue('rejected') + clearInputWrite.mockResolvedValue(false) mount(() => ({ text: DRAFT, createdAt: 1 })) let result: boolean | undefined await act(async () => { @@ -141,22 +133,6 @@ describe('useMobileNativeChatMessageSend', () => { expect(sendWithOutcome).not.toHaveBeenCalled() }) - it('reports an interactive prompt rejected while clearing a seeded launch draft', async () => { - clearInputWrite.mockResolvedValue('interactive-prompt') - mount(() => ({ text: DRAFT, createdAt: 1 })) - - let result: boolean | undefined - await act(async () => { - result = await api!.send('hello') - }) - - expect(result).toBe(false) - expect(restoreRejectedDraft).toHaveBeenCalledTimes(1) - expect(sendWithOutcome).not.toHaveBeenCalled() - expect(acceptSend).not.toHaveBeenCalled() - expect(onSendError).toHaveBeenCalledWith('Finish the terminal prompt before sending') - }) - it('drops the body write\u2019s own Ctrl+U prefix once the dedicated clear ran', async () => { // A Ctrl+U written immediately before body text in the SAME write arrives as // a literal control character, so it would head the received message. @@ -217,7 +193,6 @@ describe('useMobileNativeChatMessageSend', () => { await api!.answerQuestion('1') }) expect(sentArgs().resolvedLaunchDraft).toBeUndefined() - expect(sentArgs().guardInteractivePrompt).toBe(false) }) it('creates an optimistic echo for an ordinary chat send', async () => { @@ -226,25 +201,9 @@ describe('useMobileNativeChatMessageSend', () => { await api!.send('hello') }) expect(acceptSend).toHaveBeenCalledTimes(1) - expect(sentArgs().guardInteractivePrompt).toBe(true) expect(onCommandSend).not.toHaveBeenCalled() }) - it('restores the draft without an optimistic echo when the host finds a prompt', async () => { - sendWithOutcome.mockResolvedValue('interactive-prompt') - mount(() => null) - - let accepted = true - await act(async () => { - accepted = await api!.send('hello') - }) - - expect(accepted).toBe(false) - expect(restoreRejectedDraft).toHaveBeenCalledTimes(1) - expect(acceptSend).not.toHaveBeenCalled() - expect(onSendError).toHaveBeenCalledWith('Finish the terminal prompt before sending') - }) - // The STA-3332 "Queued forever" regression: command sends dispatch into the // agent's TUI and never echo as user turns, so they must not create a pending // bubble that no transcript match can ever retire. @@ -254,7 +213,6 @@ describe('useMobileNativeChatMessageSend', () => { await api!.send('/clear') }) expect(acceptSend).not.toHaveBeenCalled() - expect(sentArgs().guardInteractivePrompt).toBe(false) expect(onCommandSend).toHaveBeenCalledWith('/clear') }) diff --git a/mobile/src/session/use-mobile-native-chat-message-send.ts b/mobile/src/session/use-mobile-native-chat-message-send.ts index bd06d598c7ad..1d59e25431cb 100644 --- a/mobile/src/session/use-mobile-native-chat-message-send.ts +++ b/mobile/src/session/use-mobile-native-chat-message-send.ts @@ -89,8 +89,6 @@ export function useMobileNativeChatMessageSend(args: { const origin = captureSendOrigin(text) const agent = agentRef.current const recordCommand = commandSendRef.current - const classification = classifyMobileNativeChatSend(agent, text) - const guardInteractivePrompt = syncComposer && classification === 'chat' // Why: the lease collapses one render after `connState`, so a question-card // answer (which reaches this send directly) would otherwise burn the whole // 15s heal+send budget waiting on a socket that is already gone. @@ -135,25 +133,20 @@ export function useMobileNativeChatMessageSend(args: { // than pasting on top of an uncleared line. const seededLaunchDraft = readSeededLaunchDraftSeed() if (seededLaunchDraft && !images?.length) { - const clearOutcome = await clearMobileNativeChatInput({ + const cleared = await clearMobileNativeChatInput({ client, terminal: handle, clearInput: buildAgentTuiClearInputForText(seededLaunchDraft.text), - guardInteractivePrompt, deadline, ...(deviceTokenRef.current ? { mobileClient: { id: deviceTokenRef.current, type: 'mobile' } } : {}) }) - if (clearOutcome !== 'accepted') { + if (!cleared) { if (syncComposer) { restoreRejectedDraft(origin, text) } - onSendError( - clearOutcome === 'interactive-prompt' - ? 'Finish the terminal prompt before sending' - : 'Message not sent' - ) + onSendError('Message not sent') return 'rejected' } } @@ -161,7 +154,6 @@ export function useMobileNativeChatMessageSend(args: { client, terminal: handle, text, - guardInteractivePrompt, // Why: pre-clear only when nothing was deliberately pasted first. The heal // above fires only for terminals a mobile image paste marked, so a desktop // launch-draft prefill parked on the input line would otherwise glue onto @@ -191,6 +183,7 @@ export function useMobileNativeChatMessageSend(args: { // TUI, not the conversation — the transcript never echoes it as a user // turn, so an optimistic bubble would sit at "Queued" forever and the // unconfirmed hold could never observe a landing. + const classification = classifyMobileNativeChatSend(agent, text) if (outcome === 'unknown') { if (classification === 'chat') { // Why: an ack-lost send usually WAS delivered (issue seen on cellular @@ -201,13 +194,6 @@ export function useMobileNativeChatMessageSend(args: { } return 'unknown' } - if (outcome === 'interactive-prompt') { - if (syncComposer) { - restoreRejectedDraft(origin, text) - } - onSendError('Finish the terminal prompt before sending') - return 'rejected' - } if (outcome === 'rejected') { if (syncComposer) { restoreRejectedDraft(origin, text) diff --git a/mobile/src/terminal/terminal-send-rpc-response.ts b/mobile/src/terminal/terminal-send-rpc-response.ts index ab1d60e03285..454c5e329727 100644 --- a/mobile/src/terminal/terminal-send-rpc-response.ts +++ b/mobile/src/terminal/terminal-send-rpc-response.ts @@ -13,12 +13,3 @@ export function isTerminalSendRpcAccepted(response: RpcResponse): boolean { } return response.result.send.accepted === true } - -export function getTerminalSendRpcRefusedReason(response: RpcResponse): string | null { - if (!response.ok || !isRecord(response.result) || !isRecord(response.result.send)) { - return null - } - return typeof response.result.send.refusedReason === 'string' - ? response.result.send.refusedReason - : null -} diff --git a/src/main/agent-hooks/hook-status-session-tabs-invalidation.test.ts b/src/main/agent-hooks/hook-status-session-tabs-invalidation.test.ts index 14235654d37b..f9d693b4b7db 100644 --- a/src/main/agent-hooks/hook-status-session-tabs-invalidation.test.ts +++ b/src/main/agent-hooks/hook-status-session-tabs-invalidation.test.ts @@ -29,6 +29,44 @@ describe('createHookStatusSessionTabsInvalidator', () => { expect(changed(working())).toBe(false) }) + it('invalidates and retains a transient prompt acknowledgement through later pings', () => { + const changed = createHookStatusSessionTabsInvalidator() + changed(working()) + const promptSubmission = { + streamId: 'stream-1', + sequence: 1, + digest: `sha256:${'a'.repeat(64)}`, + receivedAt: 100 + } + + expect(changed({ ...working(), promptSubmission })).toBe(true) + expect(changed(working())).toBe(false) + expect(changed.getPromptSubmissions('tab:leaf')).toEqual([promptSubmission]) + + changed.forgetPane('tab:leaf') + expect(changed.getPromptSubmissions('tab:leaf')).toEqual([]) + }) + + it('bounds prompt acknowledgement history per pane', () => { + const changed = createHookStatusSessionTabsInvalidator() + + for (let sequence = 1; sequence <= 10; sequence += 1) { + changed({ + ...working(), + promptSubmission: { + streamId: 'stream-1', + sequence, + digest: `sha256:${sequence.toString(16).padStart(64, '0')}`, + receivedAt: sequence + } + }) + } + + expect(changed.getPromptSubmissions('tab:leaf').map(({ sequence }) => sequence)).toEqual([ + 3, 4, 5, 6, 7, 8, 9, 10 + ]) + }) + it.each([ ['state', { state: 'waiting' as const }], ['prompt', { prompt: 'ship it' }], diff --git a/src/main/agent-hooks/hook-status-session-tabs-invalidation.ts b/src/main/agent-hooks/hook-status-session-tabs-invalidation.ts index 59b259fa8495..749d94a3645b 100644 --- a/src/main/agent-hooks/hook-status-session-tabs-invalidation.ts +++ b/src/main/agent-hooks/hook-status-session-tabs-invalidation.ts @@ -1,28 +1,58 @@ import type { AgentHookEventPayload } from '../../shared/agent-hook-listener' -import type { ParsedAgentStatusPayload } from '../../shared/agent-status-types' +import { + AGENT_PROMPT_SUBMISSION_HISTORY_MAX, + type AgentPromptSubmissionOccurrence, + type ParsedAgentStatusPayload +} from '../../shared/agent-status-types' -type KnownStatus = { connectionId: string | null; payload: ParsedAgentStatusPayload } +type KnownStatus = { + connectionId: string | null + payload: ParsedAgentStatusPayload + promptSubmissions?: AgentPromptSubmissionOccurrence[] +} + +type HookSessionTabsStatusEvent = AgentHookEventPayload & { + promptSubmission?: AgentPromptSubmissionOccurrence +} /** Reports whether a hook status event changed anything the `session.tabs` * projection publishes, so a repeated same-state ping costs no snapshot rebuild. * Mirrors `retainAgentRowSnapshot`'s change set so both carriers invalidate alike. */ export function createHookStatusSessionTabsInvalidator(): { - (event: AgentHookEventPayload): boolean + (event: HookSessionTabsStatusEvent): boolean forgetPane: (paneKey: string) => void forgetConnection: (connectionId: string) => string[] + getPromptSubmissions: (paneKey: string) => readonly AgentPromptSubmissionOccurrence[] } { const known = new Map() - const invalidator = (event: AgentHookEventPayload): boolean => { + const invalidator = (event: HookSessionTabsStatusEvent): boolean => { // Why: resume-identity rows carry transport placeholders, not status; the // provider-session invalidator owns their republish. if (event.providerSessionOnly === true) { return false } - const previous = known.get(event.paneKey)?.payload + const previousStatus = known.get(event.paneKey) + const previous = previousStatus?.payload const next = event.payload - known.set(event.paneKey, { connectionId: event.connectionId, payload: next }) + const occurrence = event.promptSubmission + const promptSubmissions = occurrence + ? [ + ...(previousStatus?.promptSubmissions ?? []).filter( + (candidate) => + candidate.streamId !== occurrence.streamId || + candidate.sequence !== occurrence.sequence + ), + occurrence + ].slice(-AGENT_PROMPT_SUBMISSION_HISTORY_MAX) + : previousStatus?.promptSubmissions + known.set(event.paneKey, { + connectionId: event.connectionId, + payload: next, + ...(promptSubmissions ? { promptSubmissions } : {}) + }) return ( !previous || + occurrence !== undefined || previous.state !== next.state || previous.prompt !== next.prompt || (previous.agentType ?? null) !== (next.agentType ?? null) || @@ -48,5 +78,8 @@ export function createHookStatusSessionTabsInvalidator(): { } return forgotten } + invalidator.getPromptSubmissions = ( + paneKey: string + ): readonly AgentPromptSubmissionOccurrence[] => known.get(paneKey)?.promptSubmissions ?? [] return invalidator } diff --git a/src/main/agent-hooks/server.test.ts b/src/main/agent-hooks/server.test.ts index 96bb121c7e74..2198ec1a488b 100644 --- a/src/main/agent-hooks/server.test.ts +++ b/src/main/agent-hooks/server.test.ts @@ -8071,6 +8071,58 @@ describe('AgentHookServer ingestRemote', () => { ) }) + it('stamps monotonic live prompt submissions and ignores replay or malformed digests', () => { + vi.useFakeTimers() + vi.setSystemTime(5_000) + try { + const server = new AgentHookServer() + const listener = vi.fn() + server.setListener(listener) + const digest = (value: string): string => + `sha256:${createHash('sha256').update(value).digest('hex')}` + const submit = ( + paneKey: string, + source: 'codex' | 'claude', + prompt: string, + options: { digest?: string; replay?: boolean } = {} + ): void => { + server.ingestRemote( + { + paneKey, + source, + hookEventName: 'UserPromptSubmit', + hasExplicitPrompt: true, + submittedPromptDigest: options.digest ?? digest(prompt), + isReplay: options.replay, + payload: { state: 'working', prompt, agentType: source } + }, + 'conn-1' + ) + } + + submit(PANE, 'codex', 'first') + submit(GOOD_PANE, 'claude', 'second') + submit(PANE, 'codex', 'replayed', { replay: true }) + submit(PANE, 'codex', 'malformed', { digest: 'sha256:not-a-digest' }) + submit(PANE, 'codex', 'third') + + const occurrences = listener.mock.calls + .map(([event]) => event.promptSubmission) + .filter(Boolean) + expect(occurrences).toHaveLength(3) + expect(occurrences.map((occurrence) => occurrence.sequence)).toEqual([1, 2, 3]) + expect(new Set(occurrences.map((occurrence) => occurrence.streamId)).size).toBe(1) + expect(occurrences.map((occurrence) => occurrence.receivedAt)).toEqual([5_000, 5_001, 5_004]) + expect(occurrences.map((occurrence) => occurrence.digest)).toEqual([ + digest('first'), + digest('second'), + digest('third') + ]) + } finally { + vi.useRealTimers() + } + }) + it('preserves active pane identity when a nested remote hook reports another agent', () => { vi.useFakeTimers() vi.setSystemTime(1_000) diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index 0f9e0a7812d7..9b5293bfe99b 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -56,6 +56,7 @@ import { } from '../../shared/claude-statusline-rate-limits' import { AGENT_STATUS_STALE_AFTER_MS, + type AgentPromptSubmissionOccurrence, type AgentStatusClearIpcPayload, type AgentStatusIpcPayload, type AgentType, @@ -90,6 +91,7 @@ export type { AgentHookSource } type EnrichedAgentHookEventPayload = AgentHookEventPayload & { receivedAt: number stateStartedAt: number + promptSubmission?: AgentPromptSubmissionOccurrence /** Stamped at hydrate for nonterminal states; never persisted (hydrate re-stamps) and cleared by any accepted live event replacing the entry. */ restoredUnconfirmed?: true } @@ -101,7 +103,12 @@ type NormalizedLocalHook = { type PersistedAgentHookEventPayload = Omit< EnrichedAgentHookEventPayload, - 'claudeRunningNonAgentTask' | 'launchToken' | 'promptInteractionKey' | 'restoredUnconfirmed' + | 'claudeRunningNonAgentTask' + | 'launchToken' + | 'promptInteractionKey' + | 'promptSubmission' + | 'restoredUnconfirmed' + | 'submittedPromptDigest' > & { launchTokenHash?: string } @@ -167,6 +174,11 @@ const LAST_STATUS_FILE_VERSION = 2 const STATUS_PERSIST_DEBOUNCE_MS = 250 const TOOL_PROGRESS_HOOK_EVENTS = new Set(['PreToolUse', 'PostToolUse', 'PostToolUseFailure']) const AGENT_PROMPT_SENT_AGENT_KINDS = new Set(AGENT_KIND_VALUES) +const SUBMITTED_PROMPT_DIGEST_RE = /^sha256:[a-f0-9]{64}$/ + +function normalizeSubmittedPromptDigest(value: unknown): string | undefined { + return typeof value === 'string' && SUBMITTED_PROMPT_DIGEST_RE.test(value) ? value : undefined +} // Why: bound file growth from PTYs that never re-attach; 7 days is the "still relevant?" horizon beyond which entries shouldn't resurrect on hydrate. const HYDRATE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000 @@ -397,6 +409,7 @@ function toAgentStatusIpcPayload(entry: EnrichedAgentHookEventPayload): AgentSta ...(entry.providerSession ? { providerSession: entry.providerSession } : {}), ...(entry.providerSessionOnly ? { providerSessionOnly: true } : {}), ...(entry.promptInteractionKey ? { promptInteractionKey: entry.promptInteractionKey } : {}), + ...(entry.promptSubmission ? { promptSubmission: entry.promptSubmission } : {}), ...(entry.restoredUnconfirmed ? { restoredUnconfirmed: true } : {}), ...entry.payload } @@ -617,6 +630,8 @@ export class AgentHookServer { private codexSubagentPollTimers = new Map>() private promptSentDedupeByPaneKey = new Map() private promptSentHashSalt = randomBytes(16).toString('hex') + private promptSubmissionStreamId = randomUUID() + private promptSubmissionSequence = 0 private closedAgentStatusTabIds = new Set() private closedAgentStatusPaneKeys = new Set() private connectionTimestampWatermarkById = new Map() @@ -1039,10 +1054,25 @@ export class AgentHookServer { previous && previous.payload.state === payload.payload.state && !commandCodeNewTurn ? previous.stateStartedAt : now + const submittedPromptDigest = normalizeSubmittedPromptDigest(payload.submittedPromptDigest) + const promptSubmission = + payload.isReplay !== true && + payload.hookEventName === 'UserPromptSubmit' && + payload.hasExplicitPrompt === true && + (payload.source === 'codex' || payload.source === 'claude') && + submittedPromptDigest + ? { + streamId: this.promptSubmissionStreamId, + sequence: ++this.promptSubmissionSequence, + digest: submittedPromptDigest, + receivedAt: now + } + : undefined return { ...payload, receivedAt: now, - stateStartedAt + stateStartedAt, + ...(promptSubmission ? { promptSubmission } : {}) } } @@ -1319,7 +1349,9 @@ export class AgentHookServer { } const subagentsChanged = JSON.stringify(normalized.payload.subagents) !== JSON.stringify(original.payload.subagents) - const next = subagentsChanged ? this.applyNormalizedStatus(normalized) : original + const next = subagentsChanged + ? this.applyNormalizedStatus({ ...normalized, submittedPromptDigest: undefined }) + : original this.scheduleCodexSubagentPoll(source, body, next) }, CODEX_SUBAGENT_POLL_MS) this.codexSubagentPollTimers.set(original.paneKey, timer) @@ -1852,6 +1884,7 @@ export class AgentHookServer { hasExplicitPrompt?: boolean promptInteractionKey?: string hookEventName?: string + submittedPromptDigest?: string source?: unknown providerPromptId?: unknown compactTrigger?: unknown @@ -1917,6 +1950,7 @@ export class AgentHookServer { ? envelope.hookEventName.trim() : undefined const source = isAgentHookSource(envelope.source) ? envelope.source : undefined + const submittedPromptDigest = normalizeSubmittedPromptDigest(envelope.submittedPromptDigest) const providerPromptId = source === 'claude' ? normalizeClaudePromptId(envelope.providerPromptId) : undefined const compactTrigger = @@ -2030,6 +2064,7 @@ export class AgentHookServer { hasExplicitPrompt: envelope.hasExplicitPrompt === true ? true : undefined, promptInteractionKey, hookEventName, + submittedPromptDigest, providerPromptId, compactTrigger, toolUseId, @@ -2748,6 +2783,8 @@ export class AgentHookServer { promptInteractionKey: _promptInteractionKey, // Why: never persisted — hydrate re-stamps it, so a stored copy could only drift. restoredUnconfirmed: _restoredUnconfirmed, + promptSubmission: _promptSubmission, + submittedPromptDigest: _submittedPromptDigest, launchToken, ...persistedPayload } = payload as EnrichedAgentHookEventPayload diff --git a/src/main/index.ts b/src/main/index.ts index 61c7d799f94b..c069e730b3b5 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1425,6 +1425,7 @@ function openMainWindow(): BrowserWindow { providerSession, providerSessionOnly, promptInteractionKey, + promptSubmission, restoredUnconfirmed, isReplay }) => { @@ -1462,6 +1463,7 @@ function openMainWindow(): BrowserWindow { stateStartedAt, ...(providerSession ? { providerSession } : {}), ...(promptInteractionKey ? { promptInteractionKey } : {}), + ...(promptSubmission ? { promptSubmission } : {}), ...(restoredUnconfirmed ? { restoredUnconfirmed: true } : {}), ...(orchestration ? { orchestration } : {}) }) @@ -2363,6 +2365,8 @@ void app.whenReady().then(async () => { // Why: worktree.ps pulls hook-reported agent status (same source as the desktop sidebar) at query time so mobile shows the same agents. getAgentStatusSnapshot: () => agentHookServer.getStatusSnapshot().filter((entry) => entry.providerSessionOnly !== true), + getAgentPromptSubmissionsForPane: (paneKey) => + hookStatusChangedSessionTabs.getPromptSubmissions(paneKey), // Why: the filter above hides resume-identity rows from the live-agent views, but // those rows carry the provider session mobile native chat addresses transcripts // by — Pi publishes identity that way and would otherwise be unreachable. diff --git a/src/main/runtime/orca-runtime-hook-agent-status-projection.test.ts b/src/main/runtime/orca-runtime-hook-agent-status-projection.test.ts index 47715ea7c5d2..f5febc033ddb 100644 --- a/src/main/runtime/orca-runtime-hook-agent-status-projection.test.ts +++ b/src/main/runtime/orca-runtime-hook-agent-status-projection.test.ts @@ -4,7 +4,10 @@ import { describe, expect, it, vi } from 'vitest' import { OrcaRuntimeService } from './orca-runtime' import { AGENT_STATUS_STALE_AFTER_MS } from '../../shared/agent-status-types' -import type { AgentStatusIpcPayload } from '../../shared/agent-status-types' +import type { + AgentPromptSubmissionOccurrence, + AgentStatusIpcPayload +} from '../../shared/agent-status-types' import { makePaneKey } from '../../shared/stable-pane-id' vi.mock('electron', () => ({ @@ -58,10 +61,12 @@ function hookRow(overrides: Partial = {}): AgentStatusIpc } async function createRuntimeWithHookRows( - rows: AgentStatusIpcPayload[] + rows: AgentStatusIpcPayload[], + promptSubmissions: readonly AgentPromptSubmissionOccurrence[] = [] ): Promise { const runtime = new OrcaRuntimeService(null, undefined, { - getAgentStatusSnapshot: () => rows + getAgentStatusSnapshot: () => rows, + getAgentPromptSubmissionsForPane: () => promptSubmissions }) const internals = runtime as unknown as { resolveTerminalWorkspaceLaunchScope: (selector: string) => Promise @@ -130,6 +135,23 @@ describe('headless hook agent-status projection (#11761)', () => { ) }) + it('carries transient prompt acknowledgements after the hook row has advanced', async () => { + const promptSubmission = { + streamId: 'stream-1', + sequence: 4, + digest: `sha256:${'a'.repeat(64)}`, + receivedAt: Date.now() + } + const runtime = await createRuntimeWithHookRows([hookRow()], [promptSubmission]) + + const result = await runtime.listMobileSessionTabs(`id:${WORKTREE_ID}`) + const tab = result.tabs[0] + + expect(tab?.type === 'terminal' && tab.agentStatus?.promptSubmissions).toEqual([ + promptSubmission + ]) + }) + it('publishes no hook transport identity to clients', async () => { const agentStatus = await projectAgentStatus([ hookRow({ launchToken: 'lt-secret', promptInteractionKey: 'turn-1' }) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 74e5bff36f85..4fc29043fd09 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -40,6 +40,7 @@ import { AGENT_STATUS_STALE_AFTER_MS, isFreshNonDoneAgentStatus, pickParsedAgentStatusPayload, + type AgentPromptSubmissionOccurrence, type AgentStatusIpcPayload, type ParsedAgentStatusPayload, type AgentStatusOrchestrationContext, @@ -341,12 +342,6 @@ import { type BrowserTabInfo, type BrowserScreencastResult } from '../../shared/runtime-types' -import { - TERMINAL_WAIT_BLOCKED_SENTINEL_RE, - detectTerminalWaitBlockedReason, - findActionableTerminalWaitBlockedSignal, - isKnownReadyPromptPreview -} from '../../shared/terminal-wait-blocked-detection' import { LINEAR_SEARCH_MAX_LIMIT, LINEAR_WRITE_BODY_CAP, @@ -3271,6 +3266,9 @@ export class OrcaRuntimeService { private terminalSideEffectLocalConsumerAvailable = false private terminalSideEffectConsumerAvailable = false private readonly getAgentStatusSnapshotFn: (() => AgentStatusIpcPayload[]) | null + private readonly getAgentPromptSubmissionsForPaneFn: + | ((paneKey: string) => readonly AgentPromptSubmissionOccurrence[]) + | null private readonly getAgentProviderSessionSnapshotFn: (() => AgentStatusIpcPayload[]) | null private readonly getAgentProviderSessionRowsForPaneFn: | ((paneKey: string) => AgentStatusIpcPayload[]) @@ -3344,6 +3342,9 @@ export class OrcaRuntimeService { // terminal output. worktree.ps reads this at query time so mobile shows the // same inline agent rows the desktop sidebar does — same source, 1:1. getAgentStatusSnapshot?: () => AgentStatusIpcPayload[] + getAgentPromptSubmissionsForPane?: ( + paneKey: string + ) => readonly AgentPromptSubmissionOccurrence[] /** Same rows, but including the resume-identity-only ones `getAgentStatusSnapshot` * filters out so they can't read as running agents. Mobile native chat needs * them: for an agent that publishes identity separately (Pi), that row is the @@ -3387,6 +3388,7 @@ export class OrcaRuntimeService { this.agentDetector = new AgentDetector(stats) } this.getAgentStatusSnapshotFn = deps?.getAgentStatusSnapshot ?? null + this.getAgentPromptSubmissionsForPaneFn = deps?.getAgentPromptSubmissionsForPane ?? null this.getAgentProviderSessionSnapshotFn = deps?.getAgentProviderSessionSnapshot ?? deps?.getAgentStatusSnapshot ?? null this.getAgentProviderSessionRowsForPaneFn = deps?.getAgentProviderSessionRowsForPane ?? null @@ -16526,13 +16528,6 @@ export class OrcaRuntimeService { return { handle, isRunningAgent, status: null } } - getTerminalWaitBlockedReason(handle: string): RuntimeTerminalWaitBlockedReason | null { - const ptyId = this.getTerminalAgentStatusPtyId(handle) - return detectTerminalWaitBlockedReason( - this.getTerminalAgentStatusSnapshot(handle, ptyId).waitText - ) - } - private getTerminalAgentStatusPtyId(handle: string): string { const pty = this.getLivePtyForHandle(handle) if (pty) { @@ -30112,6 +30107,7 @@ export class OrcaRuntimeService { const paneKey = isTerminalLeafId(tab.leafId) ? makePaneKey(tab.parentTabId, tab.leafId) : `${tab.parentTabId}:${legacyPaneId ?? tab.leafId}` + const promptSubmissions = this.getAgentPromptSubmissionsForPaneFn?.(paneKey) ?? [] const mobileStatusPty = livePty ?? pty // Why: headless hooks live only in main's retained rows; reuse this lookup // for both title ownership and status publication so the two cannot diverge. @@ -30174,7 +30170,8 @@ export class OrcaRuntimeService { ? normalizeCompatibleAgentStatusEntryForOwner( { ...tab.agentStatus, - ...(hookProviderSession ? { providerSession: hookProviderSession } : {}) + ...(hookProviderSession ? { providerSession: hookProviderSession } : {}), + ...(promptSubmissions.length > 0 ? { promptSubmissions: [...promptSubmissions] } : {}) }, ownerAgent ) @@ -30206,6 +30203,9 @@ export class OrcaRuntimeService { agentType: normalizedTabAgentStatus.agentType, ...(normalizedTabAgentStatus.providerSession ? { providerSession: normalizedTabAgentStatus.providerSession } + : {}), + ...(promptSubmissions.length > 0 + ? { promptSubmissions: [...promptSubmissions] } : {}) } } @@ -30302,6 +30302,7 @@ export class OrcaRuntimeService { getHookRowsForPane: (paneKey: string) => AgentStatusIpcPayload[] ): { agentStatus: AgentStatusEntry } | Record { const paneKey = this.getMobileTerminalPaneKey(tab) + const promptSubmissions = this.getAgentPromptSubmissionsForPaneFn?.(paneKey) ?? [] // Why: neither the OSC-retained row nor a title-derived status can carry a // provider session — only the hook payload does, and headless serve has no // renderer to publish `tab.agentStatus`. Without it mobile native chat has no @@ -30378,7 +30379,8 @@ export class OrcaRuntimeService { : {}), tabId: tab.parentTabId, terminalTitle, - ...providerSession + ...providerSession, + ...(promptSubmissions.length > 0 ? { promptSubmissions: [...promptSubmissions] } : {}) }, ownerAgent ) @@ -30410,7 +30412,8 @@ export class OrcaRuntimeService { tabId: tab.parentTabId, terminalTitle, stateHistory: [], - ...providerSession + ...providerSession, + ...(promptSubmissions.length > 0 ? { promptSubmissions: [...promptSubmissions] } : {}) } } } @@ -36380,6 +36383,210 @@ function detectExplicitIdleStatusFromTitle(title: string): AgentStatus | null { return null } +function isKnownReadyPromptPreview(preview: string): boolean { + const normalized = preview.toLowerCase() + const readyIndex = findKnownReadyPromptIndex(normalized) + if (readyIndex === null) { + return false + } + const blockedSignal = findTerminalWaitBlockedSignal(normalized) + if (blockedSignal !== null && blockedSignal.index > readyIndex) { + return false + } + return true +} + +function detectTerminalWaitBlockedReason(preview: string): RuntimeTerminalWaitBlockedReason | null { + const normalized = preview.toLowerCase() + return findActionableTerminalWaitBlockedSignal(normalized)?.reason ?? null +} + +function findActionableTerminalWaitBlockedSignal( + normalized: string +): { reason: RuntimeTerminalWaitBlockedReason; index: number } | null { + const blockedSignal = findTerminalWaitBlockedSignal(normalized) + if (blockedSignal === null) { + return null + } + const dismissedModalIndex = findDismissedStartupModalIndex(normalized) + // Why: a live prompt after the modal means it was dismissed → signal no longer actionable, even mid-run (Cursor never reports idle via OSC title). + return dismissedModalIndex !== null && dismissedModalIndex > blockedSignal.index + ? null + : blockedSignal +} + +// Why: a live prompt (idle OR busy) proves the startup modal was dismissed, so a mid-run Cursor lane stops reporting stale trust hits. +function findDismissedStartupModalIndex(normalized: string): number | null { + const indexes = [ + findCodexReadyPromptIndex(normalized), + findAntigravityReadyPromptIndex(normalized), + findCursorActivePromptIndex(normalized) + ].filter((index): index is number => index !== null) + return indexes.length > 0 ? Math.max(...indexes) : null +} + +function findKnownReadyPromptIndex(normalized: string): number | null { + const indexes = [ + findCodexReadyPromptIndex(normalized), + findAntigravityReadyPromptIndex(normalized), + findCursorReadyPromptIndex(normalized) + ].filter((index): index is number => index !== null) + return indexes.length > 0 ? Math.max(...indexes) : null +} + +// Why: match the banner's last occurrence to skip the trust dialog's own "Cursor Agent" text; "→" is cursor-agent's persistent input prompt. +function findCursorActivePromptIndex(normalized: string): number | null { + const headerIndex = normalized.lastIndexOf('cursor agent') + if (headerIndex === -1) { + return null + } + return normalized.includes('→', headerIndex) ? headerIndex : null +} + +// Why: cursor-agent emits no idle OSC title; infer idle from the tail (braille spinner = busy, its absence = idle). +const CURSOR_BUSY_SPINNER_RE = /[⠁-⣿]/ + +function findCursorReadyPromptIndex(normalized: string): number | null { + const activeIndex = findCursorActivePromptIndex(normalized) + if (activeIndex === null) { + return null + } + return CURSOR_BUSY_SPINNER_RE.test(normalized.slice(activeIndex)) ? null : activeIndex +} + +function findCodexReadyPromptIndex(normalized: string): number | null { + const headerIndex = normalized.lastIndexOf('openai codex') + if (headerIndex === -1) { + return null + } + const readySegment = normalized.slice(headerIndex) + // Why: Codex prints permissions only in YOLO mode; the stable ready header is OpenAI Codex + model + directory. + return readySegment.includes('model:') && readySegment.includes('directory:') ? headerIndex : null +} + +function findAntigravityReadyPromptIndex(normalized: string): number | null { + const headerIndex = normalized.lastIndexOf('antigravity cli') + if (headerIndex === -1) { + return null + } + let lineStart = headerIndex + let modelIndex: number | null = null + let promptIndex: number | null = null + + // Why: ready previews can include echoed paste after the header; scan line bounds directly instead of splitting the whole tail. + for (let cursor = headerIndex; cursor <= normalized.length; cursor += 1) { + if (cursor < normalized.length && normalized.charCodeAt(cursor) !== 10) { + continue + } + let trimmedStart = lineStart + let trimmedEnd = cursor + while (trimmedStart < trimmedEnd && isTerminalWaitWhitespace(normalized, trimmedStart)) { + trimmedStart += 1 + } + while (trimmedEnd > trimmedStart && isTerminalWaitWhitespace(normalized, trimmedEnd - 1)) { + trimmedEnd -= 1 + } + if (lineStart > headerIndex && trimmedStart < trimmedEnd) { + if (modelIndex === null && normalized.startsWith('gemini', trimmedStart)) { + modelIndex = trimmedStart + } + if ( + promptIndex === null && + trimmedEnd - trimmedStart === 1 && + normalized.charCodeAt(trimmedStart) === 62 + ) { + promptIndex = trimmedStart + } + } + lineStart = cursor + 1 + } + + return modelIndex !== null && promptIndex !== null ? Math.max(modelIndex, promptIndex) : null +} + +function isTerminalWaitWhitespace(value: string, index: number): boolean { + const code = value.charCodeAt(index) + return code === 32 || (code >= 9 && code <= 13) +} + +const TERMINAL_WAIT_BLOCKED_SENTINEL_RE = + /update available|choose working directory to|codex just got an upgrade|hooks need review|do you trust|trust this|trusted workspace|press enter to (?:confirm|continue|view|insert)|press t to trust/i + +function findTerminalWaitBlockedSignal( + normalized: string +): { reason: RuntimeTerminalWaitBlockedReason; index: number } | null { + // Why: one combined negative scan over the up-to-256 KiB tail avoids a dozen full-tail searches when no prompt can match. + if (!TERMINAL_WAIT_BLOCKED_SENTINEL_RE.test(normalized)) { + return null + } + const candidates: { reason: RuntimeTerminalWaitBlockedReason; index: number }[] = [] + const updateIndex = normalized.lastIndexOf('update available') + if (updateIndex !== -1 && normalized.includes('press enter to continue', updateIndex)) { + candidates.push({ reason: 'codex-update-prompt', index: updateIndex }) + } + const cwdIndex = normalized.lastIndexOf('choose working directory to') + if (cwdIndex !== -1 && normalized.includes('press enter to continue', cwdIndex)) { + candidates.push({ reason: 'codex-cwd-prompt', index: cwdIndex }) + } + const modelMigrationIndex = normalized.lastIndexOf('codex just got an upgrade') + if ( + modelMigrationIndex !== -1 && + normalized.includes('press enter to continue', modelMigrationIndex) + ) { + candidates.push({ reason: 'codex-model-migration-prompt', index: modelMigrationIndex }) + } + const hooksIndex = normalized.lastIndexOf('hooks need review') + if (hooksIndex !== -1 && normalized.includes('press enter to confirm', hooksIndex)) { + candidates.push({ reason: 'codex-hooks-review-prompt', index: hooksIndex }) + } + const trustIndex = Math.max( + normalized.lastIndexOf('do you trust'), + normalized.lastIndexOf('trust this'), + normalized.lastIndexOf('trusted workspace') + ) + const trustSegment = trustIndex === -1 ? '' : normalized.slice(trustIndex) + if ( + trustIndex !== -1 && + (trustSegment.includes('workspace') || + trustSegment.includes('folder') || + trustSegment.includes('directory') || + trustSegment.includes('repo')) + ) { + candidates.push({ reason: 'codex-trust-workspace', index: trustIndex }) + } + const interactivePromptIndex = Math.max( + normalized.lastIndexOf('press enter to confirm'), + normalized.lastIndexOf('press enter to continue'), + normalized.lastIndexOf('press enter to view'), + normalized.lastIndexOf('press enter to insert'), + normalized.lastIndexOf('press t to trust') + ) + const interactivePromptContext = + interactivePromptIndex === -1 + ? '' + : normalized.slice(Math.max(0, interactivePromptIndex - 600), interactivePromptIndex + 200) + const hasCodexInteractiveContext = + interactivePromptContext.includes('codex') || + interactivePromptContext.includes('permission') || + interactivePromptContext.includes('sandbox') || + interactivePromptContext.includes('trust') || + interactivePromptContext.includes('hook') + if (interactivePromptIndex !== -1 && hasCodexInteractiveContext) { + const contextStart = Math.max(0, interactivePromptIndex - 600) + const hasSpecificPromptInContext = candidates.some( + (candidate) => candidate.index >= contextStart && candidate.index <= interactivePromptIndex + ) + if (!hasSpecificPromptInContext) { + candidates.push({ reason: 'codex-interactive-prompt', index: interactivePromptIndex }) + } + } + return candidates.length > 0 + ? candidates.reduce((latest, candidate) => + candidate.index > latest.index ? candidate : latest + ) + : null +} + function buildTerminalWaitResult( handle: string, condition: RuntimeTerminalWaitCondition, diff --git a/src/main/runtime/rpc/methods/terminal.ts b/src/main/runtime/rpc/methods/terminal.ts index e133a318bc77..05f94f2e6314 100644 --- a/src/main/runtime/rpc/methods/terminal.ts +++ b/src/main/runtime/rpc/methods/terminal.ts @@ -380,13 +380,8 @@ async function commitMobileInputFloorClaim(claim: MobileInputFloorClaimHolder): } } -function getTerminalSendGuardRefusedReason( - error: unknown -): 'no-agent' | 'permission' | 'interactive-prompt' | undefined { +function getTerminalSendGuardRefusedReason(error: unknown): 'no-agent' | 'permission' | undefined { const message = error instanceof Error ? error.message : String(error) - if (message.includes('terminal_guard_interactive_prompt')) { - return 'interactive-prompt' - } if (message.includes('terminal_guard_permission')) { return 'permission' } @@ -889,7 +884,6 @@ const TerminalSend = TerminalHandle.extend({ text: OptionalString, enter: z.unknown().optional(), interrupt: z.unknown().optional(), - nativeChat: z.literal(true).optional(), resolvedLaunchDraft: z .object({ text: z.string(), @@ -1228,7 +1222,6 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ !isTerminalQueryReply(params.text) || params.enter === true || params.interrupt === true || - params.nativeChat === true || params.requireAgentStatus !== undefined || params.client?.type !== 'mobile' || !queryReplyClientId || @@ -1300,34 +1293,23 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ } } } - // Why: guarded sends recheck terminal identity and prompt state immediately before each PTY write. + // Why: recheck permission/no-agent state immediately before accepting the PTY write. const assertSendPreconditions = - params.requireAgentStatus === 'sendable' || params.nativeChat === true + params.requireAgentStatus === 'sendable' ? async (ptyId?: string): Promise => { - const assertWritable = (): void => { - assertTerminalSendExactPtyBinding(runtime, params.terminal, ptyId) - if (ptyId && isTerminalInputLockedForClient(runtime, ptyId, params.client)) { - throw new Error('terminal_guard_not_writable') + await assertTerminalAgentSendable({ + runtime, + handle: params.terminal, + assertWritable: () => { + assertTerminalSendExactPtyBinding(runtime, params.terminal, ptyId) + if (ptyId && isTerminalInputLockedForClient(runtime, ptyId, params.client)) { + throw new Error('terminal_guard_not_writable') + } } - } - if (params.requireAgentStatus === 'sendable') { - await assertTerminalAgentSendable({ - runtime, - handle: params.terminal, - assertWritable - }) - } else { - assertWritable() - } - if ( - params.nativeChat === true && - runtime.getTerminalWaitBlockedReason(params.terminal) !== null - ) { - throw new Error('terminal_guard_interactive_prompt') - } + }) } : undefined - if (assertSendPreconditions) { + if (params.requireAgentStatus === 'sendable') { try { await assertSendPreconditions?.(leaf?.ptyId ?? undefined) } catch (error) { diff --git a/src/main/runtime/rpc/terminal-send.test.ts b/src/main/runtime/rpc/terminal-send.test.ts index 65d047538e3d..e9fb481aae06 100644 --- a/src/main/runtime/rpc/terminal-send.test.ts +++ b/src/main/runtime/rpc/terminal-send.test.ts @@ -615,40 +615,6 @@ describe('terminal send RPC', () => { expect(beginMobileInputFloor).not.toHaveBeenCalled() }) - it('refuses native-chat sends while the terminal has an interactive prompt', async () => { - const runtime = stubRuntime({ - resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), - getDriver: vi.fn().mockReturnValue({ kind: 'mobile', clientId: 'mobile-1' }), - getTerminalWaitBlockedReason: vi.fn().mockReturnValue('codex-interactive-prompt'), - sendTerminal: vi.fn() - }) - const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) - - const response = await dispatcher.dispatch( - makeRequest('terminal.send', { - terminal: 'terminal-1', - text: 'hello', - enter: true, - nativeChat: true, - client: { id: 'mobile-1', type: 'mobile' } - }) - ) - - expect(response).toMatchObject({ - ok: true, - result: { - send: { - handle: 'terminal-1', - accepted: false, - bytesWritten: 0, - refusedReason: 'interactive-prompt' - } - } - }) - expect(runtime.sendTerminal).not.toHaveBeenCalled() - expect(runtime.beginMobileInputFloor).not.toHaveBeenCalled() - }) - it('allows guarded terminal sends when the agent is sendable', async () => { const runtime = stubRuntime({ resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), diff --git a/src/main/ssh/ssh-relay-session.ts b/src/main/ssh/ssh-relay-session.ts index f44d2a77dbdc..9f6e148f6d0f 100644 --- a/src/main/ssh/ssh-relay-session.ts +++ b/src/main/ssh/ssh-relay-session.ts @@ -1444,6 +1444,7 @@ export class SshRelaySession { hasExplicitPrompt?: unknown promptInteractionKey?: unknown hookEventName?: unknown + submittedPromptDigest?: unknown source?: unknown providerPromptId?: unknown compactTrigger?: unknown @@ -1476,6 +1477,10 @@ export class SshRelaySession { : undefined, hookEventName: typeof envelope.hookEventName === 'string' ? envelope.hookEventName : undefined, + submittedPromptDigest: + typeof envelope.submittedPromptDigest === 'string' + ? envelope.submittedPromptDigest + : undefined, source: envelope.source, providerPromptId: envelope.providerPromptId, compactTrigger: envelope.compactTrigger, diff --git a/src/relay/agent-hook-server.test.ts b/src/relay/agent-hook-server.test.ts index 32d5691cbe07..f36194aa0628 100644 --- a/src/relay/agent-hook-server.test.ts +++ b/src/relay/agent-hook-server.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createHash } from 'node:crypto' import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { homedir, tmpdir } from 'node:os' import { join } from 'node:path' @@ -65,6 +66,9 @@ describe('RelayAgentHookServer', () => { expect(envelope.connectionId).toBeNull() expect(envelope.payload.state).toBe('working') expect(envelope.payload.prompt).toBe('hi') + expect(envelope.submittedPromptDigest).toBe( + `sha256:${createHash('sha256').update('hi').digest('hex')}` + ) expect(envelope.claudeRunningNonAgentTask).toBe(false) // Why: the relay forwards body env/version so Orca's warn-once // protocol diagnostics and remote-location marker survive the wire. diff --git a/src/relay/agent-hook-server.ts b/src/relay/agent-hook-server.ts index 3166c70a2f5c..de18b3872a76 100644 --- a/src/relay/agent-hook-server.ts +++ b/src/relay/agent-hook-server.ts @@ -317,6 +317,7 @@ export class RelayAgentHookServer { hasExplicitPrompt: event.hasExplicitPrompt, promptInteractionKey: event.promptInteractionKey, hookEventName: event.hookEventName, + submittedPromptDigest: event.submittedPromptDigest, providerPromptId: event.providerPromptId, compactTrigger: event.compactTrigger, toolUseId: event.toolUseId, @@ -403,9 +404,10 @@ export class RelayAgentHookServer { } const subagentsChanged = JSON.stringify(event.payload.subagents) !== JSON.stringify(original.payload.subagents) - const next = subagentsChanged ? event : original + const refresh = { ...event, submittedPromptDigest: undefined } + const next = subagentsChanged ? refresh : original if (subagentsChanged) { - this.applyEvent(event, source, env, version) + this.applyEvent(refresh, source, env, version) } this.scheduleCodexSubagentPoll(source, body, next, env, version) }, CODEX_SUBAGENT_POLL_MS) diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx index 1532521259fa..7a2077e2bfd3 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx @@ -36,8 +36,6 @@ const mocks = vi.hoisted(() => ({ sendNativeChatMessageVerified: vi.fn(), trackPendingSend: vi.fn(), setDraft: vi.fn(), - draft: 'hello', - toastInfo: vi.fn(), draftScopeKeys: [] as string[], clearNativeChatLaunchDraft: vi.fn(), markNativeChatLaunchDraftAdopted: vi.fn() @@ -63,9 +61,6 @@ vi.mock('@/runtime/runtime-terminal-inspection', () => ({ vi.mock('@/lib/agent-paste-draft', () => ({ getSettingsForAgentTabRuntimeOwner: () => ({}) })) -vi.mock('sonner', () => ({ - toast: { info: (...args: unknown[]) => mocks.toastInfo(...args) } -})) vi.mock('./native-chat-runtime-send', () => ({ sendNativeChatMessage: (...args: unknown[]) => mocks.sendNativeChatMessage(...args), sendNativeChatMessageVerified: (...args: unknown[]) => @@ -90,7 +85,7 @@ vi.mock('@/lib/native-chat-telemetry', () => ({ vi.mock('./use-native-chat-draft', () => ({ useNativeChatDraft: (scopeKey: string) => { mocks.draftScopeKeys.push(scopeKey) - return { draft: mocks.draft, setDraft: mocks.setDraft } + return { draft: 'hello', setDraft: mocks.setDraft } } })) vi.mock('./native-chat-draft-cache', () => ({ @@ -110,7 +105,8 @@ vi.mock('./use-native-chat-composer-attachments', () => ({ imageAttachments: [], attachResolvedPaths: vi.fn(), clearImageAttachments: vi.fn(), - removeImageAttachment: vi.fn() + removeImageAttachment: vi.fn(), + restoreFailedMessage: vi.fn(() => true) }) })) vi.mock('./use-native-chat-composer-paste', () => ({ @@ -147,7 +143,6 @@ describe('NativeChatComposer', () => { clearNativeChatModelEnrichmentForTests() mocks.fieldProps = null mocks.modelSwitchOutcome = 'applied' - mocks.draft = 'hello' mocks.draftScopeKeys.length = 0 mocks.confirmationObserver = null mocks.createClaudeModelSwitchConfirmationObserver.mockImplementation(() => { @@ -240,59 +235,6 @@ describe('NativeChatComposer', () => { expect(mocks.trackPendingSend).toHaveBeenCalledWith(mocks.sendHandle, 'pending-1') }) - it('keeps a chat draft and reveals an active Codex picker instead of sending', () => { - const onOptimisticSend = vi.fn() - const onSwitchToTerminal = vi.fn() - render( - - [ - 'Select Model and Effort', - 'gpt-5.3-codex-spark', - 'Press enter to confirm or esc to cancel' - ].join('\n') - } - onOptimisticSend={onOptimisticSend} - onSwitchToTerminal={onSwitchToTerminal} - /> - ) - - act(() => mocks.fieldProps?.onSend?.()) - - expect(mocks.sendNativeChatMessage).not.toHaveBeenCalled() - expect(onOptimisticSend).not.toHaveBeenCalled() - expect(mocks.setDraft).not.toHaveBeenCalled() - expect(mocks.clearNativeChatLaunchDraft).not.toHaveBeenCalled() - expect(mocks.toastInfo).toHaveBeenCalledWith('Finish the terminal prompt before sending') - expect(onSwitchToTerminal).toHaveBeenCalledOnce() - }) - - it('allows an intentional slash command while a Codex picker is open', () => { - mocks.draft = '/model' - const onSwitchToTerminal = vi.fn() - render( - - ['gpt-5.3-codex-spark', 'Press enter to confirm or esc to cancel'].join('\n') - } - onSwitchToTerminal={onSwitchToTerminal} - /> - ) - - act(() => mocks.fieldProps?.onSend?.()) - - expect(mocks.sendNativeChatMessage).toHaveBeenCalledWith({}, 'pty-1', '/model', undefined) - expect(onSwitchToTerminal).not.toHaveBeenCalled() - }) - it('retires the launch-draft seed once a send clears the TUI input line', () => { render( store.dictationState) const voiceSettings = useAppStore((store) => store.settings?.voice) @@ -150,34 +148,30 @@ export const NativeChatComposer = forwardRef { - if (!targetPtyId) { - return null - } - return { ptyId: targetPtyId, settings: getSettingsForAgentTabRuntimeOwner(terminalTabId) } - }, [targetPtyId, terminalTabId]) - - const [hasPty, disabled] = [targetPtyId !== null, targetPtyId === null || !canSend] - - const syncCaret = useCallback((el: HTMLTextAreaElement) => { - setCaret(el.selectionStart ?? el.value.length) - }, []) + const { resolveTarget, hasPty, disabled, syncCaret } = useNativeChatComposerTarget( + terminalTabId, + targetPtyId, + canSend, + setCaret + ) - const { imageAttachments, attachResolvedPaths, clearImageAttachments, removeImageAttachment } = - useNativeChatComposerAttachments({ - attachmentScopeKey: paneKey, - caret, - resolveTarget, - textareaRef, - setCaret, - setDraft, - setNotice - }) - const sendButtonDisabled = isWorking - ? !hasPty || !onStop - : disabled || (draft.trim() === '' && imageAttachments.length === 0) + const { + imageAttachments, + attachResolvedPaths, + clearImageAttachments, + removeImageAttachment, + restoreFailedMessage + } = useNativeChatComposerAttachments({ + attachmentScopeKey: paneKey, + caret, + resolveTarget, + textareaRef, + setCaret, + setDraft, + setNotice + }) + const composerEmpty = draft.trim() === '' && imageAttachments.length === 0 + const sendButtonDisabled = isWorking ? !hasPty || !onStop : disabled || composerEmpty const { insertTypedText, focus } = useNativeChatTypedInsertion({ textareaRef, @@ -209,8 +203,14 @@ export const NativeChatComposer = forwardRef ({ focus, insertTypedText, handlePasteEvent: handlePaste, pasteFromClipboard }), - [focus, insertTypedText, handlePaste, pasteFromClipboard] + () => ({ + focus, + insertTypedText, + handlePasteEvent: handlePaste, + pasteFromClipboard, + restoreFailedMessage + }), + [focus, insertTypedText, handlePaste, pasteFromClipboard, restoreFailedMessage] ) const { pickAttachment } = useNativeChatFileAttachmentActions(attachExternalPaths) @@ -252,9 +252,6 @@ export const NativeChatComposer = forwardRef { + it('shows the approved recovery copy and opens the terminal', () => { + const onShowTerminal = vi.fn() + render() + + expect(screen.getByText('Message wasn’t sent')).toBeTruthy() + expect( + screen.getByText( + 'The terminal may be waiting for input. Your message is still in the composer.' + ) + ).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: 'Show terminal' })) + expect(onShowTerminal).toHaveBeenCalledOnce() + }) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatDeliveryRecoveryNotice.tsx b/src/renderer/src/components/native-chat/NativeChatDeliveryRecoveryNotice.tsx new file mode 100644 index 000000000000..f16bcaa5f2c8 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatDeliveryRecoveryNotice.tsx @@ -0,0 +1,33 @@ +import { AlertTriangle } from 'lucide-react' +import { translate } from '../../i18n/i18n' +import { Button } from '../ui/button' + +export function NativeChatDeliveryRecoveryNotice({ + onShowTerminal +}: { + onShowTerminal?: () => void +}): React.JSX.Element { + return ( +
+
+
+
+
+

+ {translate('components.native-chat.deliveryRecovery.title', 'Message wasn’t sent')} +

+

+ {translate( + 'components.native-chat.deliveryRecovery.description', + 'The terminal may be waiting for input. Your message is still in the composer.' + )} +

+
+ +
+
+ ) +} diff --git a/src/renderer/src/components/native-chat/NativeChatView.tsx b/src/renderer/src/components/native-chat/NativeChatView.tsx index 2fb458166ecd..6fd7be67c1db 100644 --- a/src/renderer/src/components/native-chat/NativeChatView.tsx +++ b/src/renderer/src/components/native-chat/NativeChatView.tsx @@ -20,19 +20,13 @@ import { } from './native-chat-working-suppression' import { applyCommandMarkerBoundaries, - appendPendingSendCache, commandMarkersAsMessages, appendCommandMarkerCache, launchPromptAsMessage, pendingSendsAsMessages, - nextNativeChatPendingSendId, - prunePendingSends, readCommandMarkerCache, - readPendingSendCache, shouldPruneLaunchPrompt, - writePendingSendCache, - type NativeChatCommandMarker, - type NativeChatPendingSend + type NativeChatCommandMarker } from './native-chat-pending' import { deriveNativeChatStreamingText, @@ -53,6 +47,8 @@ import { selectNativeChatRuntimeEnvironmentId } from './native-chat-runtime-owne import { useNativeChatPasteBridge } from './use-native-chat-paste-bridge' import { useNativeChatFileLinkClick } from './use-native-chat-file-link-click' import type { NativeChatViewProps } from './native-chat-view-types' +import { NativeChatDeliveryRecoveryNotice } from './NativeChatDeliveryRecoveryNotice' +import { useNativeChatPendingDelivery } from './use-native-chat-pending-delivery' export type { NativeChatViewProps } from './native-chat-view-types' @@ -161,6 +157,9 @@ function NativeChatResolvedView({ const hookWorkingEpoch = useAppStore( (s) => s.agentStatusByPaneKey[paneKey]?.stateStartedAt ?? null ) + const promptSubmissions = useAppStore( + (s) => s.agentStatusByPaneKey[paneKey]?.promptSubmissions ?? EMPTY_PROMPT_SUBMISSIONS + ) const canSend = useNativeChatCanSend(targetPtyId) // Reuse the verified composer send path for interactive cards and composer // stop (Stop sends ESC, the agent-TUI interrupt key). @@ -198,10 +197,24 @@ function NativeChatResolvedView({ () => ({ paneKey, agent, sessionId }), [paneKey, agent, sessionId] ) - const pendingScope = useMemo(() => ({ paneKey, agent }), [paneKey, agent]) - const [pending, setPending] = useState(() => - readPendingSendCache(pendingScope) - ) + const restoreFailedMessage = useCallback((text: string, imagePaths?: string[]) => { + composerRef.current?.restoreFailedMessage(text, imagePaths) + }, []) + const { + pending, + deliveryFailed, + clearPending, + onOptimisticSend, + onOptimisticSendCanceled, + markDeliverySubmitted + } = useNativeChatPendingDelivery({ + paneKey, + agent, + messages: session.messages, + promptSubmissions, + restoreMessage: restoreFailedMessage, + setWorkingInterrupted + }) // Slash commands aren't chat turns, so they get a small local "Ran /clear" // system line instead of a user bubble. Capped + cached per conversation. const [commandMarkers, setCommandMarkers] = useState(() => @@ -211,55 +224,18 @@ function NativeChatResolvedView({ // often learns its provider session id after the first send; clearing pending // on that transition briefly flashes the empty state before the transcript // user turn lands. - useEffect(() => { - setPending(readPendingSendCache(pendingScope)) - setWorkingInterrupted(false) - }, [pendingScope]) // Command markers are session-scoped because slash commands like /clear are // local feedback for a specific transcript boundary. useEffect(() => { setCommandMarkers(readCommandMarkerCache(commandMarkerScope)) setWorkingInterrupted(false) }, [commandMarkerScope]) - // Prune echoes whose real user turn is now in the transcript. - useEffect(() => { - setPending((prev) => - writePendingSendCache(pendingScope, prunePendingSends(prev, session.messages)) - ) - }, [session.messages, pendingScope]) useEffect(() => { if (!paneLaunchPrompt || !shouldPruneLaunchPrompt(paneLaunchPrompt, session.messages)) { return } clearNativeChatLaunchPrompt(terminalTabId) }, [clearNativeChatLaunchPrompt, paneLaunchPrompt, session.messages, terminalTabId]) - const onOptimisticSend = useCallback( - (text: string, imagePaths?: string[]) => { - setWorkingInterrupted(false) - const sentAt = Date.now() - const boundary = session.messages.at(-1) - const entry: NativeChatPendingSend = { - id: nextNativeChatPendingSendId(sentAt), - text, - sentAt, - afterMessageId: boundary?.id ?? null, - afterMessageTimestamp: boundary?.timestamp ?? null, - ...(imagePaths ? { imagePaths } : {}) - } - setPending(appendPendingSendCache(pendingScope, entry)) - return entry.id - }, - [pendingScope, session.messages] - ) - const onOptimisticSendCanceled = useCallback( - (pendingId: string) => { - // Why: detach/interrupt cancels the delayed Enter, so its optimistic echo - // must not come back from the pane cache as a prompt that was delivered. - const next = readPendingSendCache(pendingScope).filter((entry) => entry.id !== pendingId) - setPending(writePendingSendCache(pendingScope, next)) - }, - [pendingScope] - ) const onSlashCommand = useCallback( (command: string) => { setCommandMarkers(appendCommandMarkerCache(commandMarkerScope, command)) @@ -357,9 +333,9 @@ function NativeChatResolvedView({ // 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, [])) + clearPending() interactiveSend.cancel() - }, [interactiveSend, pendingScope]) + }, [clearPending, interactiveSend]) const nativeChatFileLinkClick = useNativeChatFileLinkClick(fileLinkContext) // Chat-only font zoom via Cmd/Ctrl +/-/0, gated to the live conversation so @@ -438,24 +414,32 @@ function NativeChatResolvedView({ the pty, the composer shows its guarded state instead of racing the mobile driver (R8). */} {questionActive ? null : ( - + <> + {deliveryFailed ? ( + + ) : null} + + )} {contextMenu.menu} ) } + +const EMPTY_PROMPT_SUBMISSIONS = [] as const diff --git a/src/renderer/src/components/native-chat/native-chat-composer-types.ts b/src/renderer/src/components/native-chat/native-chat-composer-types.ts index 58e97ccd209c..65c00ec0e252 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-types.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-types.ts @@ -19,6 +19,8 @@ export type NativeChatComposerProps = { onOptimisticSend?: (text: string, imagePaths?: string[]) => string | undefined /** Remove an optimistic echo when its delayed submit is canceled. */ onOptimisticSendCanceled?: (pendingId: string) => void + /** Arm delivery confirmation once this pending send's Enter write fires. */ + onOptimisticSendSubmitted?: (pendingId: string) => void /** Record a dispatched slash command that does not create a chat turn. */ onSlashCommand?: (command: string) => void /** Picker-only agent commands continue in the hosted TUI after dispatch. */ @@ -42,4 +44,6 @@ export type NativeChatComposerHandle = { }) => void /** Pastes clipboard content when no DOM paste event is available. */ pasteFromClipboard: () => void + /** Restore a failed send without discarding newer composer edits. */ + restoreFailedMessage: (text: string, imagePaths?: string[]) => boolean } diff --git a/src/renderer/src/components/native-chat/native-chat-pending.ts b/src/renderer/src/components/native-chat/native-chat-pending.ts index 466ec16cd8c3..3d4c124f7c9c 100644 --- a/src/renderer/src/components/native-chat/native-chat-pending.ts +++ b/src/renderer/src/components/native-chat/native-chat-pending.ts @@ -5,6 +5,7 @@ import type { NativeChatMessage } from '../../../../shared/native-chat-types' import { setBoundedScopeCacheEntry } from './native-chat-composer-scope-cache' +import type { NativeChatDeliveryCheck } from './native-chat-prompt-delivery' import type { NativeChatLaunchPrompt } from '@/lib/native-chat-launch-prompt' import { advancedNativeChatUserContentCounts, @@ -38,6 +39,8 @@ export type NativeChatPendingSend = { matchingOccurrence?: number /** Shared time boundary when that message boundary is unavailable. */ matchingAfterTimestamp?: number + /** Bounded confirmation state for recovering sends that never became a transcript turn. */ + deliveryCheck?: NativeChatDeliveryCheck } export type NativeChatPendingSendScope = { diff --git a/src/renderer/src/components/native-chat/native-chat-prompt-delivery.test.ts b/src/renderer/src/components/native-chat/native-chat-prompt-delivery.test.ts new file mode 100644 index 000000000000..073c37e796c9 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-prompt-delivery.test.ts @@ -0,0 +1,110 @@ +import { createHash } from 'node:crypto' +import { describe, expect, it } from 'vitest' +import { + armNativeChatDeliveryCheck, + assignMatchingPromptSubmissions, + hasMatchingPromptSubmission, + nativeChatPromptDigest +} from './native-chat-prompt-delivery' + +describe('native chat prompt delivery', () => { + it('hashes the full exact multiline Unicode prompt', () => { + const prompt = ` ${'long '.repeat(60)}\nsecond line 🚀 ` + const expected = createHash('sha256').update(prompt).digest('hex') + expect(nativeChatPromptDigest(prompt)).toBe(`sha256:${expected}`) + }) + + it('requires an exact digest from a newer occurrence in the same stream', () => { + const expectedDigest = nativeChatPromptDigest('hello') + const check = armNativeChatDeliveryCheck( + { + expectedDigest, + baseline: { streamId: 'stream-a', sequence: 4, digest: 'old', receivedAt: 10 } + }, + 20 + ) + + expect( + hasMatchingPromptSubmission(check, [ + { streamId: 'stream-a', sequence: 4, digest: expectedDigest, receivedAt: 21 } + ]) + ).toBe(false) + expect( + hasMatchingPromptSubmission(check, [ + { streamId: 'stream-a', sequence: 5, digest: 'sha256:wrong', receivedAt: 21 } + ]) + ).toBe(false) + expect( + hasMatchingPromptSubmission(check, [ + { streamId: 'stream-a', sequence: 5, digest: expectedDigest, receivedAt: 21 } + ]) + ).toBe(true) + }) + + it('accepts only a newer matching occurrence from a newly observed stream', () => { + const expectedDigest = nativeChatPromptDigest('hello') + const check = armNativeChatDeliveryCheck( + { + expectedDigest, + baseline: { streamId: 'stream-a', sequence: 9, digest: 'old', receivedAt: 10 } + }, + 20 + ) + + expect( + hasMatchingPromptSubmission(check, [ + { streamId: 'stream-b', sequence: 1, digest: expectedDigest, receivedAt: 1 } + ]) + ).toBe(false) + expect( + hasMatchingPromptSubmission(check, [ + { streamId: 'stream-b', sequence: 1, digest: expectedDigest, receivedAt: 30 } + ]) + ).toBe(true) + }) + + it('rejects a matching occurrence received before an unbased send was armed', () => { + const expectedDigest = nativeChatPromptDigest('hello') + const check = armNativeChatDeliveryCheck({ expectedDigest }, 20) + + expect( + hasMatchingPromptSubmission(check, [ + { streamId: 'stream-a', sequence: 1, digest: expectedDigest, receivedAt: 19 } + ]) + ).toBe(false) + expect( + hasMatchingPromptSubmission(check, [ + { streamId: 'stream-a', sequence: 2, digest: expectedDigest, receivedAt: 20 } + ]) + ).toBe(true) + }) + + it('assigns one occurrence to only one repeated pending send', () => { + const expectedDigest = nativeChatPromptDigest('repeat') + const check = armNativeChatDeliveryCheck({ expectedDigest }, 20) + const occurrence = { + streamId: 'stream-a', + sequence: 1, + digest: expectedDigest, + receivedAt: 21 + } + + const first = assignMatchingPromptSubmissions( + [ + { id: 'first', deliveryCheck: check }, + { id: 'second', deliveryCheck: check } + ], + [occurrence] + ) + expect([...first.keys()]).toEqual(['first']) + + const reserved = assignMatchingPromptSubmissions( + [ + { id: 'first', deliveryCheck: { ...check, acknowledgedBy: occurrence } }, + { id: 'second', deliveryCheck: check } + ], + [occurrence] + ) + expect([...reserved.keys()]).toEqual(['first']) + }) +}) diff --git a/src/renderer/src/components/native-chat/native-chat-prompt-delivery.ts b/src/renderer/src/components/native-chat/native-chat-prompt-delivery.ts new file mode 100644 index 000000000000..67cad52eda86 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-prompt-delivery.ts @@ -0,0 +1,95 @@ +import type { AgentPromptSubmissionOccurrence } from '../../../../shared/agent-status-types' +import { sha256 } from '../../lib/sha256' + +export const NATIVE_CHAT_DELIVERY_CONFIRMATION_MS = 8_000 + +export type NativeChatDeliveryCheck = { + expectedDigest?: string + baseline?: AgentPromptSubmissionOccurrence + submittedAt?: number + deadline?: number + acknowledgedBy?: AgentPromptSubmissionOccurrence +} + +type NativeChatDeliveryCandidate = { + id: string + deliveryCheck?: NativeChatDeliveryCheck +} + +function bytesToHex(bytes: Uint8Array): string { + return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('') +} + +export function nativeChatPromptDigest(text: string): string { + return `sha256:${bytesToHex(sha256(new TextEncoder().encode(text)))}` +} + +export function armNativeChatDeliveryCheck( + check: NativeChatDeliveryCheck, + submittedAt: number +): NativeChatDeliveryCheck { + return { + ...check, + submittedAt, + deadline: submittedAt + NATIVE_CHAT_DELIVERY_CONFIRMATION_MS + } +} + +function occurrenceIsNewer( + occurrence: AgentPromptSubmissionOccurrence, + check: NativeChatDeliveryCheck +): boolean { + if (check.baseline) { + return check.baseline.streamId === occurrence.streamId + ? occurrence.sequence > check.baseline.sequence + : occurrence.receivedAt > check.baseline.receivedAt + } + return check.submittedAt !== undefined && occurrence.receivedAt >= check.submittedAt +} + +export function hasMatchingPromptSubmission( + check: NativeChatDeliveryCheck, + occurrences: readonly AgentPromptSubmissionOccurrence[] +): boolean { + return ( + check.expectedDigest !== undefined && + occurrences.some( + (occurrence) => + occurrence.digest === check.expectedDigest && occurrenceIsNewer(occurrence, check) + ) + ) +} + +function occurrenceKey(occurrence: AgentPromptSubmissionOccurrence): string { + return `${occurrence.streamId}\0${occurrence.sequence}` +} + +/** Assign each hook occurrence to at most one pending send. */ +export function assignMatchingPromptSubmissions( + candidates: readonly NativeChatDeliveryCandidate[], + occurrences: readonly AgentPromptSubmissionOccurrence[] +): Map { + const assignments = new Map() + const claimed = new Set() + for (const candidate of candidates) { + const acknowledgedBy = candidate.deliveryCheck?.acknowledgedBy + if (acknowledgedBy) { + assignments.set(candidate.id, acknowledgedBy) + claimed.add(occurrenceKey(acknowledgedBy)) + } + } + for (const candidate of candidates) { + const check = candidate.deliveryCheck + if (!check || check.acknowledgedBy) { + continue + } + const occurrence = occurrences.find( + (item) => !claimed.has(occurrenceKey(item)) && hasMatchingPromptSubmission(check, [item]) + ) + if (occurrence) { + assignments.set(candidate.id, occurrence) + claimed.add(occurrenceKey(occurrence)) + } + } + return assignments +} diff --git a/src/renderer/src/components/native-chat/native-chat-pty-send-queue.ts b/src/renderer/src/components/native-chat/native-chat-pty-send-queue.ts index 4ae0ce9fe785..724e3c71dda1 100644 --- a/src/renderer/src/components/native-chat/native-chat-pty-send-queue.ts +++ b/src/renderer/src/components/native-chat/native-chat-pty-send-queue.ts @@ -10,8 +10,11 @@ export type NativeChatPtySendQueueHandle = { cancel: () => void settleAfterMs: number settled: Promise + /** Resolves at the exact submit write, or false when canceled first. */ + submission: Promise bodyStarted: () => boolean finished: () => boolean + submitted: () => boolean } export type EnqueueNativeChatPtySendOptions = { @@ -95,6 +98,10 @@ export function enqueueNativeChatPtySend( let bodyStarted = false let finished = false let submitted = false + let resolveSubmission!: (submitted: boolean) => void + const submission = new Promise((resolve) => { + resolveSubmission = resolve + }) const timers: ReturnType[] = [] let release: (() => void) | null = null @@ -119,6 +126,7 @@ export function enqueueNativeChatPtySend( const markSubmitted = (): void => { submitted = true + resolveSubmission(true) finishEntry() } @@ -165,6 +173,7 @@ export function enqueueNativeChatPtySend( return } cancelled = true + resolveSubmission(false) for (const timer of timers) { clearTimeout(timer) } @@ -182,8 +191,10 @@ export function enqueueNativeChatPtySend( }, settleAfterMs, settled, + submission, bodyStarted: () => bodyStarted, - finished: () => finished + finished: () => finished, + submitted: () => submitted } state.handles.add(handle) return handle diff --git a/src/renderer/src/components/native-chat/native-chat-runtime-send.ts b/src/renderer/src/components/native-chat/native-chat-runtime-send.ts index a2974474b2b5..afd6e66c892c 100644 --- a/src/renderer/src/components/native-chat/native-chat-runtime-send.ts +++ b/src/renderer/src/components/native-chat/native-chat-runtime-send.ts @@ -65,6 +65,10 @@ export type NativeChatSendHandle = { settleAfterMs: number /** Actual completion, which can outlive the nominal schedule if the renderer stalls. */ settled?: Promise + /** Resolves at the submit write, before later hook events can race its baseline. */ + submission?: Promise + /** True only after the submit write fired; canceled queued sends remain false. */ + submitted?: () => boolean } type RuntimeSettings = ReturnType diff --git a/src/renderer/src/components/native-chat/native-chat-terminal-prompt-guard.ts b/src/renderer/src/components/native-chat/native-chat-terminal-prompt-guard.ts deleted file mode 100644 index d11de54615b1..000000000000 --- a/src/renderer/src/components/native-chat/native-chat-terminal-prompt-guard.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { toast } from 'sonner' -import { translate } from '@/i18n/i18n' -import { detectTerminalWaitBlockedReason } from '../../../../shared/terminal-wait-blocked-detection' - -export function guardNativeChatTerminalPrompt( - classification: string, - readTerminalScreen: (() => string | null) | undefined, - onSwitchToTerminal: (() => void) | undefined -): boolean { - const screen = classification === 'chat' ? readTerminalScreen?.() : null - if (!screen || detectTerminalWaitBlockedReason(screen) === null) { - return false - } - toast.info( - translate( - 'components.native-chat.composer.finishTerminalPrompt', - 'Finish the terminal prompt before sending' - ) - ) - onSwitchToTerminal?.() - return true -} diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.ts b/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.ts index 60ecf44ff5ad..0f447e1cd4ec 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.ts @@ -1,4 +1,11 @@ -import { useCallback, useRef, useState, type RefObject } from 'react' +import { + useCallback, + useRef, + useState, + type Dispatch, + type RefObject, + type SetStateAction +} from 'react' import { translate } from '@/i18n/i18n' import { isNativeChatImageAttachmentPath } from './native-chat-image-paste' import { @@ -14,7 +21,7 @@ export type UseNativeChatComposerAttachmentsArgs = { caret: number resolveTarget: () => NativeChatResolvedTarget | null textareaRef: RefObject - setCaret: (caret: number) => void + setCaret: Dispatch> setDraft: (updater: (previous: string) => string) => void setNotice: (notice: string | null) => void } @@ -30,6 +37,7 @@ export function useNativeChatComposerAttachments({ }: UseNativeChatComposerAttachmentsArgs): { imageAttachments: NativeChatComposerImageAttachment[] appendImageAttachments: (paths: string[]) => void + restoreFailedMessage: (text: string, imagePaths?: string[]) => boolean attachResolvedPaths: (paths: string[]) => void clearImageAttachments: () => void removeImageAttachment: (id: string) => void @@ -80,6 +88,22 @@ export function useNativeChatComposerAttachments({ [updateImageAttachments] ) + const restoreFailedMessage = useCallback( + (text: string, imagePaths?: string[]): boolean => { + setDraft((current) => { + if (current === text || text.length === 0) { + return current + } + const prefix = current.length === 0 ? text : `${text}\n\n` + setCaret((position) => position + prefix.length) + return `${prefix}${current}` + }) + appendImageAttachments(imagePaths ?? []) + return true + }, + [appendImageAttachments, setCaret, setDraft] + ) + const insertFileReferences = useCallback( (paths: string[]) => { const references = paths.map(formatNativeChatFileReference).join(' ') @@ -134,6 +158,7 @@ export function useNativeChatComposerAttachments({ return { imageAttachments, appendImageAttachments, + restoreFailedMessage, attachResolvedPaths, clearImageAttachments: () => updateImageAttachments(() => []), removeImageAttachment: (id) => diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-target.ts b/src/renderer/src/components/native-chat/use-native-chat-composer-target.ts new file mode 100644 index 000000000000..95cd09f56125 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-target.ts @@ -0,0 +1,32 @@ +import { useCallback } from 'react' +import { getSettingsForAgentTabRuntimeOwner } from '@/lib/agent-paste-draft' +import type { NativeChatResolvedTarget } from './native-chat-composer-target' + +export function useNativeChatComposerTarget( + terminalTabId: string, + targetPtyId: string | null, + canSend: boolean, + setCaret: (caret: number | ((previous: number) => number)) => void +): { + resolveTarget: () => NativeChatResolvedTarget | null + hasPty: boolean + disabled: boolean + syncCaret: (element: HTMLTextAreaElement) => void +} { + const resolveTarget = useCallback((): NativeChatResolvedTarget | null => { + if (!targetPtyId) { + return null + } + return { ptyId: targetPtyId, settings: getSettingsForAgentTabRuntimeOwner(terminalTabId) } + }, [targetPtyId, terminalTabId]) + const syncCaret = useCallback( + (element: HTMLTextAreaElement) => setCaret(element.selectionStart ?? element.value.length), + [setCaret] + ) + return { + resolveTarget, + hasPty: targetPtyId !== null, + disabled: targetPtyId === null || !canSend, + syncCaret + } +} diff --git a/src/renderer/src/components/native-chat/use-native-chat-delivery-recovery.ts b/src/renderer/src/components/native-chat/use-native-chat-delivery-recovery.ts new file mode 100644 index 000000000000..b572842856c6 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-delivery-recovery.ts @@ -0,0 +1,190 @@ +import { useCallback, useEffect, useRef, useState, type Dispatch, type SetStateAction } from 'react' +import type { AgentPromptSubmissionOccurrence } from '../../../../shared/agent-status-types' +import type { NativeChatMessage } from '../../../../shared/native-chat-types' +import { + pendingSendsAsMessages, + readPendingSendCache, + writePendingSendCache, + type NativeChatPendingSend, + type NativeChatPendingSendScope +} from './native-chat-pending' +import { + armNativeChatDeliveryCheck, + assignMatchingPromptSubmissions +} from './native-chat-prompt-delivery' + +type DeliveryRecoveryArgs = { + scope: NativeChatPendingSendScope + pending: NativeChatPendingSend[] + setPending: Dispatch> + messages: NativeChatMessage[] + promptSubmissions: readonly AgentPromptSubmissionOccurrence[] + restoreMessage: (text: string, imagePaths?: string[]) => void +} + +export function useNativeChatDeliveryRecovery({ + scope, + pending, + setPending, + messages, + promptSubmissions, + restoreMessage +}: DeliveryRecoveryArgs): { + failed: boolean + clearFailure: () => void + markSubmitted: (pendingId: string) => void +} { + const [failed, setFailed] = useState(false) + const timersRef = useRef(new Map>()) + const messagesRef = useRef(messages) + const submissionsRef = useRef(promptSubmissions) + const restoreMessageRef = useRef(restoreMessage) + messagesRef.current = messages + submissionsRef.current = promptSubmissions + restoreMessageRef.current = restoreMessage + + const updatePending = useCallback( + (updater: (entries: NativeChatPendingSend[]) => NativeChatPendingSend[]) => { + setPending((current) => writePendingSendCache(scope, updater(current))) + }, + [scope, setPending] + ) + + const markSubmitted = useCallback( + (pendingId: string) => { + const submittedAt = Date.now() + updatePending((entries) => + entries.map((entry) => + entry.id === pendingId && entry.deliveryCheck + ? { + ...entry, + deliveryCheck: armNativeChatDeliveryCheck( + { + ...entry.deliveryCheck, + ...(submissionsRef.current.at(-1) + ? { baseline: submissionsRef.current.at(-1) } + : {}) + }, + submittedAt + ) + } + : entry + ) + ) + }, + [updatePending] + ) + const clearFailure = useCallback(() => setFailed(false), []) + + useEffect(() => setFailed(false), [scope]) + + useEffect(() => { + const assignments = assignMatchingPromptSubmissions(pending, promptSubmissions) + const transcriptConfirmedIds = new Set( + pending + .filter( + (entry) => + entry.deliveryCheck?.deadline !== undefined && + pendingSendsAsMessages([entry], messages).length === 0 + ) + .map((entry) => entry.id) + ) + const newlyAcknowledged = pending.some( + (entry) => !entry.deliveryCheck?.acknowledgedBy && assignments.has(entry.id) + ) + if (transcriptConfirmedIds.size === 0 && !newlyAcknowledged) { + return + } + updatePending((entries) => + entries.map((entry) => { + if (transcriptConfirmedIds.has(entry.id)) { + const { deliveryCheck: _deliveryCheck, ...confirmed } = entry + return confirmed + } + const acknowledgedBy = assignments.get(entry.id) + if (!acknowledgedBy || entry.deliveryCheck?.acknowledgedBy) { + return entry + } + return { + ...entry, + deliveryCheck: { ...entry.deliveryCheck, deadline: undefined, acknowledgedBy } + } + }) + ) + }, [messages, pending, promptSubmissions, updatePending]) + + useEffect(() => { + const liveIds = new Set() + for (const entry of pending) { + const deadline = entry.deliveryCheck?.deadline + if (deadline === undefined) { + continue + } + liveIds.add(entry.id) + if (timersRef.current.has(entry.id)) { + continue + } + const timer = setTimeout( + () => { + timersRef.current.delete(entry.id) + const cached = readPendingSendCache(scope).find((candidate) => candidate.id === entry.id) + if (!cached?.deliveryCheck) { + return + } + const cachedEntries = readPendingSendCache(scope) + const acknowledgedBy = assignMatchingPromptSubmissions( + cachedEntries, + submissionsRef.current + ).get(entry.id) + if (acknowledgedBy) { + setPending( + writePendingSendCache( + scope, + cachedEntries.map((candidate) => + candidate.id === entry.id && candidate.deliveryCheck + ? { + ...candidate, + deliveryCheck: { + ...candidate.deliveryCheck, + deadline: undefined, + acknowledgedBy + } + } + : candidate + ) + ) + ) + return + } + if (pendingSendsAsMessages([cached], messagesRef.current).length === 0) { + return + } + const next = readPendingSendCache(scope).filter((candidate) => candidate.id !== entry.id) + setPending(writePendingSendCache(scope, next)) + restoreMessageRef.current(cached.text, cached.imagePaths) + setFailed(true) + }, + Math.max(0, deadline - Date.now()) + ) + timersRef.current.set(entry.id, timer) + } + for (const [id, timer] of timersRef.current) { + if (!liveIds.has(id)) { + clearTimeout(timer) + timersRef.current.delete(id) + } + } + }, [pending, scope, setPending]) + + useEffect( + () => () => { + for (const timer of timersRef.current.values()) { + clearTimeout(timer) + } + timersRef.current.clear() + }, + [scope] + ) + + return { failed, clearFailure, markSubmitted } +} diff --git a/src/renderer/src/components/native-chat/use-native-chat-pending-delivery.ts b/src/renderer/src/components/native-chat/use-native-chat-pending-delivery.ts new file mode 100644 index 000000000000..11cb18347000 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-pending-delivery.ts @@ -0,0 +1,117 @@ +import { + useCallback, + useEffect, + useMemo, + useState, + type Dispatch, + type SetStateAction +} from 'react' +import type { + AgentPromptSubmissionOccurrence, + AgentType +} from '../../../../shared/agent-status-types' +import type { NativeChatMessage } from '../../../../shared/native-chat-types' +import { + appendPendingSendCache, + nextNativeChatPendingSendId, + prunePendingSends, + readPendingSendCache, + writePendingSendCache, + type NativeChatPendingSend +} from './native-chat-pending' +import { nativeChatPromptDigest } from './native-chat-prompt-delivery' +import { useNativeChatDeliveryRecovery } from './use-native-chat-delivery-recovery' + +export function useNativeChatPendingDelivery({ + paneKey, + agent, + messages, + promptSubmissions, + restoreMessage, + setWorkingInterrupted +}: { + paneKey: string + agent: AgentType + messages: NativeChatMessage[] + promptSubmissions: readonly AgentPromptSubmissionOccurrence[] + restoreMessage: (text: string, imagePaths?: string[]) => void + setWorkingInterrupted: Dispatch> +}): { + pending: NativeChatPendingSend[] + deliveryFailed: boolean + clearPending: () => void + onOptimisticSend: (text: string, imagePaths?: string[]) => string + onOptimisticSendCanceled: (pendingId: string) => void + markDeliverySubmitted: (pendingId: string) => void +} { + const scope = useMemo(() => ({ paneKey, agent }), [agent, paneKey]) + const [pending, setPending] = useState(() => readPendingSendCache(scope)) + const { + failed: deliveryFailed, + clearFailure, + markSubmitted: markDeliverySubmitted + } = useNativeChatDeliveryRecovery({ + scope, + pending, + setPending, + messages, + promptSubmissions, + restoreMessage + }) + + useEffect(() => { + setPending(readPendingSendCache(scope)) + setWorkingInterrupted(false) + }, [scope, setWorkingInterrupted]) + + useEffect(() => { + setPending((current) => writePendingSendCache(scope, prunePendingSends(current, messages))) + }, [messages, scope]) + + const clearPending = useCallback(() => { + setPending(writePendingSendCache(scope, [])) + }, [scope]) + + const onOptimisticSend = useCallback( + (text: string, imagePaths?: string[]): string => { + clearFailure() + setWorkingInterrupted(false) + const sentAt = Date.now() + const boundary = messages.at(-1) + const entry: NativeChatPendingSend = { + id: nextNativeChatPendingSendId(sentAt), + text, + sentAt, + afterMessageId: boundary?.id ?? null, + afterMessageTimestamp: boundary?.timestamp ?? null, + deliveryCheck: { + ...(text.trim().length > 0 && !imagePaths?.length + ? { expectedDigest: nativeChatPromptDigest(text) } + : {}), + ...(promptSubmissions.at(-1) ? { baseline: promptSubmissions.at(-1) } : {}) + }, + ...(imagePaths ? { imagePaths } : {}) + } + setPending(appendPendingSendCache(scope, entry)) + return entry.id + }, + [clearFailure, messages, promptSubmissions, scope, setWorkingInterrupted] + ) + + const onOptimisticSendCanceled = useCallback( + (pendingId: string) => { + const next = readPendingSendCache(scope).filter((entry) => entry.id !== pendingId) + setPending(writePendingSendCache(scope, next)) + }, + [scope] + ) + + return { + pending, + deliveryFailed, + clearPending, + onOptimisticSend, + onOptimisticSendCanceled, + markDeliverySubmitted + } +} diff --git a/src/renderer/src/components/native-chat/use-native-chat-send-lifecycle.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-send-lifecycle.test.tsx index a9bb8c3be33b..66aae2158610 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-send-lifecycle.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-send-lifecycle.test.tsx @@ -5,7 +5,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { useNativeChatSendLifecycle } from './use-native-chat-send-lifecycle' function handle(settleAfterMs = 500) { - return { cancel: vi.fn<() => void>(), settleAfterMs } + return { cancel: vi.fn<() => void>(), settleAfterMs, submitted: () => true } } describe('useNativeChatSendLifecycle', () => { @@ -92,4 +92,29 @@ describe('useNativeChatSendLifecycle', () => { resolveSettled() }) }) + + it('arms delivery recovery at submission before the send settles', async () => { + let resolveSubmission!: (submitted: boolean) => void + let resolveSettled!: () => void + const pending = { + ...handle(), + submission: new Promise((resolve) => { + resolveSubmission = resolve + }), + settled: new Promise((resolve) => { + resolveSettled = resolve + }) + } + const onPendingSendSubmitted = vi.fn() + const { result } = renderHook(() => + useNativeChatSendLifecycle('tab-1', 'pty-1', undefined, onPendingSendSubmitted) + ) + + act(() => result.current.trackPendingSend(pending, 'pending-1')) + expect(onPendingSendSubmitted).not.toHaveBeenCalled() + await act(async () => resolveSubmission(true)) + expect(onPendingSendSubmitted).toHaveBeenCalledWith('pending-1') + await act(async () => resolveSettled()) + expect(onPendingSendSubmitted).toHaveBeenCalledOnce() + }) }) diff --git a/src/renderer/src/components/native-chat/use-native-chat-send-lifecycle.ts b/src/renderer/src/components/native-chat/use-native-chat-send-lifecycle.ts index c325cb2200ea..c064ea2fc1e7 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-send-lifecycle.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-send-lifecycle.ts @@ -9,7 +9,8 @@ export type NativeChatSendLifecycle = { export function useNativeChatSendLifecycle( terminalTabId: string, targetPtyId: string | null, - onPendingSendCanceled?: (pendingId: string) => void + onPendingSendCanceled?: (pendingId: string) => void, + onPendingSendSubmitted?: (pendingId: string) => void ): NativeChatSendLifecycle { const pendingSendHandlesRef = useRef( new Map< @@ -30,24 +31,37 @@ export function useNativeChatSendLifecycle( } pendingSendHandlesRef.current.clear() }, [onPendingSendCanceled]) - const trackPendingSend = useCallback((handle: NativeChatSendHandle, pendingId?: string) => { - const entry = { - cleanupTimer: null as ReturnType | null, - ...(pendingId ? { pendingId } : {}) - } - pendingSendHandlesRef.current.set(handle, entry) - if (handle.settled) { - void handle.settled.then(() => { - if (pendingSendHandlesRef.current.get(handle) === entry) { - pendingSendHandlesRef.current.delete(handle) - } - }) - return - } - entry.cleanupTimer = setTimeout(() => { - pendingSendHandlesRef.current.delete(handle) - }, handle.settleAfterMs) - }, []) + const trackPendingSend = useCallback( + (handle: NativeChatSendHandle, pendingId?: string) => { + const entry = { + cleanupTimer: null as ReturnType | null, + ...(pendingId ? { pendingId } : {}) + } + pendingSendHandlesRef.current.set(handle, entry) + if (pendingId && handle.submission) { + void handle.submission.then((submitted) => { + if (submitted) { + onPendingSendSubmitted?.(pendingId) + } + }) + } + if (handle.settled) { + void handle.settled.then(() => { + if (pendingId && !handle.submission && handle.submitted?.() !== false) { + onPendingSendSubmitted?.(pendingId) + } + if (pendingSendHandlesRef.current.get(handle) === entry) { + pendingSendHandlesRef.current.delete(handle) + } + }) + return + } + entry.cleanupTimer = setTimeout(() => { + pendingSendHandlesRef.current.delete(handle) + }, handle.settleAfterMs) + }, + [onPendingSendSubmitted] + ) // Why: delayed Enter/image writes belong to the exact PTY target. A pane // swap or unmount must cancel them before that PTY can close or be reused. diff --git a/src/renderer/src/hooks/useIpcEvents.test.ts b/src/renderer/src/hooks/useIpcEvents.test.ts index c13cf20057a2..a194da6c70b3 100644 --- a/src/renderer/src/hooks/useIpcEvents.test.ts +++ b/src/renderer/src/hooks/useIpcEvents.test.ts @@ -4952,6 +4952,12 @@ describe('useIpcEvents agent status snapshot integration', () => { terminalHandle?: string launchToken?: string providerSession?: { key: 'session_id'; id: string } + promptSubmission?: { + streamId: string + sequence: number + digest: string + receivedAt: number + } orchestration?: { taskId?: string dispatchId?: string @@ -6073,6 +6079,12 @@ describe('useIpcEvents agent status snapshot integration', () => { state: 'working' as const, prompt: 'p', agentType: 'claude', + promptSubmission: { + streamId: 'stream-1', + sequence: 7, + digest: `sha256:${'a'.repeat(64)}`, + receivedAt: 1_700_000_000_000 + }, receivedAt: 1_700_000_000_000, stateStartedAt: 1_699_999_999_000 } @@ -6155,7 +6167,17 @@ describe('useIpcEvents agent status snapshot integration', () => { expect(setAgentStatus).toHaveBeenCalledTimes(1) expect(setAgentStatus).toHaveBeenCalledWith( FUTURE_PANE_KEY, - expect.objectContaining({ state: 'working', prompt: 'p', agentType: 'claude' }), + expect.objectContaining({ + state: 'working', + prompt: 'p', + agentType: 'claude', + promptSubmission: { + streamId: 'stream-1', + sequence: 7, + digest: `sha256:${'a'.repeat(64)}`, + receivedAt: 1_700_000_000_000 + } + }), 'Future Tab', { updatedAt: 1_700_000_000_000, stateStartedAt: 1_699_999_999_000 }, expectWorktreeRouting('wt-1'), diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index bed3e4e86c89..578771d3d443 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -3213,11 +3213,14 @@ export function useIpcEvents(): void { const statusPayloadWithTurnBoundary = data.promptInteractionKey ? { ...statusPayload, promptInteractionKey: data.promptInteractionKey } : statusPayload + const statusPayloadWithPromptSubmission = data.promptSubmission + ? { ...statusPayloadWithTurnBoundary, promptSubmission: data.promptSubmission } + : statusPayloadWithTurnBoundary // Why: hydrated-unconfirmed provenance is envelope data the payload whitelist above drops; re-thread it or freshness gates confirm restored rows. const statusPayloadWithProvenance = data.restoredUnconfirmed === true - ? { ...statusPayloadWithTurnBoundary, restoredUnconfirmed: true } - : statusPayloadWithTurnBoundary + ? { ...statusPayloadWithPromptSubmission, restoredUnconfirmed: true } + : statusPayloadWithPromptSubmission const identity = resolveAgentStatusIdentity({ existing: existingStatus ? { diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 39a7b1211673..78514e642fe8 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -14812,8 +14812,7 @@ "max": "Max", "on": "On", "off": "Off" - }, - "finishTerminalPrompt": "Finish the terminal prompt before sending" + } }, "tool": { "running": "Running…", @@ -14824,6 +14823,10 @@ "status": { "responding": "Agent is responding" }, + "deliveryRecovery": { + "title": "Message wasn’t sent", + "description": "The terminal may be waiting for input. Your message is still in the composer." + }, "jumpToLatest": "Jump to latest", "toggle": { "showTerminal": "Show terminal", diff --git a/src/renderer/src/store/slices/agent-prompt-submission-history.ts b/src/renderer/src/store/slices/agent-prompt-submission-history.ts new file mode 100644 index 000000000000..bee70d5d6e36 --- /dev/null +++ b/src/renderer/src/store/slices/agent-prompt-submission-history.ts @@ -0,0 +1,23 @@ +import { + AGENT_PROMPT_SUBMISSION_HISTORY_MAX, + type AgentPromptSubmissionOccurrence +} from '../../../../shared/agent-status-types' + +export function appendAgentPromptSubmission( + existing: AgentPromptSubmissionOccurrence[] | undefined, + occurrence: AgentPromptSubmissionOccurrence | undefined +): AgentPromptSubmissionOccurrence[] | undefined { + if (!occurrence) { + return existing + } + const previous = existing ?? [] + if ( + previous.some( + (candidate) => + candidate.streamId === occurrence.streamId && candidate.sequence === occurrence.sequence + ) + ) { + return existing + } + return [...previous, occurrence].slice(-AGENT_PROMPT_SUBMISSION_HISTORY_MAX) +} diff --git a/src/renderer/src/store/slices/agent-status-prompt-submission.test.ts b/src/renderer/src/store/slices/agent-status-prompt-submission.test.ts new file mode 100644 index 000000000000..0508f76865cc --- /dev/null +++ b/src/renderer/src/store/slices/agent-status-prompt-submission.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest' +import { AGENT_PROMPT_SUBMISSION_HISTORY_MAX } from '../../../../shared/agent-status-types' +import { createTestStore } from './store-test-helpers' + +describe('agent prompt submission occurrences', () => { + it('preserves a bounded deduped history through later status updates', () => { + const store = createTestStore() + const paneKey = 'tab-1:1' + + for (let sequence = 1; sequence <= AGENT_PROMPT_SUBMISSION_HISTORY_MAX + 2; sequence += 1) { + store.getState().setAgentStatus(paneKey, { + state: 'working', + prompt: `prompt ${sequence}`, + agentType: 'codex', + promptSubmission: { + streamId: 'stream-1', + sequence, + digest: `sha256:${String(sequence).padStart(64, '0')}`, + receivedAt: sequence + } + }) + } + + const bounded = store.getState().agentStatusByPaneKey[paneKey].promptSubmissions + expect(bounded?.map((occurrence) => occurrence.sequence)).toEqual( + Array.from({ length: AGENT_PROMPT_SUBMISSION_HISTORY_MAX }, (_, index) => index + 3) + ) + + store.getState().setAgentStatus(paneKey, { + state: 'working', + prompt: 'tool update', + agentType: 'codex', + toolName: 'Read' + }) + expect(store.getState().agentStatusByPaneKey[paneKey].promptSubmissions).toBe(bounded) + + const duplicate = bounded?.at(-1) + if (!duplicate) { + throw new Error('expected bounded prompt submission history') + } + store.getState().setAgentStatus(paneKey, { + state: 'working', + prompt: 'duplicate delivery', + agentType: 'codex', + promptSubmission: duplicate + }) + expect(store.getState().agentStatusByPaneKey[paneKey].promptSubmissions).toBe(bounded) + }) +}) diff --git a/src/renderer/src/store/slices/agent-status.ts b/src/renderer/src/store/slices/agent-status.ts index fcf39188136f..d42524bb938b 100644 --- a/src/renderer/src/store/slices/agent-status.ts +++ b/src/renderer/src/store/slices/agent-status.ts @@ -5,6 +5,7 @@ import { AGENT_STATUS_STALE_AFTER_MS, AGENT_STATE_HISTORY_MAX, agentSubagentsEqual, + type AgentPromptSubmissionOccurrence, type AgentStateHistoryEntry, type AgentStatusEntry, type AgentStatusOrchestrationContext, @@ -46,6 +47,7 @@ import { transferAgentPaneAuthorityAlias } from './agent-pane-authority' import { createFreshnessScheduler } from './agent-status-freshness-scheduler' +import { appendAgentPromptSubmission } from './agent-prompt-submission-history' /** Snapshot of a finished/vanished agent status entry, kept so the dashboard and sidebar hover * keep showing the completion until the user clicks the worktree. `worktreeId` is stamped at @@ -155,6 +157,7 @@ export type AgentStatusSlice = { payload: ParsedAgentStatusPayload & { orchestration?: AgentStatusOrchestrationContext promptInteractionKey?: string + promptSubmission?: AgentPromptSubmissionOccurrence restoredUnconfirmed?: boolean }, terminalTitle?: string, @@ -1889,6 +1892,10 @@ export const createAgentStatusSlice: StateCreator { expect(event!.payload.agentType).toBe('claude') }) + it.each(['claude', 'codex'] as const)( + 'hashes the full raw %s prompt only on UserPromptSubmit', + (source) => { + const prompt = ` ${'multiline prompt '.repeat(20)}\nemoji: 🐋\n${'tail'.repeat(80)} ` + const submitted = normalizeHookPayload( + state, + source, + { + paneKey: PANE_KEY, + payload: { hook_event_name: 'UserPromptSubmit', prompt } + }, + 'production' + ) + const tool = normalizeHookPayload( + state, + source, + { + paneKey: PANE_KEY, + payload: { hook_event_name: 'PreToolUse', tool_name: 'Read' } + }, + 'production' + ) + + expect(submitted?.payload.prompt.length).toBe(200) + expect(submitted?.submittedPromptDigest).toBe( + `sha256:${createHash('sha256').update(prompt).digest('hex')}` + ) + expect(tool?.submittedPromptDigest).toBeUndefined() + } + ) + it('normalizes Gemini BeforeTool to working with tool fields', () => { const event = normalizeHookPayload( state, diff --git a/src/shared/agent-hook-listener.ts b/src/shared/agent-hook-listener.ts index 985f95b1876a..6460ebe8706b 100644 --- a/src/shared/agent-hook-listener.ts +++ b/src/shared/agent-hook-listener.ts @@ -325,6 +325,8 @@ export type AgentHookEventPayload = { promptInteractionKey?: string /** Raw agent hook event name, used by main-process transition guards. */ hookEventName?: string + /** SHA-256 of a live UserPromptSubmit's full raw prompt before preview truncation. */ + submittedPromptDigest?: string /** Claude's provider-owned user-prompt UUID. */ providerPromptId?: string /** Active Claude compact generation, keyed by provider prompt identity. */ @@ -4161,6 +4163,14 @@ export function normalizeHookPayload( } const extractedPrompt = extractPromptText(hookPayload as Record) const promptText = extractedPrompt.text + const rawSubmittedPrompt = + extractedPrompt.source && typeof hookPayloadRecord[extractedPrompt.source] === 'string' + ? (hookPayloadRecord[extractedPrompt.source] as string) + : promptText + const submittedPromptDigest = + (source === 'codex' || source === 'claude') && eventName === 'UserPromptSubmit' + ? `sha256:${createHash('sha256').update(rawSubmittedPrompt).digest('hex')}` + : undefined let resolvedPromptText = promptText let hasTranscriptPromptEvidence = false // Why: exhaustive switch so a new AgentHookSource fails typecheck here instead of silently misrouting. @@ -4310,6 +4320,7 @@ export function normalizeHookPayload( ), promptInteractionKey, hookEventName: typeof eventName === 'string' ? eventName : undefined, + submittedPromptDigest, providerPromptId, compactTrigger, toolUseId: readFirstString(hookPayloadRecord, ['tool_use_id', 'toolUseId']), diff --git a/src/shared/agent-hook-relay.ts b/src/shared/agent-hook-relay.ts index 128397551bc2..ab4ba34c449e 100644 --- a/src/shared/agent-hook-relay.ts +++ b/src/shared/agent-hook-relay.ts @@ -84,6 +84,8 @@ export type AgentHookRelayEnvelope = { promptInteractionKey?: string /** Hook discriminator preserved for main-process transition rules. */ hookEventName?: string + /** Full raw prompt digest for live delivery acknowledgement. */ + submittedPromptDigest?: string /** Claude's provider-owned user-prompt UUID. */ providerPromptId?: string /** Active Claude compact generation, keyed by provider prompt identity. */ diff --git a/src/shared/agent-status-types.ts b/src/shared/agent-status-types.ts index 0d902ecb0fc1..6d9a0bbb3bc1 100644 --- a/src/shared/agent-status-types.ts +++ b/src/shared/agent-status-types.ts @@ -86,6 +86,20 @@ export type AgentSubagentSnapshot = { startedAt: number } +/** Exact, transient evidence that a pane accepted a user prompt through a provider hook. */ +export type AgentPromptSubmissionOccurrence = { + /** Identifies one main-process hook-server lifetime. */ + streamId: string + /** Monotonic within `streamId`; globally ordered across panes in that stream. */ + sequence: number + /** SHA-256 of the full raw prompt before status-preview normalization. */ + digest: string + /** Main-process receive time for restart-safe ordering. */ + receivedAt: number +} + +export const AGENT_PROMPT_SUBMISSION_HISTORY_MAX = 8 + export type AgentStatusEntry = { state: AgentStatusState /** The user's most recent prompt. Cached across the turn — later tool-use events @@ -137,6 +151,8 @@ export type AgentStatusEntry = { providerSession?: AgentProviderSessionMetadata /** Live-only Command Code turn boundary key; not persisted to last-status.json. */ promptInteractionKey?: string + /** Recent live prompt-submit occurrences for native-chat delivery acknowledgement. */ + promptSubmissions?: AgentPromptSubmissionOccurrence[] /** True for a nonterminal state hydrated from last-status.json with no live hook since: * the transition may have been missed while no receiver was up, so freshness gates * treat the row as stale immediately. Cleared by any accepted live event. */ @@ -229,6 +245,8 @@ export type AgentStatusIpcPayload = ParsedAgentStatusPayload & { providerSessionOnly?: boolean /** Live-only Command Code turn boundary key; not persisted to last-status.json. */ promptInteractionKey?: string + /** Present only on a live accepted UserPromptSubmit event, never restored from disk. */ + promptSubmission?: AgentPromptSubmissionOccurrence /** See AgentStatusEntry.restoredUnconfirmed — hydrated nonterminal provenance. */ restoredUnconfirmed?: boolean } diff --git a/src/shared/runtime-types.ts b/src/shared/runtime-types.ts index 99c22a2e7ef0..6e012e60b421 100644 --- a/src/shared/runtime-types.ts +++ b/src/shared/runtime-types.ts @@ -604,7 +604,7 @@ export type RuntimeTerminalSend = { handle: string accepted: boolean bytesWritten: number - refusedReason?: 'no-agent' | 'permission' | 'interactive-prompt' + refusedReason?: 'no-agent' | 'permission' } export type RuntimeTerminalAgentStatusState = 'working' | 'permission' | 'idle' | null diff --git a/src/shared/terminal-wait-blocked-detection.ts b/src/shared/terminal-wait-blocked-detection.ts deleted file mode 100644 index 690360cfc5d3..000000000000 --- a/src/shared/terminal-wait-blocked-detection.ts +++ /dev/null @@ -1,197 +0,0 @@ -import type { RuntimeTerminalWaitBlockedReason } from './runtime-types' - -export const TERMINAL_WAIT_BLOCKED_SENTINEL_RE = - /update available|choose working directory to|codex just got an upgrade|hooks need review|do you trust|trust this|trusted workspace|press enter to (?:confirm|continue|view|insert)|press t to trust/i - -export function detectTerminalWaitBlockedReason( - preview: string -): RuntimeTerminalWaitBlockedReason | null { - return findActionableTerminalWaitBlockedSignal(preview.toLowerCase())?.reason ?? null -} - -export function isKnownReadyPromptPreview(preview: string): boolean { - const normalized = preview.toLowerCase() - const readyIndex = findKnownReadyPromptIndex(normalized) - if (readyIndex === null) { - return false - } - const blockedSignal = findTerminalWaitBlockedSignal(normalized) - return blockedSignal === null || blockedSignal.index <= readyIndex -} - -export function findActionableTerminalWaitBlockedSignal( - normalized: string -): { reason: RuntimeTerminalWaitBlockedReason; index: number } | null { - const blockedSignal = findTerminalWaitBlockedSignal(normalized) - if (blockedSignal === null) { - return null - } - const dismissedModalIndex = findDismissedStartupModalIndex(normalized) - // Why: a live prompt after the modal means it was dismissed, including mid-run Cursor prompts. - return dismissedModalIndex !== null && dismissedModalIndex > blockedSignal.index - ? null - : blockedSignal -} - -function findDismissedStartupModalIndex(normalized: string): number | null { - const indexes = [ - findCodexReadyPromptIndex(normalized), - findAntigravityReadyPromptIndex(normalized), - findCursorActivePromptIndex(normalized) - ].filter((index): index is number => index !== null) - return indexes.length > 0 ? Math.max(...indexes) : null -} - -function findKnownReadyPromptIndex(normalized: string): number | null { - const indexes = [ - findCodexReadyPromptIndex(normalized), - findAntigravityReadyPromptIndex(normalized), - findCursorReadyPromptIndex(normalized) - ].filter((index): index is number => index !== null) - return indexes.length > 0 ? Math.max(...indexes) : null -} - -function findCursorActivePromptIndex(normalized: string): number | null { - const headerIndex = normalized.lastIndexOf('cursor agent') - if (headerIndex === -1) { - return null - } - return normalized.includes('→', headerIndex) ? headerIndex : null -} - -const CURSOR_BUSY_SPINNER_RE = /[⠁-⣿]/ - -function findCursorReadyPromptIndex(normalized: string): number | null { - const activeIndex = findCursorActivePromptIndex(normalized) - if (activeIndex === null) { - return null - } - return CURSOR_BUSY_SPINNER_RE.test(normalized.slice(activeIndex)) ? null : activeIndex -} - -function findCodexReadyPromptIndex(normalized: string): number | null { - const headerIndex = normalized.lastIndexOf('openai codex') - if (headerIndex === -1) { - return null - } - const readySegment = normalized.slice(headerIndex) - return readySegment.includes('model:') && readySegment.includes('directory:') ? headerIndex : null -} - -function findAntigravityReadyPromptIndex(normalized: string): number | null { - const headerIndex = normalized.lastIndexOf('antigravity cli') - if (headerIndex === -1) { - return null - } - let lineStart = headerIndex - let modelIndex: number | null = null - let promptIndex: number | null = null - - for (let cursor = headerIndex; cursor <= normalized.length; cursor += 1) { - if (cursor < normalized.length && normalized.charCodeAt(cursor) !== 10) { - continue - } - let trimmedStart = lineStart - let trimmedEnd = cursor - while (trimmedStart < trimmedEnd && isTerminalWaitWhitespace(normalized, trimmedStart)) { - trimmedStart += 1 - } - while (trimmedEnd > trimmedStart && isTerminalWaitWhitespace(normalized, trimmedEnd - 1)) { - trimmedEnd -= 1 - } - if (lineStart > headerIndex && trimmedStart < trimmedEnd) { - if (modelIndex === null && normalized.startsWith('gemini', trimmedStart)) { - modelIndex = trimmedStart - } - if ( - promptIndex === null && - trimmedEnd - trimmedStart === 1 && - normalized.charCodeAt(trimmedStart) === 62 - ) { - promptIndex = trimmedStart - } - } - lineStart = cursor + 1 - } - - return modelIndex !== null && promptIndex !== null ? Math.max(modelIndex, promptIndex) : null -} - -function isTerminalWaitWhitespace(value: string, index: number): boolean { - const code = value.charCodeAt(index) - return code === 32 || (code >= 9 && code <= 13) -} - -function findTerminalWaitBlockedSignal( - normalized: string -): { reason: RuntimeTerminalWaitBlockedReason; index: number } | null { - if (!TERMINAL_WAIT_BLOCKED_SENTINEL_RE.test(normalized)) { - return null - } - const candidates: { reason: RuntimeTerminalWaitBlockedReason; index: number }[] = [] - const updateIndex = normalized.lastIndexOf('update available') - if (updateIndex !== -1 && normalized.includes('press enter to continue', updateIndex)) { - candidates.push({ reason: 'codex-update-prompt', index: updateIndex }) - } - const cwdIndex = normalized.lastIndexOf('choose working directory to') - if (cwdIndex !== -1 && normalized.includes('press enter to continue', cwdIndex)) { - candidates.push({ reason: 'codex-cwd-prompt', index: cwdIndex }) - } - const modelMigrationIndex = normalized.lastIndexOf('codex just got an upgrade') - if ( - modelMigrationIndex !== -1 && - normalized.includes('press enter to continue', modelMigrationIndex) - ) { - candidates.push({ reason: 'codex-model-migration-prompt', index: modelMigrationIndex }) - } - const hooksIndex = normalized.lastIndexOf('hooks need review') - if (hooksIndex !== -1 && normalized.includes('press enter to confirm', hooksIndex)) { - candidates.push({ reason: 'codex-hooks-review-prompt', index: hooksIndex }) - } - const trustIndex = Math.max( - normalized.lastIndexOf('do you trust'), - normalized.lastIndexOf('trust this'), - normalized.lastIndexOf('trusted workspace') - ) - const trustSegment = trustIndex === -1 ? '' : normalized.slice(trustIndex) - if ( - trustIndex !== -1 && - (trustSegment.includes('workspace') || - trustSegment.includes('folder') || - trustSegment.includes('directory') || - trustSegment.includes('repo')) - ) { - candidates.push({ reason: 'codex-trust-workspace', index: trustIndex }) - } - const interactivePromptIndex = Math.max( - normalized.lastIndexOf('press enter to confirm'), - normalized.lastIndexOf('press enter to continue'), - normalized.lastIndexOf('press enter to view'), - normalized.lastIndexOf('press enter to insert'), - normalized.lastIndexOf('press t to trust') - ) - const interactivePromptContext = - interactivePromptIndex === -1 - ? '' - : normalized.slice(Math.max(0, interactivePromptIndex - 600), interactivePromptIndex + 200) - const hasCodexInteractiveContext = - interactivePromptContext.includes('codex') || - interactivePromptContext.includes('permission') || - interactivePromptContext.includes('sandbox') || - interactivePromptContext.includes('trust') || - interactivePromptContext.includes('hook') - if (interactivePromptIndex !== -1 && hasCodexInteractiveContext) { - const contextStart = Math.max(0, interactivePromptIndex - 600) - const hasSpecificPromptInContext = candidates.some( - (candidate) => candidate.index >= contextStart && candidate.index <= interactivePromptIndex - ) - if (!hasSpecificPromptInContext) { - candidates.push({ reason: 'codex-interactive-prompt', index: interactivePromptIndex }) - } - } - return candidates.length > 0 - ? candidates.reduce((latest, candidate) => - candidate.index > latest.index ? candidate : latest - ) - : null -} From 66154b1cbca65b28daf4ac9802908c6a8b45f6a2 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 5 Aug 2026 19:41:11 -0700 Subject: [PATCH 4/6] fix(native-chat): own delivery recovery timers --- .../use-native-chat-delivery-recovery.ts | 130 ++++++++---------- 1 file changed, 59 insertions(+), 71 deletions(-) diff --git a/src/renderer/src/components/native-chat/use-native-chat-delivery-recovery.ts b/src/renderer/src/components/native-chat/use-native-chat-delivery-recovery.ts index b572842856c6..48cfd93491b3 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-delivery-recovery.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-delivery-recovery.ts @@ -22,6 +22,23 @@ type DeliveryRecoveryArgs = { restoreMessage: (text: string, imagePaths?: string[]) => void } +function scheduleDeliveryChecks( + pending: readonly NativeChatPendingSend[], + onDeadline: (entry: NativeChatPendingSend) => void +): () => void { + const timers = pending.flatMap((entry) => { + const deadline = entry.deliveryCheck?.deadline + return deadline === undefined + ? [] + : [setTimeout(() => onDeadline(entry), Math.max(0, deadline - Date.now()))] + }) + return () => { + for (const timer of timers) { + clearTimeout(timer) + } + } +} + export function useNativeChatDeliveryRecovery({ scope, pending, @@ -34,8 +51,7 @@ export function useNativeChatDeliveryRecovery({ clearFailure: () => void markSubmitted: (pendingId: string) => void } { - const [failed, setFailed] = useState(false) - const timersRef = useRef(new Map>()) + const [failedScope, setFailedScope] = useState(null) const messagesRef = useRef(messages) const submissionsRef = useRef(promptSubmissions) const restoreMessageRef = useRef(restoreMessage) @@ -74,9 +90,8 @@ export function useNativeChatDeliveryRecovery({ }, [updatePending] ) - const clearFailure = useCallback(() => setFailed(false), []) - - useEffect(() => setFailed(false), [scope]) + const clearFailure = useCallback(() => setFailedScope(null), []) + const failed = failedScope?.paneKey === scope.paneKey && failedScope.agent === scope.agent useEffect(() => { const assignments = assignMatchingPromptSubmissions(pending, promptSubmissions) @@ -113,78 +128,51 @@ export function useNativeChatDeliveryRecovery({ ) }, [messages, pending, promptSubmissions, updatePending]) - useEffect(() => { - const liveIds = new Set() - for (const entry of pending) { - const deadline = entry.deliveryCheck?.deadline - if (deadline === undefined) { - continue - } - liveIds.add(entry.id) - if (timersRef.current.has(entry.id)) { - continue + const handleDeliveryDeadline = useCallback( + (entry: NativeChatPendingSend) => { + const cached = readPendingSendCache(scope).find((candidate) => candidate.id === entry.id) + if (!cached?.deliveryCheck) { + return } - const timer = setTimeout( - () => { - timersRef.current.delete(entry.id) - const cached = readPendingSendCache(scope).find((candidate) => candidate.id === entry.id) - if (!cached?.deliveryCheck) { - return - } - const cachedEntries = readPendingSendCache(scope) - const acknowledgedBy = assignMatchingPromptSubmissions( - cachedEntries, - submissionsRef.current - ).get(entry.id) - if (acknowledgedBy) { - setPending( - writePendingSendCache( - scope, - cachedEntries.map((candidate) => - candidate.id === entry.id && candidate.deliveryCheck - ? { - ...candidate, - deliveryCheck: { - ...candidate.deliveryCheck, - deadline: undefined, - acknowledgedBy - } - } - : candidate - ) - ) + const cachedEntries = readPendingSendCache(scope) + const acknowledgedBy = assignMatchingPromptSubmissions( + cachedEntries, + submissionsRef.current + ).get(entry.id) + if (acknowledgedBy) { + setPending( + writePendingSendCache( + scope, + cachedEntries.map((candidate) => + candidate.id === entry.id && candidate.deliveryCheck + ? { + ...candidate, + deliveryCheck: { + ...candidate.deliveryCheck, + deadline: undefined, + acknowledgedBy + } + } + : candidate ) - return - } - if (pendingSendsAsMessages([cached], messagesRef.current).length === 0) { - return - } - const next = readPendingSendCache(scope).filter((candidate) => candidate.id !== entry.id) - setPending(writePendingSendCache(scope, next)) - restoreMessageRef.current(cached.text, cached.imagePaths) - setFailed(true) - }, - Math.max(0, deadline - Date.now()) - ) - timersRef.current.set(entry.id, timer) - } - for (const [id, timer] of timersRef.current) { - if (!liveIds.has(id)) { - clearTimeout(timer) - timersRef.current.delete(id) + ) + ) + return } - } - }, [pending, scope, setPending]) - - useEffect( - () => () => { - for (const timer of timersRef.current.values()) { - clearTimeout(timer) + if (pendingSendsAsMessages([cached], messagesRef.current).length === 0) { + return } - timersRef.current.clear() + const next = readPendingSendCache(scope).filter((candidate) => candidate.id !== entry.id) + setPending(writePendingSendCache(scope, next)) + restoreMessageRef.current(cached.text, cached.imagePaths) + setFailedScope(scope) }, - [scope] + [scope, setPending] ) + useEffect(() => { + return scheduleDeliveryChecks(pending, handleDeliveryDeadline) + }, [handleDeliveryDeadline, pending]) + return { failed, clearFailure, markSubmitted } } From f267f8e4c6a5d7527f326d2985b0ab9817af530d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 5 Aug 2026 19:48:21 -0700 Subject: [PATCH 5/6] fix(native-chat): synchronize delivery refs after commit --- ...se-mobile-native-chat-delivery-recovery.ts | 21 ++++++++++++++----- .../session/use-mobile-native-chat-drafts.ts | 2 +- .../use-native-chat-delivery-recovery.ts | 19 +++++++++++++---- 3 files changed, 32 insertions(+), 10 deletions(-) diff --git a/mobile/src/session/use-mobile-native-chat-delivery-recovery.ts b/mobile/src/session/use-mobile-native-chat-delivery-recovery.ts index 8728942d40fe..1712c38dd0a9 100644 --- a/mobile/src/session/use-mobile-native-chat-delivery-recovery.ts +++ b/mobile/src/session/use-mobile-native-chat-delivery-recovery.ts @@ -1,4 +1,12 @@ -import { useCallback, useEffect, useRef, useState, type Dispatch, type SetStateAction } from 'react' +import { + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, + type Dispatch, + type SetStateAction +} from 'react' import type { AgentPromptSubmissionOccurrence } from '../../../src/shared/agent-status-types' import type { NativeChatMessage } from '../../../src/shared/native-chat-types' import { @@ -62,17 +70,20 @@ export function useMobileNativeChatDeliveryRecovery({ readPromptSubmissionBaseline: () => AgentPromptSubmissionOccurrence | undefined } { const activeDraftKeyRef = useRef(draftKey) - activeDraftKeyRef.current = draftKey const activePendingKeyRef = useRef(pendingKey) - activePendingKeyRef.current = pendingKey const messagesRef = useRef(messages) - messagesRef.current = messages const promptSubmissionsRef = useRef(promptSubmissions) - promptSubmissionsRef.current = promptSubmissions const pendingCounterRef = useRef(0) const deliveryChecksRef = useRef([]) const [failureDraftKey, setFailureDraftKey] = useState(null) + useLayoutEffect(() => { + activeDraftKeyRef.current = draftKey + activePendingKeyRef.current = pendingKey + messagesRef.current = messages + promptSubmissionsRef.current = promptSubmissions + }, [draftKey, messages, pendingKey, promptSubmissions]) + const readPromptSubmissionBaseline = useCallback(() => promptSubmissionsRef.current.at(-1), []) const trackAcceptedTextSend = useCallback( diff --git a/mobile/src/session/use-mobile-native-chat-drafts.ts b/mobile/src/session/use-mobile-native-chat-drafts.ts index 97a1a7cd4241..8603ebf80754 100644 --- a/mobile/src/session/use-mobile-native-chat-drafts.ts +++ b/mobile/src/session/use-mobile-native-chat-drafts.ts @@ -154,7 +154,7 @@ export function useMobileNativeChatDrafts(args: { pendingKey, normalizedText, baselineOccurrences: countUserTextOccurrences(currentMessages, normalizedText), - baselineTailMessageId: currentMessages[currentMessages.length - 1]?.id ?? null, + baselineTailMessageId: currentMessages.at(-1)?.id ?? null, ...(promptSubmissionBaseline ? { promptSubmissionBaseline } : {}) } }, diff --git a/src/renderer/src/components/native-chat/use-native-chat-delivery-recovery.ts b/src/renderer/src/components/native-chat/use-native-chat-delivery-recovery.ts index 48cfd93491b3..43196baab1fa 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-delivery-recovery.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-delivery-recovery.ts @@ -1,4 +1,12 @@ -import { useCallback, useEffect, useRef, useState, type Dispatch, type SetStateAction } from 'react' +import { + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, + type Dispatch, + type SetStateAction +} from 'react' import type { AgentPromptSubmissionOccurrence } from '../../../../shared/agent-status-types' import type { NativeChatMessage } from '../../../../shared/native-chat-types' import { @@ -55,9 +63,12 @@ export function useNativeChatDeliveryRecovery({ const messagesRef = useRef(messages) const submissionsRef = useRef(promptSubmissions) const restoreMessageRef = useRef(restoreMessage) - messagesRef.current = messages - submissionsRef.current = promptSubmissions - restoreMessageRef.current = restoreMessage + + useLayoutEffect(() => { + messagesRef.current = messages + submissionsRef.current = promptSubmissions + restoreMessageRef.current = restoreMessage + }, [messages, promptSubmissions, restoreMessage]) const updatePending = useCallback( (updater: (entries: NativeChatPendingSend[]) => NativeChatPendingSend[]) => { From 7988d8e966aab9d9b34c92f48076f4afe1e012a8 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 5 Aug 2026 20:23:42 -0700 Subject: [PATCH 6/6] fix(native-chat): avoid false image delivery failures --- .../use-native-chat-pending-delivery.test.tsx | 39 +++++++++++++++++++ .../use-native-chat-pending-delivery.ts | 14 ++++--- 2 files changed, 47 insertions(+), 6 deletions(-) create mode 100644 src/renderer/src/components/native-chat/use-native-chat-pending-delivery.test.tsx diff --git a/src/renderer/src/components/native-chat/use-native-chat-pending-delivery.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-pending-delivery.test.tsx new file mode 100644 index 000000000000..bf54e16a9b1d --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-pending-delivery.test.tsx @@ -0,0 +1,39 @@ +// @vitest-environment happy-dom + +import { act, renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it } from 'vitest' +import { clearPendingSendCacheForTests } from './native-chat-pending' +import { useNativeChatPendingDelivery } from './use-native-chat-pending-delivery' + +describe('useNativeChatPendingDelivery', () => { + beforeEach(() => clearPendingSendCacheForTests()) + + it('arms exact acknowledgement recovery only for text-only sends', () => { + const messages = [] + const promptSubmissions = [] + const restoreMessage = (): void => {} + const setWorkingInterrupted = (): void => {} + const { result } = renderHook(() => + useNativeChatPendingDelivery({ + paneKey: 'tab:leaf', + agent: 'claude', + messages, + promptSubmissions, + restoreMessage, + setWorkingInterrupted + }) + ) + + act(() => { + result.current.onOptimisticSend('text only') + result.current.onOptimisticSend('with image', ['/tmp/image.png']) + result.current.onOptimisticSend('', ['/tmp/image-only.png']) + }) + + expect(result.current.pending.map((entry) => entry.deliveryCheck !== undefined)).toEqual([ + true, + false, + false + ]) + }) +}) diff --git a/src/renderer/src/components/native-chat/use-native-chat-pending-delivery.ts b/src/renderer/src/components/native-chat/use-native-chat-pending-delivery.ts index 11cb18347000..41e21022eb27 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-pending-delivery.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-pending-delivery.ts @@ -84,12 +84,14 @@ export function useNativeChatPendingDelivery({ sentAt, afterMessageId: boundary?.id ?? null, afterMessageTimestamp: boundary?.timestamp ?? null, - deliveryCheck: { - ...(text.trim().length > 0 && !imagePaths?.length - ? { expectedDigest: nativeChatPromptDigest(text) } - : {}), - ...(promptSubmissions.at(-1) ? { baseline: promptSubmissions.at(-1) } : {}) - }, + ...(text.trim().length > 0 && !imagePaths?.length + ? { + deliveryCheck: { + expectedDigest: nativeChatPromptDigest(text), + ...(promptSubmissions.at(-1) ? { baseline: promptSubmissions.at(-1) } : {}) + } + } + : {}), ...(imagePaths ? { imagePaths } : {}) } setPending(appendPendingSendCache(scope, entry))