From efdecf98f4c2a2d51b50d859f1848ba69d6573d7 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Thu, 19 Mar 2026 08:56:10 +0000 Subject: [PATCH 01/33] =?UTF-8?q?=F0=9F=A4=96=20fix:=20detect=20pending=20?= =?UTF-8?q?ask=5Fuser=5Fquestion=20within=20latest=20turn?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ensure awaitingUserQuestion remains true when ask_user_question is followed by additional tool/text parts in the same assistant message. Also add regression tests for same-turn trailing parts and stale historical question turns. --- _Generated with `mux` • Model: `openai:gpt-5.3-codex` • Thinking: `xhigh` • Cost: `$5.41`_ --- .../StreamingMessageAggregator.status.test.ts | 96 +++++++++++++++++++ .../messages/StreamingMessageAggregator.ts | 32 +++++-- 2 files changed, 121 insertions(+), 7 deletions(-) diff --git a/src/browser/utils/messages/StreamingMessageAggregator.status.test.ts b/src/browser/utils/messages/StreamingMessageAggregator.status.test.ts index f99d1f3b7d..dbc03e17b7 100644 --- a/src/browser/utils/messages/StreamingMessageAggregator.status.test.ts +++ b/src/browser/utils/messages/StreamingMessageAggregator.status.test.ts @@ -93,6 +93,102 @@ describe("ask_user_question waiting state", () => { expect(aggregator.hasAwaitingUserQuestion()).toBe(true); }); + + it("keeps awaiting state when ask_user_question is followed by other parts in the same turn", () => { + const aggregator = new StreamingMessageAggregator("2024-01-01T00:00:00.000Z"); + + aggregator.loadHistoricalMessages([ + { + id: "assistant-1", + role: "assistant" as const, + parts: [ + { + type: "dynamic-tool" as const, + toolCallId: "call-ask-1", + toolName: "ask_user_question", + state: "input-available" as const, + input: { + questions: [ + { + header: "Approach", + question: "Which approach should we take?", + options: [ + { label: "A", description: "Approach A" }, + { label: "B", description: "Approach B" }, + ], + multiSelect: false, + }, + ], + }, + }, + { + type: "dynamic-tool" as const, + toolCallId: "call-todo-1", + toolName: "todo_write", + state: "output-available" as const, + input: { todos: [{ content: "Waiting for answers", status: "in_progress" }] }, + output: { success: true }, + }, + { type: "text" as const, text: "Please answer the question above." }, + ], + metadata: { + timestamp: 1000, + historySequence: 1, + partial: true, + }, + }, + ]); + + expect(aggregator.hasAwaitingUserQuestion()).toBe(true); + }); + + it("does not treat older question turns as awaiting after chat moves on", () => { + const aggregator = new StreamingMessageAggregator("2024-01-01T00:00:00.000Z"); + + aggregator.loadHistoricalMessages([ + { + id: "assistant-1", + role: "assistant" as const, + parts: [ + { + type: "dynamic-tool" as const, + toolCallId: "call-ask-1", + toolName: "ask_user_question", + state: "input-available" as const, + input: { + questions: [ + { + header: "Approach", + question: "Which approach should we take?", + options: [ + { label: "A", description: "Approach A" }, + { label: "B", description: "Approach B" }, + ], + multiSelect: false, + }, + ], + }, + }, + ], + metadata: { + timestamp: 1000, + historySequence: 1, + partial: true, + }, + }, + { + id: "user-2", + role: "user" as const, + parts: [{ type: "text" as const, text: "Skipping this and moving on" }], + metadata: { + timestamp: 2000, + historySequence: 2, + }, + }, + ]); + + expect(aggregator.hasAwaitingUserQuestion()).toBe(false); + }); }); describe("StreamingMessageAggregator - Agent Status", () => { diff --git a/src/browser/utils/messages/StreamingMessageAggregator.ts b/src/browser/utils/messages/StreamingMessageAggregator.ts index 75785a453d..87fc491c65 100644 --- a/src/browser/utils/messages/StreamingMessageAggregator.ts +++ b/src/browser/utils/messages/StreamingMessageAggregator.ts @@ -716,19 +716,37 @@ export class StreamingMessageAggregator { * Used to show "Awaiting your input" instead of "streaming..." in the UI. */ hasAwaitingUserQuestion(): boolean { - // Only treat the workspace as "awaiting input" when the *latest* displayed - // message is an executing ask_user_question tool. - // - // This avoids false positives from stale historical partials if the user - // continued the chat after skipping/canceling the questions. const displayed = this.getDisplayedMessages(); const last = displayed[displayed.length - 1]; - if (last?.type !== "tool") { + if (!last || !("historyId" in last)) { return false; } - return last.toolName === "ask_user_question" && last.status === "executing"; + // Treat the latest assistant turn as "awaiting input" when it contains an + // executing ask_user_question tool, even if later parts in the same turn + // (text or other tool calls) were emitted after the question. + // + // We intentionally scope to the latest historyId only so stale historical + // questions do not keep the workspace in an awaiting state once the chat has + // moved on to a newer message. + const latestHistoryId = last.historyId; + for (let i = displayed.length - 1; i >= 0; i--) { + const message = displayed[i]; + if (!("historyId" in message) || message.historyId !== latestHistoryId) { + break; + } + + if ( + message.type === "tool" && + message.toolName === "ask_user_question" && + message.status === "executing" + ) { + return true; + } + } + + return false; } /** From 5696c31bca04f5faae4849660fa161b93f5c1b7d Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Thu, 19 Mar 2026 09:12:50 +0000 Subject: [PATCH 02/33] =?UTF-8?q?=F0=9F=A4=96=20fix:=20suppress=20interrup?= =?UTF-8?q?tion=20UI=20for=20pending=20question=20turns?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Align interruption/retry detection with awaiting-question behavior when ask_user_question is followed by later parts in the same assistant turn. Also make awaitingUserQuestion read from untruncated history to avoid losing waiting state when transcript display truncates older rows from the latest turn. --- _Generated with `mux` • Model: `openai:gpt-5.3-codex` • Thinking: `xhigh` • Cost: `$5.41`_ --- src/browser/components/ChatPane/ChatPane.tsx | 4 +- .../StreamingMessageAggregator.status.test.ts | 55 +++++++++++++++++++ .../messages/StreamingMessageAggregator.ts | 48 ++++++++-------- .../utils/messages/messageUtils.test.ts | 33 +++++++++++ src/browser/utils/messages/messageUtils.ts | 21 +++++-- .../utils/messages/retryEligibility.test.ts | 53 ++++++++++++++++++ src/common/utils/messages/retryEligibility.ts | 47 +++++++++++++--- 7 files changed, 224 insertions(+), 37 deletions(-) diff --git a/src/browser/components/ChatPane/ChatPane.tsx b/src/browser/components/ChatPane/ChatPane.tsx index 8b31d89ffd..77c9b26b6e 100644 --- a/src/browser/components/ChatPane/ChatPane.tsx +++ b/src/browser/components/ChatPane/ChatPane.tsx @@ -860,7 +860,9 @@ export const ChatPane: React.FC = (props) => { /> )} {isAtCutoff && } - {shouldShowInterruptedBarrier(msg) && } + {shouldShowInterruptedBarrier(msg, deferredMessages) && ( + + )} ); })} diff --git a/src/browser/utils/messages/StreamingMessageAggregator.status.test.ts b/src/browser/utils/messages/StreamingMessageAggregator.status.test.ts index dbc03e17b7..4b120af205 100644 --- a/src/browser/utils/messages/StreamingMessageAggregator.status.test.ts +++ b/src/browser/utils/messages/StreamingMessageAggregator.status.test.ts @@ -189,6 +189,61 @@ describe("ask_user_question waiting state", () => { expect(aggregator.hasAwaitingUserQuestion()).toBe(false); }); + + it("keeps awaiting state even when display truncation hides the ask_user_question row", () => { + const aggregator = new StreamingMessageAggregator("2024-01-01T00:00:00.000Z"); + + const trailingToolParts = Array.from({ length: 80 }, (_, index) => ({ + type: "dynamic-tool" as const, + toolCallId: `call-todo-${index}`, + toolName: "todo_write", + state: "output-available" as const, + input: { todos: [{ content: `Task ${index}`, status: "in_progress" }] }, + output: { success: true }, + })); + + aggregator.loadHistoricalMessages([ + { + id: "assistant-1", + role: "assistant" as const, + parts: [ + { + type: "dynamic-tool" as const, + toolCallId: "call-ask-1", + toolName: "ask_user_question", + state: "input-available" as const, + input: { + questions: [ + { + header: "Approach", + question: "Which approach should we take?", + options: [ + { label: "A", description: "Approach A" }, + { label: "B", description: "Approach B" }, + ], + multiSelect: false, + }, + ], + }, + }, + ...trailingToolParts, + ], + metadata: { + timestamp: 1000, + historySequence: 1, + partial: true, + }, + }, + ]); + + const displayed = aggregator.getDisplayedMessages(); + expect( + displayed.some( + (message) => message.type === "tool" && message.toolName === "ask_user_question" + ) + ).toBe(false); + expect(aggregator.hasAwaitingUserQuestion()).toBe(true); + }); }); describe("StreamingMessageAggregator - Agent Status", () => { diff --git a/src/browser/utils/messages/StreamingMessageAggregator.ts b/src/browser/utils/messages/StreamingMessageAggregator.ts index 87fc491c65..a4f95fae40 100644 --- a/src/browser/utils/messages/StreamingMessageAggregator.ts +++ b/src/browser/utils/messages/StreamingMessageAggregator.ts @@ -716,34 +716,32 @@ export class StreamingMessageAggregator { * Used to show "Awaiting your input" instead of "streaming..." in the UI. */ hasAwaitingUserQuestion(): boolean { - const displayed = this.getDisplayedMessages(); - const last = displayed[displayed.length - 1]; + const showSyntheticMessages = + typeof window !== "undefined" && window.api?.debugLlmRequest === true; - if (!last || !("historyId" in last)) { - return false; - } + // Use untruncated history so long turns cannot hide an awaiting question + // behind history-hidden markers in getDisplayedMessages(). + const allMessages = this.getAllMessages(); - // Treat the latest assistant turn as "awaiting input" when it contains an - // executing ask_user_question tool, even if later parts in the same turn - // (text or other tool calls) were emitted after the question. - // - // We intentionally scope to the latest historyId only so stale historical - // questions do not keep the workspace in an awaiting state once the chat has - // moved on to a newer message. - const latestHistoryId = last.historyId; - for (let i = displayed.length - 1; i >= 0; i--) { - const message = displayed[i]; - if (!("historyId" in message) || message.historyId !== latestHistoryId) { - break; - } - - if ( - message.type === "tool" && - message.toolName === "ask_user_question" && - message.status === "executing" - ) { - return true; + // Find the latest history message that is visible in the transcript. + for (let i = allMessages.length - 1; i >= 0; i--) { + const message = allMessages[i]; + const isSynthetic = message.metadata?.synthetic === true; + const isUiVisibleSynthetic = message.metadata?.uiVisible === true; + if (isSynthetic && !showSyntheticMessages && !isUiVisibleSynthetic) { + continue; } + + if (message.role !== "assistant") { + return false; + } + + return message.parts.some( + (part) => + isDynamicToolPart(part) && + part.toolName === "ask_user_question" && + part.state === "input-available" + ); } return false; diff --git a/src/browser/utils/messages/messageUtils.test.ts b/src/browser/utils/messages/messageUtils.test.ts index adb754c52e..7cae2732dd 100644 --- a/src/browser/utils/messages/messageUtils.test.ts +++ b/src/browser/utils/messages/messageUtils.test.ts @@ -28,6 +28,39 @@ describe("shouldShowInterruptedBarrier", () => { expect(shouldShowInterruptedBarrier(msg)).toBe(false); }); + it("returns false for trailing partial rows when latest turn contains executing ask_user_question", () => { + const questionTool: DisplayedMessage = { + type: "tool", + id: "tool-ask", + historyId: "assistant-1", + toolName: "ask_user_question", + toolCallId: "call-ask", + args: { questions: [] }, + status: "executing", + isPartial: true, + historySequence: 2, + streamSequence: 0, + isLastPartOfMessage: false, + }; + + const trailingPartialText: DisplayedMessage = { + type: "assistant", + id: "assistant-tail", + historyId: "assistant-1", + content: "Please answer above.", + historySequence: 2, + streamSequence: 1, + isStreaming: false, + isPartial: true, + isLastPartOfMessage: true, + isCompacted: false, + isIdleCompacted: false, + }; + + const messages = [questionTool, trailingPartialText]; + + expect(shouldShowInterruptedBarrier(trailingPartialText, messages)).toBe(false); + }); it("returns false for decorative compaction boundary rows", () => { const msg: DisplayedMessage = { type: "compaction-boundary", diff --git a/src/browser/utils/messages/messageUtils.ts b/src/browser/utils/messages/messageUtils.ts index 2495d7ae3e..68a53645c3 100644 --- a/src/browser/utils/messages/messageUtils.ts +++ b/src/browser/utils/messages/messageUtils.ts @@ -1,6 +1,10 @@ import type { DisplayedMessage } from "@/common/types/message"; import { formatReviewForModel } from "@/common/types/review"; import type { BashOutputToolArgs } from "@/common/types/tools"; +import { + getLastNonDecorativeMessage, + hasExecutingAskUserQuestionInLatestTurn, +} from "@/common/utils/messages/retryEligibility"; /** * Returns the text that should be placed into the ChatInput when editing a user message. @@ -72,7 +76,10 @@ export interface BashOutputGroupInfo { * - Message was interrupted (isPartial) AND not currently streaming * - For multi-part messages, only show on the last part */ -export function shouldShowInterruptedBarrier(msg: DisplayedMessage): boolean { +export function shouldShowInterruptedBarrier( + msg: DisplayedMessage, + allMessages: DisplayedMessage[] = [msg] +): boolean { if ( msg.type === "user" || msg.type === "stream-error" || @@ -85,9 +92,15 @@ export function shouldShowInterruptedBarrier(msg: DisplayedMessage): boolean { // ask_user_question is intentionally a "waiting for input" state. Even if the // underlying message is a persisted partial (e.g. after app restart), we keep - // it answerable instead of showing "Interrupted". - if (msg.type === "tool" && msg.toolName === "ask_user_question" && msg.status === "executing") { - return false; + // the full latest turn answerable instead of showing "Interrupted" on any + // trailing parts from that same assistant message. + if (hasExecutingAskUserQuestionInLatestTurn(allMessages)) { + const lastMessage = getLastNonDecorativeMessage(allMessages); + if (lastMessage && "historyId" in msg && "historyId" in lastMessage) { + if (msg.historyId === lastMessage.historyId) { + return false; + } + } } // Only show on the last part of multi-part messages diff --git a/src/common/utils/messages/retryEligibility.test.ts b/src/common/utils/messages/retryEligibility.test.ts index a391280bcc..96cf301586 100644 --- a/src/common/utils/messages/retryEligibility.test.ts +++ b/src/common/utils/messages/retryEligibility.test.ts @@ -170,6 +170,59 @@ describe("hasInterruptedStream", () => { expect(hasInterruptedStream(messages)).toBe(false); }); + + it("returns false when latest turn contains executing ask_user_question plus trailing parts", () => { + const messages: DisplayedMessage[] = [ + { + type: "user", + id: "user-1", + historyId: "user-1", + content: "Hello", + historySequence: 1, + }, + { + type: "tool", + id: "tool-ask", + historyId: "assistant-1", + toolName: "ask_user_question", + toolCallId: "call-ask", + args: { questions: [] }, + status: "executing", + isPartial: true, + historySequence: 2, + streamSequence: 0, + isLastPartOfMessage: false, + }, + { + type: "tool", + id: "tool-todo", + historyId: "assistant-1", + toolName: "todo_write", + toolCallId: "call-todo", + args: { todos: [] }, + status: "completed", + isPartial: true, + historySequence: 2, + streamSequence: 1, + isLastPartOfMessage: false, + }, + { + type: "assistant", + id: "assistant-tail", + historyId: "assistant-1", + content: "Please answer above.", + historySequence: 2, + streamSequence: 2, + isStreaming: false, + isPartial: true, + isLastPartOfMessage: true, + isCompacted: false, + isIdleCompacted: false, + }, + ]; + + expect(hasInterruptedStream(messages)).toBe(false); + }); it("returns true for partial tool message", () => { const messages: DisplayedMessage[] = [ { diff --git a/src/common/utils/messages/retryEligibility.ts b/src/common/utils/messages/retryEligibility.ts index bb1ac38e99..b8d9665e3e 100644 --- a/src/common/utils/messages/retryEligibility.ts +++ b/src/common/utils/messages/retryEligibility.ts @@ -114,6 +114,42 @@ export function getLastNonDecorativeMessage( return undefined; } +/** + * Check whether the latest non-decorative turn is intentionally waiting on + * ask_user_question input. + * + * We scope this to the latest historyId turn so stale historical questions do + * not suppress interruption/retry UI once conversation has moved on. + */ +export function hasExecutingAskUserQuestionInLatestTurn(messages: DisplayedMessage[]): boolean { + const lastMessage = getLastNonDecorativeMessage(messages); + if (!lastMessage || !("historyId" in lastMessage)) { + return false; + } + + const latestHistoryId = lastMessage.historyId; + for (let i = messages.length - 1; i >= 0; i--) { + const message = messages[i]; + if (!("historyId" in message)) { + continue; + } + + if (message.historyId !== latestHistoryId) { + break; + } + + if ( + message.type === "tool" && + message.toolName === "ask_user_question" && + message.status === "executing" + ) { + return true; + } + } + + return false; +} + /** * Check if messages contain an interrupted stream * @@ -169,13 +205,10 @@ function computeHasInterruptedStream( // ask_user_question is a special case: an unfinished tool call represents an // intentional "waiting for user input" state, not a stream interruption. // - // Treating it as interrupted causes RetryBarrier + auto-resume to fire on app - // restart, which re-runs the LLM call and re-asks the questions. - if ( - lastMessage.type === "tool" && - lastMessage.toolName === "ask_user_question" && - lastMessage.status === "executing" - ) { + // We suppress interruption/retry for the entire latest turn when it contains + // an executing ask_user_question call, including cases where later parts in + // the same turn were emitted after the question. + if (hasExecutingAskUserQuestionInLatestTurn(messages)) { return false; } From cff3aa7667cdf551e8cb2456ad2f32a27cd2f7a1 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Thu, 19 Mar 2026 09:25:55 +0000 Subject: [PATCH 03/33] =?UTF-8?q?=F0=9F=A4=96=20fix:=20preserve=20error=20?= =?UTF-8?q?retry=20states=20for=20failed=20question=20turns?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Do not treat turns as awaiting-input when the latest visible state is a stream error, even if ask_user_question remains input-available in preserved tool parts. This keeps retry/interrupted UX available for genuine provider/network failures that happen mid-tool. --- _Generated with `mux` • Model: `openai:gpt-5.3-codex` • Thinking: `xhigh` • Cost: `$5.41`_ --- .../StreamingMessageAggregator.status.test.ts | 41 +++++++++++++++++++ .../messages/StreamingMessageAggregator.ts | 6 +++ .../utils/messages/retryEligibility.test.ts | 28 +++++++++++++ src/common/utils/messages/retryEligibility.ts | 5 +++ 4 files changed, 80 insertions(+) diff --git a/src/browser/utils/messages/StreamingMessageAggregator.status.test.ts b/src/browser/utils/messages/StreamingMessageAggregator.status.test.ts index 4b120af205..fa5a8575cf 100644 --- a/src/browser/utils/messages/StreamingMessageAggregator.status.test.ts +++ b/src/browser/utils/messages/StreamingMessageAggregator.status.test.ts @@ -244,6 +244,47 @@ describe("ask_user_question waiting state", () => { ).toBe(false); expect(aggregator.hasAwaitingUserQuestion()).toBe(true); }); + + it("does not report awaiting input when latest assistant turn has stream error metadata", () => { + const aggregator = new StreamingMessageAggregator("2024-01-01T00:00:00.000Z"); + + aggregator.loadHistoricalMessages([ + { + id: "assistant-1", + role: "assistant" as const, + parts: [ + { + type: "dynamic-tool" as const, + toolCallId: "call-ask-1", + toolName: "ask_user_question", + state: "input-available" as const, + input: { + questions: [ + { + header: "Approach", + question: "Which approach should we take?", + options: [ + { label: "A", description: "Approach A" }, + { label: "B", description: "Approach B" }, + ], + multiSelect: false, + }, + ], + }, + }, + ], + metadata: { + timestamp: 1000, + historySequence: 1, + partial: true, + error: "Connection dropped", + errorType: "network", + }, + }, + ]); + + expect(aggregator.hasAwaitingUserQuestion()).toBe(false); + }); }); describe("StreamingMessageAggregator - Agent Status", () => { diff --git a/src/browser/utils/messages/StreamingMessageAggregator.ts b/src/browser/utils/messages/StreamingMessageAggregator.ts index a4f95fae40..c3f6ccc46d 100644 --- a/src/browser/utils/messages/StreamingMessageAggregator.ts +++ b/src/browser/utils/messages/StreamingMessageAggregator.ts @@ -736,6 +736,12 @@ export class StreamingMessageAggregator { return false; } + // Error metadata means this turn ended in failure; surface retry/error state + // instead of presenting the turn as awaiting user input. + if (message.metadata?.error != null) { + return false; + } + return message.parts.some( (part) => isDynamicToolPart(part) && diff --git a/src/common/utils/messages/retryEligibility.test.ts b/src/common/utils/messages/retryEligibility.test.ts index 96cf301586..f917a69880 100644 --- a/src/common/utils/messages/retryEligibility.test.ts +++ b/src/common/utils/messages/retryEligibility.test.ts @@ -223,6 +223,34 @@ describe("hasInterruptedStream", () => { expect(hasInterruptedStream(messages)).toBe(false); }); + + it("returns true when latest row is stream-error even if turn includes executing ask_user_question", () => { + const messages: DisplayedMessage[] = [ + { + type: "tool", + id: "tool-ask", + historyId: "assistant-1", + toolName: "ask_user_question", + toolCallId: "call-ask", + args: { questions: [] }, + status: "executing", + isPartial: true, + historySequence: 2, + streamSequence: 0, + isLastPartOfMessage: false, + }, + { + type: "stream-error", + id: "assistant-1-error", + historyId: "assistant-1", + error: "Connection dropped", + errorType: "network", + historySequence: 2, + }, + ]; + + expect(hasInterruptedStream(messages)).toBe(true); + }); it("returns true for partial tool message", () => { const messages: DisplayedMessage[] = [ { diff --git a/src/common/utils/messages/retryEligibility.ts b/src/common/utils/messages/retryEligibility.ts index b8d9665e3e..8cc9073315 100644 --- a/src/common/utils/messages/retryEligibility.ts +++ b/src/common/utils/messages/retryEligibility.ts @@ -127,6 +127,11 @@ export function hasExecutingAskUserQuestionInLatestTurn(messages: DisplayedMessa return false; } + // If the latest visible row is a stream error, preserve interruption/retry UX. + if (lastMessage.type === "stream-error") { + return false; + } + const latestHistoryId = lastMessage.historyId; for (let i = messages.length - 1; i >= 0; i--) { const message = messages[i]; From 2d0fca891a41a033b497cbaff8a67c16d5ed59fa Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Thu, 19 Mar 2026 09:36:18 +0000 Subject: [PATCH 04/33] =?UTF-8?q?=F0=9F=A4=96=20fix:=20use=20authoritative?= =?UTF-8?q?=20awaiting-question=20signal=20in=20retry=20gating?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Thread untruncated awaitingUserQuestion state into interruption context so display truncation cannot incorrectly re-enable RetryBarrier/auto-retry for pending ask_user_question turns. --- _Generated with `mux` • Model: `openai:gpt-5.3-codex` • Thinking: `xhigh` • Cost: `$5.41`_ --- src/browser/components/ChatPane/ChatPane.tsx | 3 +- .../utils/messages/retryEligibility.test.ts | 21 ++++++++++ src/common/utils/messages/retryEligibility.ts | 40 ++++++++++++++----- 3 files changed, 54 insertions(+), 10 deletions(-) diff --git a/src/browser/components/ChatPane/ChatPane.tsx b/src/browser/components/ChatPane/ChatPane.tsx index 77c9b26b6e..011d6139a9 100644 --- a/src/browser/components/ChatPane/ChatPane.tsx +++ b/src/browser/components/ChatPane/ChatPane.tsx @@ -572,7 +572,8 @@ export const ChatPane: React.FC = (props) => { workspaceState.messages, workspaceState.pendingStreamStartTime, workspaceState.runtimeStatus, - workspaceState.lastAbortReason + workspaceState.lastAbortReason, + workspaceState.awaitingUserQuestion ) : null; diff --git a/src/common/utils/messages/retryEligibility.test.ts b/src/common/utils/messages/retryEligibility.test.ts index f917a69880..0ca21d47cb 100644 --- a/src/common/utils/messages/retryEligibility.test.ts +++ b/src/common/utils/messages/retryEligibility.test.ts @@ -251,6 +251,27 @@ describe("hasInterruptedStream", () => { expect(hasInterruptedStream(messages)).toBe(true); }); + + it("returns false when authoritative awaitingUserQuestion flag is true", () => { + const messages: DisplayedMessage[] = [ + { + type: "assistant", + id: "assistant-tail", + historyId: "assistant-1", + content: "Please answer above.", + historySequence: 2, + streamSequence: 0, + isStreaming: false, + isPartial: true, + isLastPartOfMessage: true, + isCompacted: false, + isIdleCompacted: false, + }, + ]; + + expect(hasInterruptedStream(messages, null, null, null, true)).toBe(false); + expect(isEligibleForAutoRetry(messages, null, null, null, true)).toBe(false); + }); it("returns true for partial tool message", () => { const messages: DisplayedMessage[] = [ { diff --git a/src/common/utils/messages/retryEligibility.ts b/src/common/utils/messages/retryEligibility.ts index 8cc9073315..a7f5b688a0 100644 --- a/src/common/utils/messages/retryEligibility.ts +++ b/src/common/utils/messages/retryEligibility.ts @@ -173,7 +173,8 @@ export function hasExecutingAskUserQuestionInLatestTurn(messages: DisplayedMessa function computeHasInterruptedStream( messages: DisplayedMessage[], pendingStreamStartTime: number | null = null, - runtimeStatus: RuntimeStatusEvent | null = null + runtimeStatus: RuntimeStatusEvent | null = null, + awaitingUserQuestion = false ): boolean { if (messages.length === 0) return false; @@ -207,6 +208,13 @@ function computeHasInterruptedStream( return false; } + // WorkspaceStore derives awaitingUserQuestion from untruncated history. When + // provided, trust that authoritative signal so display truncation cannot + // re-enable interruption/retry UI for intentionally pending questions. + if (awaitingUserQuestion) { + return false; + } + // ask_user_question is a special case: an unfinished tool call represents an // intentional "waiting for user input" state, not a stream interruption. // @@ -239,12 +247,14 @@ export function getInterruptionContext( messages: DisplayedMessage[], pendingStreamStartTime: number | null = null, runtimeStatus: RuntimeStatusEvent | null = null, - lastAbortReason: StreamAbortReasonSnapshot | null = null + lastAbortReason: StreamAbortReasonSnapshot | null = null, + awaitingUserQuestion = false ): InterruptionContext { const hasInterrupted = computeHasInterruptedStream( messages, pendingStreamStartTime, - runtimeStatus + runtimeStatus, + awaitingUserQuestion ); if (!hasInterrupted) { @@ -281,10 +291,16 @@ export function hasInterruptedStream( messages: DisplayedMessage[], pendingStreamStartTime: number | null = null, runtimeStatus: RuntimeStatusEvent | null = null, - lastAbortReason: StreamAbortReasonSnapshot | null = null + lastAbortReason: StreamAbortReasonSnapshot | null = null, + awaitingUserQuestion = false ): boolean { - return getInterruptionContext(messages, pendingStreamStartTime, runtimeStatus, lastAbortReason) - .hasInterruptedStream; + return getInterruptionContext( + messages, + pendingStreamStartTime, + runtimeStatus, + lastAbortReason, + awaitingUserQuestion + ).hasInterruptedStream; } /** @@ -302,8 +318,14 @@ export function isEligibleForAutoRetry( messages: DisplayedMessage[], pendingStreamStartTime: number | null = null, runtimeStatus: RuntimeStatusEvent | null = null, - lastAbortReason: StreamAbortReasonSnapshot | null = null + lastAbortReason: StreamAbortReasonSnapshot | null = null, + awaitingUserQuestion = false ): boolean { - return getInterruptionContext(messages, pendingStreamStartTime, runtimeStatus, lastAbortReason) - .isEligibleForAutoRetry; + return getInterruptionContext( + messages, + pendingStreamStartTime, + runtimeStatus, + lastAbortReason, + awaitingUserQuestion + ).isEligibleForAutoRetry; } From f28e8a42545a79f1a360ca16e518fab76a7f424b Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Thu, 19 Mar 2026 09:43:46 +0000 Subject: [PATCH 05/33] =?UTF-8?q?=F0=9F=A4=96=20fix:=20suppress=20interrup?= =?UTF-8?q?ted=20rows=20with=20authoritative=20awaiting=20state?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pass workspaceState.awaitingUserQuestion into interrupted-row rendering so long truncated question turns no longer show contradictory "Interrupted" rows while the workspace is waiting for user input. --- _Generated with `mux` • Model: `openai:gpt-5.3-codex` • Thinking: `xhigh` • Cost: `$5.41`_ --- src/browser/components/ChatPane/ChatPane.tsx | 8 +++-- .../utils/messages/messageUtils.test.ts | 20 ++++++++++++ src/browser/utils/messages/messageUtils.ts | 31 ++++++++++++------- 3 files changed, 45 insertions(+), 14 deletions(-) diff --git a/src/browser/components/ChatPane/ChatPane.tsx b/src/browser/components/ChatPane/ChatPane.tsx index 011d6139a9..6f943dcfff 100644 --- a/src/browser/components/ChatPane/ChatPane.tsx +++ b/src/browser/components/ChatPane/ChatPane.tsx @@ -861,9 +861,11 @@ export const ChatPane: React.FC = (props) => { /> )} {isAtCutoff && } - {shouldShowInterruptedBarrier(msg, deferredMessages) && ( - - )} + {shouldShowInterruptedBarrier( + msg, + deferredMessages, + workspaceState.awaitingUserQuestion + ) && } ); })} diff --git a/src/browser/utils/messages/messageUtils.test.ts b/src/browser/utils/messages/messageUtils.test.ts index 7cae2732dd..007511bb46 100644 --- a/src/browser/utils/messages/messageUtils.test.ts +++ b/src/browser/utils/messages/messageUtils.test.ts @@ -61,6 +61,26 @@ describe("shouldShowInterruptedBarrier", () => { expect(shouldShowInterruptedBarrier(trailingPartialText, messages)).toBe(false); }); + + it("returns false when authoritative awaitingUserQuestion flag is true", () => { + const trailingPartialText: DisplayedMessage = { + type: "assistant", + id: "assistant-tail", + historyId: "assistant-1", + content: "Please answer above.", + historySequence: 2, + streamSequence: 1, + isStreaming: false, + isPartial: true, + isLastPartOfMessage: true, + isCompacted: false, + isIdleCompacted: false, + }; + + expect(shouldShowInterruptedBarrier(trailingPartialText, [trailingPartialText], true)).toBe( + false + ); + }); it("returns false for decorative compaction boundary rows", () => { const msg: DisplayedMessage = { type: "compaction-boundary", diff --git a/src/browser/utils/messages/messageUtils.ts b/src/browser/utils/messages/messageUtils.ts index 68a53645c3..255c5fcb54 100644 --- a/src/browser/utils/messages/messageUtils.ts +++ b/src/browser/utils/messages/messageUtils.ts @@ -78,7 +78,8 @@ export interface BashOutputGroupInfo { */ export function shouldShowInterruptedBarrier( msg: DisplayedMessage, - allMessages: DisplayedMessage[] = [msg] + allMessages: DisplayedMessage[] = [msg], + awaitingUserQuestion = false ): boolean { if ( msg.type === "user" || @@ -90,17 +91,25 @@ export function shouldShowInterruptedBarrier( ) return false; + const lastMessage = getLastNonDecorativeMessage(allMessages); + const isLatestTurnRow = + lastMessage != null && + "historyId" in msg && + "historyId" in lastMessage && + msg.historyId === lastMessage.historyId; + // ask_user_question is intentionally a "waiting for input" state. Even if the - // underlying message is a persisted partial (e.g. after app restart), we keep - // the full latest turn answerable instead of showing "Interrupted" on any - // trailing parts from that same assistant message. - if (hasExecutingAskUserQuestionInLatestTurn(allMessages)) { - const lastMessage = getLastNonDecorativeMessage(allMessages); - if (lastMessage && "historyId" in msg && "historyId" in lastMessage) { - if (msg.historyId === lastMessage.historyId) { - return false; - } - } + // question row is truncated in the displayed transcript, the authoritative + // awaitingUserQuestion workspace state should still suppress interrupted UI for + // the latest turn. + if (isLatestTurnRow && awaitingUserQuestion) { + return false; + } + + // Fallback for callers that don't provide the authoritative awaiting flag: + // infer from displayed rows only. + if (isLatestTurnRow && hasExecutingAskUserQuestionInLatestTurn(allMessages)) { + return false; } // Only show on the last part of multi-part messages From a66fd6c0069a9a2e9f23050ff6f4b6e23e75f86a Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Thu, 19 Mar 2026 09:52:50 +0000 Subject: [PATCH 06/33] =?UTF-8?q?=F0=9F=A4=96=20fix:=20ignore=20ephemeral?= =?UTF-8?q?=20plan-display=20rows=20for=20awaiting=20detection?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keep awaitingUserQuestion anchored to real assistant turns by skipping frontend-only plan-display transcript rows when inferring pending ask_user_question state. --- _Generated with `mux` • Model: `openai:gpt-5.3-codex` • Thinking: `xhigh` • Cost: `$5.41`_ --- .../StreamingMessageAggregator.status.test.ts | 52 +++++++++++++++++++ .../messages/StreamingMessageAggregator.ts | 6 +++ 2 files changed, 58 insertions(+) diff --git a/src/browser/utils/messages/StreamingMessageAggregator.status.test.ts b/src/browser/utils/messages/StreamingMessageAggregator.status.test.ts index fa5a8575cf..5c79ee04d3 100644 --- a/src/browser/utils/messages/StreamingMessageAggregator.status.test.ts +++ b/src/browser/utils/messages/StreamingMessageAggregator.status.test.ts @@ -285,6 +285,58 @@ describe("ask_user_question waiting state", () => { expect(aggregator.hasAwaitingUserQuestion()).toBe(false); }); + + it("ignores plan-display rows when inferring awaiting input", () => { + const aggregator = new StreamingMessageAggregator("2024-01-01T00:00:00.000Z"); + + aggregator.loadHistoricalMessages([ + { + id: "assistant-1", + role: "assistant" as const, + parts: [ + { + type: "dynamic-tool" as const, + toolCallId: "call-ask-1", + toolName: "ask_user_question", + state: "input-available" as const, + input: { + questions: [ + { + header: "Approach", + question: "Which approach should we take?", + options: [ + { label: "A", description: "Approach A" }, + { label: "B", description: "Approach B" }, + ], + multiSelect: false, + }, + ], + }, + }, + ], + metadata: { + timestamp: 1000, + historySequence: 1, + partial: true, + }, + }, + { + id: "plan-display-1", + role: "assistant" as const, + parts: [{ type: "text" as const, text: "# Plan\n\n- Draft" }], + metadata: { + timestamp: 2000, + historySequence: Number.MAX_SAFE_INTEGER, + muxMetadata: { + type: "plan-display", + path: "/tmp/plan.md", + }, + }, + }, + ]); + + expect(aggregator.hasAwaitingUserQuestion()).toBe(true); + }); }); describe("StreamingMessageAggregator - Agent Status", () => { diff --git a/src/browser/utils/messages/StreamingMessageAggregator.ts b/src/browser/utils/messages/StreamingMessageAggregator.ts index c3f6ccc46d..012eee3d0b 100644 --- a/src/browser/utils/messages/StreamingMessageAggregator.ts +++ b/src/browser/utils/messages/StreamingMessageAggregator.ts @@ -732,6 +732,12 @@ export class StreamingMessageAggregator { continue; } + // Ignore ephemeral /plan transcript rows when determining whether the + // underlying assistant turn is still waiting for ask_user_question input. + if (message.metadata?.muxMetadata?.type === "plan-display") { + continue; + } + if (message.role !== "assistant") { return false; } From e2703d550b7d25efc0c61c9238d7635671820fae Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Thu, 19 Mar 2026 10:03:41 +0000 Subject: [PATCH 07/33] =?UTF-8?q?=F0=9F=A4=96=20fix:=20treat=20plan-displa?= =?UTF-8?q?y=20rows=20as=20decorative=20in=20interruption=20checks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ignore ephemeral plan-display transcript rows when deriving latest actionable message for interruption/awaiting-related checks. This keeps interrupted-row suppression aligned with awaitingUserQuestion even when users open /plan while a question turn is pending. --- _Generated with `mux` • Model: `openai:gpt-5.3-codex` • Thinking: `xhigh` • Cost: `$5.41`_ --- .../utils/messages/messageUtils.test.ts | 29 +++++++++++++++++++ .../utils/messages/retryEligibility.test.ts | 26 +++++++++++++++++ src/common/utils/messages/retryEligibility.ts | 3 +- 3 files changed, 57 insertions(+), 1 deletion(-) diff --git a/src/browser/utils/messages/messageUtils.test.ts b/src/browser/utils/messages/messageUtils.test.ts index 007511bb46..31dacc305a 100644 --- a/src/browser/utils/messages/messageUtils.test.ts +++ b/src/browser/utils/messages/messageUtils.test.ts @@ -81,6 +81,35 @@ describe("shouldShowInterruptedBarrier", () => { false ); }); + + it("ignores trailing plan-display rows when awaitingUserQuestion is true", () => { + const trailingPartialText: DisplayedMessage = { + type: "assistant", + id: "assistant-tail", + historyId: "assistant-1", + content: "Please answer above.", + historySequence: 2, + streamSequence: 1, + isStreaming: false, + isPartial: true, + isLastPartOfMessage: true, + isCompacted: false, + isIdleCompacted: false, + }; + + const planDisplay: DisplayedMessage = { + type: "plan-display", + id: "plan-display-1", + historyId: "plan-display-1", + content: "# Plan", + path: "/tmp/plan.md", + historySequence: Number.MAX_SAFE_INTEGER, + }; + + expect( + shouldShowInterruptedBarrier(trailingPartialText, [trailingPartialText, planDisplay], true) + ).toBe(false); + }); it("returns false for decorative compaction boundary rows", () => { const msg: DisplayedMessage = { type: "compaction-boundary", diff --git a/src/common/utils/messages/retryEligibility.test.ts b/src/common/utils/messages/retryEligibility.test.ts index 0ca21d47cb..93f647242c 100644 --- a/src/common/utils/messages/retryEligibility.test.ts +++ b/src/common/utils/messages/retryEligibility.test.ts @@ -32,6 +32,32 @@ describe("getLastNonDecorativeMessage", () => { expect(lastMessage?.id).toBe("error-1"); }); + it("ignores trailing plan-display rows when finding latest actionable row", () => { + const messages: DisplayedMessage[] = [ + { + type: "assistant", + id: "assistant-1", + historyId: "assistant-1", + content: "Working...", + historySequence: 1, + isStreaming: false, + isPartial: true, + isCompacted: false, + isIdleCompacted: false, + }, + { + type: "plan-display", + id: "plan-display-1", + historyId: "plan-display-1", + content: "# Plan", + path: "/tmp/plan.md", + historySequence: Number.MAX_SAFE_INTEGER, + }, + ]; + + const lastMessage = getLastNonDecorativeMessage(messages); + expect(lastMessage?.id).toBe("assistant-1"); + }); it("returns undefined when all rows are decorative", () => { const messages: DisplayedMessage[] = [ { diff --git a/src/common/utils/messages/retryEligibility.ts b/src/common/utils/messages/retryEligibility.ts index a7f5b688a0..d81ef38c7c 100644 --- a/src/common/utils/messages/retryEligibility.ts +++ b/src/common/utils/messages/retryEligibility.ts @@ -98,7 +98,8 @@ function isDecorativeTranscriptMessage(message: DisplayedMessage): boolean { return ( message.type === "history-hidden" || message.type === "workspace-init" || - message.type === "compaction-boundary" + message.type === "compaction-boundary" || + message.type === "plan-display" ); } From ed9943f00058bb8596feda46b72b2934fc23350c Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Thu, 19 Mar 2026 10:15:29 +0000 Subject: [PATCH 08/33] =?UTF-8?q?=F0=9F=A4=96=20fix:=20scope=20plan-displa?= =?UTF-8?q?y=20skipping=20to=20interrupted-row=20turn=20resolution?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Revert global decorative treatment of plan-display rows and instead skip them only in messageUtils latest-turn resolution used for interrupted-row rendering. This avoids changing retry/stream-error latest-message semantics while still preventing /plan previews from breaking awaiting-question interrupted-row suppression. --- _Generated with `mux` • Model: `openai:gpt-5.3-codex` • Thinking: `xhigh` • Cost: `$5.41`_ --- src/browser/utils/messages/messageUtils.ts | 25 ++++++++++++++++- .../utils/messages/retryEligibility.test.ts | 27 ------------------- src/common/utils/messages/retryEligibility.ts | 3 +-- 3 files changed, 25 insertions(+), 30 deletions(-) diff --git a/src/browser/utils/messages/messageUtils.ts b/src/browser/utils/messages/messageUtils.ts index 255c5fcb54..f0094b2182 100644 --- a/src/browser/utils/messages/messageUtils.ts +++ b/src/browser/utils/messages/messageUtils.ts @@ -91,7 +91,30 @@ export function shouldShowInterruptedBarrier( ) return false; - const lastMessage = getLastNonDecorativeMessage(allMessages); + const lastMessage = (() => { + const latest = getLastNonDecorativeMessage(allMessages); + if (latest?.type !== "plan-display") { + return latest; + } + + // /plan previews are ephemeral transcript rows and should not redefine the + // latest actionable assistant turn for interruption UI. + for (let i = allMessages.length - 1; i >= 0; i--) { + const candidate = allMessages[i]; + if ( + candidate.type === "plan-display" || + candidate.type === "history-hidden" || + candidate.type === "workspace-init" || + candidate.type === "compaction-boundary" + ) { + continue; + } + return candidate; + } + + return undefined; + })(); + const isLatestTurnRow = lastMessage != null && "historyId" in msg && diff --git a/src/common/utils/messages/retryEligibility.test.ts b/src/common/utils/messages/retryEligibility.test.ts index 93f647242c..818184c696 100644 --- a/src/common/utils/messages/retryEligibility.test.ts +++ b/src/common/utils/messages/retryEligibility.test.ts @@ -31,33 +31,6 @@ describe("getLastNonDecorativeMessage", () => { const lastMessage = getLastNonDecorativeMessage(messages); expect(lastMessage?.id).toBe("error-1"); }); - - it("ignores trailing plan-display rows when finding latest actionable row", () => { - const messages: DisplayedMessage[] = [ - { - type: "assistant", - id: "assistant-1", - historyId: "assistant-1", - content: "Working...", - historySequence: 1, - isStreaming: false, - isPartial: true, - isCompacted: false, - isIdleCompacted: false, - }, - { - type: "plan-display", - id: "plan-display-1", - historyId: "plan-display-1", - content: "# Plan", - path: "/tmp/plan.md", - historySequence: Number.MAX_SAFE_INTEGER, - }, - ]; - - const lastMessage = getLastNonDecorativeMessage(messages); - expect(lastMessage?.id).toBe("assistant-1"); - }); it("returns undefined when all rows are decorative", () => { const messages: DisplayedMessage[] = [ { diff --git a/src/common/utils/messages/retryEligibility.ts b/src/common/utils/messages/retryEligibility.ts index d81ef38c7c..a7f5b688a0 100644 --- a/src/common/utils/messages/retryEligibility.ts +++ b/src/common/utils/messages/retryEligibility.ts @@ -98,8 +98,7 @@ function isDecorativeTranscriptMessage(message: DisplayedMessage): boolean { return ( message.type === "history-hidden" || message.type === "workspace-init" || - message.type === "compaction-boundary" || - message.type === "plan-display" + message.type === "compaction-boundary" ); } From 6105ed2e1a4d9405a5fe45dc9581e6f95e462d2b Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Thu, 19 Mar 2026 10:27:32 +0000 Subject: [PATCH 09/33] =?UTF-8?q?=F0=9F=A4=96=20fix:=20ignore=20plan-displ?= =?UTF-8?q?ay=20previews=20in=20ask-user=20waiting=20fallback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When inferring pending ask_user_question from displayed rows only, skip trailing plan-display transcript rows so /plan previews do not re-enable retry state on intentionally waiting turns. --- _Generated with `mux` • Model: `openai:gpt-5.3-codex` • Thinking: `xhigh` • Cost: `$5.41`_ --- .../utils/messages/retryEligibility.test.ts | 40 +++++++++++++++++++ src/common/utils/messages/retryEligibility.ts | 25 +++++++++++- 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/src/common/utils/messages/retryEligibility.test.ts b/src/common/utils/messages/retryEligibility.test.ts index 818184c696..5267306c19 100644 --- a/src/common/utils/messages/retryEligibility.test.ts +++ b/src/common/utils/messages/retryEligibility.test.ts @@ -223,6 +223,46 @@ describe("hasInterruptedStream", () => { expect(hasInterruptedStream(messages)).toBe(false); }); + it("returns false when pending ask_user_question turn is followed by plan-display row", () => { + const messages: DisplayedMessage[] = [ + { + type: "tool", + id: "tool-ask", + historyId: "assistant-1", + toolName: "ask_user_question", + toolCallId: "call-ask", + args: { questions: [] }, + status: "executing", + isPartial: true, + historySequence: 2, + streamSequence: 0, + isLastPartOfMessage: false, + }, + { + type: "assistant", + id: "assistant-tail", + historyId: "assistant-1", + content: "Please answer above.", + historySequence: 2, + streamSequence: 1, + isStreaming: false, + isPartial: true, + isLastPartOfMessage: true, + isCompacted: false, + isIdleCompacted: false, + }, + { + type: "plan-display", + id: "plan-display-1", + historyId: "plan-display-1", + content: "# Plan", + path: "/tmp/plan.md", + historySequence: Number.MAX_SAFE_INTEGER, + }, + ]; + + expect(hasInterruptedStream(messages)).toBe(false); + }); it("returns true when latest row is stream-error even if turn includes executing ask_user_question", () => { const messages: DisplayedMessage[] = [ { diff --git a/src/common/utils/messages/retryEligibility.ts b/src/common/utils/messages/retryEligibility.ts index a7f5b688a0..c742ae36fd 100644 --- a/src/common/utils/messages/retryEligibility.ts +++ b/src/common/utils/messages/retryEligibility.ts @@ -122,7 +122,30 @@ export function getLastNonDecorativeMessage( * not suppress interruption/retry UI once conversation has moved on. */ export function hasExecutingAskUserQuestionInLatestTurn(messages: DisplayedMessage[]): boolean { - const lastMessage = getLastNonDecorativeMessage(messages); + const lastMessage = (() => { + const latest = getLastNonDecorativeMessage(messages); + if (latest?.type !== "plan-display") { + return latest; + } + + // /plan previews are ephemeral transcript rows and should not redefine the + // latest actionable turn when inferring pending ask_user_question state. + for (let i = messages.length - 1; i >= 0; i--) { + const candidate = messages[i]; + if ( + candidate.type === "plan-display" || + candidate.type === "history-hidden" || + candidate.type === "workspace-init" || + candidate.type === "compaction-boundary" + ) { + continue; + } + return candidate; + } + + return undefined; + })(); + if (!lastMessage || !("historyId" in lastMessage)) { return false; } From aaa6e52f1cc9a839784608f0cc05f80a8757912a Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Thu, 19 Mar 2026 10:41:22 +0000 Subject: [PATCH 10/33] =?UTF-8?q?=F0=9F=A4=96=20fix:=20require=20latest=20?= =?UTF-8?q?unfinished=20part=20to=20be=20ask=5Fuser=5Fquestion=20for=20awa?= =?UTF-8?q?iting=20state?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refine awaitingUserQuestion inference so a turn is marked awaiting-input only when the latest unfinished part is the pending ask_user_question itself. If later unfinished text/reasoning/tool parts exist, treat the turn as interrupted instead of awaiting. --- _Generated with `mux` • Model: `openai:gpt-5.3-codex` • Thinking: `xhigh` • Cost: `$5.41`_ --- .../StreamingMessageAggregator.status.test.ts | 42 ++++++++++++++++- .../messages/StreamingMessageAggregator.ts | 47 ++++++++++++++++++- 2 files changed, 85 insertions(+), 4 deletions(-) diff --git a/src/browser/utils/messages/StreamingMessageAggregator.status.test.ts b/src/browser/utils/messages/StreamingMessageAggregator.status.test.ts index 5c79ee04d3..26b9142a87 100644 --- a/src/browser/utils/messages/StreamingMessageAggregator.status.test.ts +++ b/src/browser/utils/messages/StreamingMessageAggregator.status.test.ts @@ -94,7 +94,7 @@ describe("ask_user_question waiting state", () => { expect(aggregator.hasAwaitingUserQuestion()).toBe(true); }); - it("keeps awaiting state when ask_user_question is followed by other parts in the same turn", () => { + it("keeps awaiting state when ask_user_question is followed by completed tool parts", () => { const aggregator = new StreamingMessageAggregator("2024-01-01T00:00:00.000Z"); aggregator.loadHistoricalMessages([ @@ -129,7 +129,6 @@ describe("ask_user_question waiting state", () => { input: { todos: [{ content: "Waiting for answers", status: "in_progress" }] }, output: { success: true }, }, - { type: "text" as const, text: "Please answer the question above." }, ], metadata: { timestamp: 1000, @@ -142,6 +141,45 @@ describe("ask_user_question waiting state", () => { expect(aggregator.hasAwaitingUserQuestion()).toBe(true); }); + it("does not report awaiting input when a later partial text segment follows the question", () => { + const aggregator = new StreamingMessageAggregator("2024-01-01T00:00:00.000Z"); + + aggregator.loadHistoricalMessages([ + { + id: "assistant-1", + role: "assistant" as const, + parts: [ + { + type: "dynamic-tool" as const, + toolCallId: "call-ask-1", + toolName: "ask_user_question", + state: "input-available" as const, + input: { + questions: [ + { + header: "Approach", + question: "Which approach should we take?", + options: [ + { label: "A", description: "Approach A" }, + { label: "B", description: "Approach B" }, + ], + multiSelect: false, + }, + ], + }, + }, + { type: "text" as const, text: "Continuing with unrelated output..." }, + ], + metadata: { + timestamp: 1000, + historySequence: 1, + partial: true, + }, + }, + ]); + + expect(aggregator.hasAwaitingUserQuestion()).toBe(false); + }); it("does not treat older question turns as awaiting after chat moves on", () => { const aggregator = new StreamingMessageAggregator("2024-01-01T00:00:00.000Z"); diff --git a/src/browser/utils/messages/StreamingMessageAggregator.ts b/src/browser/utils/messages/StreamingMessageAggregator.ts index 012eee3d0b..fc540c8f53 100644 --- a/src/browser/utils/messages/StreamingMessageAggregator.ts +++ b/src/browser/utils/messages/StreamingMessageAggregator.ts @@ -748,12 +748,55 @@ export class StreamingMessageAggregator { return false; } - return message.parts.some( - (part) => + let latestPendingQuestionIndex = -1; + for (let partIndex = 0; partIndex < message.parts.length; partIndex++) { + const part = message.parts[partIndex]; + if ( isDynamicToolPart(part) && part.toolName === "ask_user_question" && part.state === "input-available" + ) { + latestPendingQuestionIndex = partIndex; + } + } + + if (latestPendingQuestionIndex === -1) { + return false; + } + + // If a later tool call is still unfinished, that later interruption should + // win over the earlier ask_user_question waiting state. + const hasLaterPendingTool = message.parts.some( + (part, partIndex) => + partIndex > latestPendingQuestionIndex && + isDynamicToolPart(part) && + part.state === "input-available" ); + if (hasLaterPendingTool) { + return false; + } + + // Persisted partial turns can include additional trailing text/reasoning + // emitted after the question. Treat that as an interrupted tail rather than + // a pure waiting state. + if (message.metadata?.partial === true) { + const hasLaterTextOrReasoning = message.parts.some((part, partIndex) => { + if (partIndex <= latestPendingQuestionIndex) { + return false; + } + + return ( + (part.type === "text" && part.text.length > 0) || + (part.type === "reasoning" && part.text.length > 0) + ); + }); + + if (hasLaterTextOrReasoning) { + return false; + } + } + + return true; } return false; From bd89e87a272852963b436561d14d15a6451e7cd2 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Thu, 19 Mar 2026 10:55:01 +0000 Subject: [PATCH 11/33] =?UTF-8?q?=F0=9F=A4=96=20fix:=20keep=20interruption?= =?UTF-8?q?=20visible=20when=20output=20continues=20after=20question?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../utils/messages/messageUtils.test.ts | 4 +-- .../utils/messages/retryEligibility.test.ts | 19 ++++++------ src/common/utils/messages/retryEligibility.ts | 31 ++++++++++++++++--- 3 files changed, 39 insertions(+), 15 deletions(-) diff --git a/src/browser/utils/messages/messageUtils.test.ts b/src/browser/utils/messages/messageUtils.test.ts index 31dacc305a..ea2309ba9e 100644 --- a/src/browser/utils/messages/messageUtils.test.ts +++ b/src/browser/utils/messages/messageUtils.test.ts @@ -28,7 +28,7 @@ describe("shouldShowInterruptedBarrier", () => { expect(shouldShowInterruptedBarrier(msg)).toBe(false); }); - it("returns false for trailing partial rows when latest turn contains executing ask_user_question", () => { + it("returns true for trailing partial rows when fallback inference sees later unfinished output", () => { const questionTool: DisplayedMessage = { type: "tool", id: "tool-ask", @@ -59,7 +59,7 @@ describe("shouldShowInterruptedBarrier", () => { const messages = [questionTool, trailingPartialText]; - expect(shouldShowInterruptedBarrier(trailingPartialText, messages)).toBe(false); + expect(shouldShowInterruptedBarrier(trailingPartialText, messages)).toBe(true); }); it("returns false when authoritative awaitingUserQuestion flag is true", () => { diff --git a/src/common/utils/messages/retryEligibility.test.ts b/src/common/utils/messages/retryEligibility.test.ts index 5267306c19..2ed813dd17 100644 --- a/src/common/utils/messages/retryEligibility.test.ts +++ b/src/common/utils/messages/retryEligibility.test.ts @@ -170,7 +170,7 @@ describe("hasInterruptedStream", () => { expect(hasInterruptedStream(messages)).toBe(false); }); - it("returns false when latest turn contains executing ask_user_question plus trailing parts", () => { + it("returns true when later unfinished parts follow executing ask_user_question", () => { const messages: DisplayedMessage[] = [ { type: "user", @@ -220,7 +220,7 @@ describe("hasInterruptedStream", () => { }, ]; - expect(hasInterruptedStream(messages)).toBe(false); + expect(hasInterruptedStream(messages)).toBe(true); }); it("returns false when pending ask_user_question turn is followed by plan-display row", () => { @@ -239,17 +239,18 @@ describe("hasInterruptedStream", () => { isLastPartOfMessage: false, }, { - type: "assistant", - id: "assistant-tail", + type: "tool", + id: "tool-todo", historyId: "assistant-1", - content: "Please answer above.", + toolName: "todo_write", + toolCallId: "call-todo", + args: { todos: [] }, + result: { success: true }, + status: "completed", + isPartial: true, historySequence: 2, streamSequence: 1, - isStreaming: false, - isPartial: true, isLastPartOfMessage: true, - isCompacted: false, - isIdleCompacted: false, }, { type: "plan-display", diff --git a/src/common/utils/messages/retryEligibility.ts b/src/common/utils/messages/retryEligibility.ts index c742ae36fd..5ea0836678 100644 --- a/src/common/utils/messages/retryEligibility.ts +++ b/src/common/utils/messages/retryEligibility.ts @@ -156,6 +156,22 @@ export function hasExecutingAskUserQuestionInLatestTurn(messages: DisplayedMessa } const latestHistoryId = lastMessage.historyId; + const isUnfinishedMessage = (message: DisplayedMessage): boolean => { + switch (message.type) { + case "tool": + return ( + message.status === "executing" || + message.status === "pending" || + message.status === "interrupted" + ); + case "assistant": + case "reasoning": + return message.isPartial === true; + default: + return false; + } + }; + for (let i = messages.length - 1; i >= 0; i--) { const message = messages[i]; if (!("historyId" in message)) { @@ -166,13 +182,20 @@ export function hasExecutingAskUserQuestionInLatestTurn(messages: DisplayedMessa break; } - if ( + // Latest error should keep retry/interruption affordances visible. + if (message.type === "stream-error") { + return false; + } + + if (!isUnfinishedMessage(message)) { + continue; + } + + return ( message.type === "tool" && message.toolName === "ask_user_question" && message.status === "executing" - ) { - return true; - } + ); } return false; From 34d8778a344cd92196c6919294d19f6d3f1d473d Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Thu, 19 Mar 2026 11:08:29 +0000 Subject: [PATCH 12/33] =?UTF-8?q?=F0=9F=A4=96=20fix:=20prefer=20later=20to?= =?UTF-8?q?ol=20failures=20over=20ask-user=20awaiting=20state?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../StreamingMessageAggregator.status.test.ts | 47 +++++++++++++++++++ .../messages/StreamingMessageAggregator.ts | 13 +++++ 2 files changed, 60 insertions(+) diff --git a/src/browser/utils/messages/StreamingMessageAggregator.status.test.ts b/src/browser/utils/messages/StreamingMessageAggregator.status.test.ts index 26b9142a87..0da87248f8 100644 --- a/src/browser/utils/messages/StreamingMessageAggregator.status.test.ts +++ b/src/browser/utils/messages/StreamingMessageAggregator.status.test.ts @@ -141,6 +141,53 @@ describe("ask_user_question waiting state", () => { expect(aggregator.hasAwaitingUserQuestion()).toBe(true); }); + it("does not report awaiting input when a later tool result fails after the question", () => { + const aggregator = new StreamingMessageAggregator("2024-01-01T00:00:00.000Z"); + + aggregator.loadHistoricalMessages([ + { + id: "assistant-1", + role: "assistant" as const, + parts: [ + { + type: "dynamic-tool" as const, + toolCallId: "call-ask-1", + toolName: "ask_user_question", + state: "input-available" as const, + input: { + questions: [ + { + header: "Approach", + question: "Which approach should we take?", + options: [ + { label: "A", description: "Approach A" }, + { label: "B", description: "Approach B" }, + ], + multiSelect: false, + }, + ], + }, + }, + { + type: "dynamic-tool" as const, + toolCallId: "call-todo-1", + toolName: "todo_write", + state: "output-available" as const, + input: { todos: [{ content: "Waiting for answers", status: "in_progress" }] }, + output: { success: false, error: "write failed" }, + }, + ], + metadata: { + timestamp: 1000, + historySequence: 1, + partial: true, + }, + }, + ]); + + expect(aggregator.hasAwaitingUserQuestion()).toBe(false); + }); + it("does not report awaiting input when a later partial text segment follows the question", () => { const aggregator = new StreamingMessageAggregator("2024-01-01T00:00:00.000Z"); diff --git a/src/browser/utils/messages/StreamingMessageAggregator.ts b/src/browser/utils/messages/StreamingMessageAggregator.ts index fc540c8f53..d3d058c33b 100644 --- a/src/browser/utils/messages/StreamingMessageAggregator.ts +++ b/src/browser/utils/messages/StreamingMessageAggregator.ts @@ -776,6 +776,19 @@ export class StreamingMessageAggregator { return false; } + // If a later tool has already failed, the latest visible state is an + // interruption/error and retry affordances should remain visible. + const hasLaterFailedTool = message.parts.some( + (part, partIndex) => + partIndex > latestPendingQuestionIndex && + isDynamicToolPart(part) && + part.state === "output-available" && + hasFailureResult(part.output) + ); + if (hasLaterFailedTool) { + return false; + } + // Persisted partial turns can include additional trailing text/reasoning // emitted after the question. Treat that as an interrupted tail rather than // a pure waiting state. From 9969383886fa2611e870945e7a1d528059726743 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Thu, 19 Mar 2026 11:23:04 +0000 Subject: [PATCH 13/33] =?UTF-8?q?=F0=9F=A4=96=20fix:=20align=20retry=20sup?= =?UTF-8?q?pression=20with=20ask-user=20question=20tail=20state?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../utils/messages/retryEligibility.test.ts | 34 +++++++++ src/common/utils/messages/retryEligibility.ts | 7 +- .../agentSession.startupAutoRetry.test.ts | 61 +++++++++++++++ src/node/services/agentSession.ts | 74 ++++++++++++++++++- 4 files changed, 171 insertions(+), 5 deletions(-) diff --git a/src/common/utils/messages/retryEligibility.test.ts b/src/common/utils/messages/retryEligibility.test.ts index 2ed813dd17..2a36cb6cc8 100644 --- a/src/common/utils/messages/retryEligibility.test.ts +++ b/src/common/utils/messages/retryEligibility.test.ts @@ -223,6 +223,40 @@ describe("hasInterruptedStream", () => { expect(hasInterruptedStream(messages)).toBe(true); }); + it("returns true when a failed tool follows executing ask_user_question", () => { + const messages: DisplayedMessage[] = [ + { + type: "tool", + id: "tool-ask", + historyId: "assistant-1", + toolName: "ask_user_question", + toolCallId: "call-ask", + args: { questions: [] }, + status: "executing", + isPartial: true, + historySequence: 2, + streamSequence: 0, + isLastPartOfMessage: false, + }, + { + type: "tool", + id: "tool-todo", + historyId: "assistant-1", + toolName: "todo_write", + toolCallId: "call-todo", + args: { todos: [] }, + result: { success: false, error: "write failed" }, + status: "failed", + isPartial: true, + historySequence: 2, + streamSequence: 1, + isLastPartOfMessage: true, + }, + ]; + + expect(hasInterruptedStream(messages)).toBe(true); + }); + it("returns false when pending ask_user_question turn is followed by plan-display row", () => { const messages: DisplayedMessage[] = [ { diff --git a/src/common/utils/messages/retryEligibility.ts b/src/common/utils/messages/retryEligibility.ts index 5ea0836678..e9b10f3e47 100644 --- a/src/common/utils/messages/retryEligibility.ts +++ b/src/common/utils/messages/retryEligibility.ts @@ -156,13 +156,14 @@ export function hasExecutingAskUserQuestionInLatestTurn(messages: DisplayedMessa } const latestHistoryId = lastMessage.historyId; - const isUnfinishedMessage = (message: DisplayedMessage): boolean => { + const isLatestTurnProgressRow = (message: DisplayedMessage): boolean => { switch (message.type) { case "tool": return ( message.status === "executing" || message.status === "pending" || - message.status === "interrupted" + message.status === "interrupted" || + message.status === "failed" ); case "assistant": case "reasoning": @@ -187,7 +188,7 @@ export function hasExecutingAskUserQuestionInLatestTurn(messages: DisplayedMessa return false; } - if (!isUnfinishedMessage(message)) { + if (!isLatestTurnProgressRow(message)) { continue; } diff --git a/src/node/services/agentSession.startupAutoRetry.test.ts b/src/node/services/agentSession.startupAutoRetry.test.ts index dbe13b44b7..c12cfa0279 100644 --- a/src/node/services/agentSession.startupAutoRetry.test.ts +++ b/src/node/services/agentSession.startupAutoRetry.test.ts @@ -1018,4 +1018,65 @@ describe("AgentSession startup auto-retry recovery", () => { session.dispose(); }); + + test("schedules startup auto-retry when a failed tool follows ask_user_question", async () => { + const workspaceId = "startup-retry-ask-user-failed-tail"; + const { session, historyService, events, cleanup } = await createSessionBundle(workspaceId); + cleanups.push(cleanup); + + const appendUserResult = await historyService.appendToHistory( + workspaceId, + createMuxMessage("user-1", "user", "Hello", { + timestamp: Date.now(), + }) + ); + expect(appendUserResult.success).toBe(true); + + const writePartialResult = await historyService.writePartial( + workspaceId, + createMuxMessage( + "assistant-1", + "assistant", + "", + { + timestamp: Date.now(), + model: "anthropic:claude-sonnet-4-5", + partial: true, + agentId: "exec", + }, + [ + { + type: "dynamic-tool", + state: "input-available", + toolCallId: "tool-ask", + toolName: "ask_user_question", + input: { question: "Name?" }, + }, + { + type: "dynamic-tool", + state: "output-available", + toolCallId: "tool-todo", + toolName: "todo_write", + input: { todos: [] }, + output: { success: false, error: "write failed" }, + }, + ] + ) + ); + expect(writePartialResult.success).toBe(true); + + const startupRetryModelHint = await session.getStartupAutoRetryModelHint(); + expect(startupRetryModelHint).toBe("anthropic:claude-sonnet-4-5"); + + session.ensureStartupAutoRetryCheck(); + + const startupCheckPromise = ( + session as unknown as { startupAutoRetryCheckPromise: Promise | null } + ).startupAutoRetryCheckPromise; + await startupCheckPromise; + + expect(events.some((event) => event.type === "auto-retry-scheduled")).toBe(true); + + session.dispose(); + }); }); diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index 2e8e5d6f78..9299000a37 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -943,12 +943,82 @@ export class AgentSession { return false; } - return message.parts.some( - (part) => + // Error metadata means the turn has failed; startup auto-retry should treat + // this as interrupted/retryable, not an intentional waiting state. + if (message.metadata?.error != null) { + return false; + } + + let latestPendingQuestionIndex = -1; + for (let partIndex = 0; partIndex < message.parts.length; partIndex += 1) { + const part = message.parts[partIndex]; + if ( part.type === "dynamic-tool" && part.toolName === "ask_user_question" && part.state === "input-available" + ) { + latestPendingQuestionIndex = partIndex; + } + } + + if (latestPendingQuestionIndex === -1) { + return false; + } + + const hasLaterPendingTool = message.parts.some( + (part, partIndex) => + partIndex > latestPendingQuestionIndex && + part.type === "dynamic-tool" && + part.state === "input-available" ); + if (hasLaterPendingTool) { + return false; + } + + const hasLaterFailedTool = message.parts.some((part, partIndex) => { + if ( + partIndex <= latestPendingQuestionIndex || + part.type !== "dynamic-tool" || + part.state !== "output-available" + ) { + return false; + } + + const output = part.output; + if (typeof output !== "object" || output === null) { + return false; + } + + if ("success" in output && output.success === false) { + return true; + } + + return "error" in output && Boolean(output.error); + }); + if (hasLaterFailedTool) { + return false; + } + + // If the stream produced text/reasoning after asking the question, this + // should recover as an interrupted tail after restart. + if (message.metadata?.partial === true) { + const hasLaterTextOrReasoning = message.parts.some((part, partIndex) => { + if (partIndex <= latestPendingQuestionIndex) { + return false; + } + + return ( + (part.type === "text" && part.text.length > 0) || + (part.type === "reasoning" && part.text.length > 0) + ); + }); + + if (hasLaterTextOrReasoning) { + return false; + } + } + + return true; } private isSyntheticSnapshotUserMessage(message: MuxMessage): boolean { From b9294816b00266e8cba57b04270e1cb71967e443 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Thu, 19 Mar 2026 13:06:32 +0000 Subject: [PATCH 14/33] =?UTF-8?q?=F0=9F=A4=96=20fix:=20keep=20pending=20as?= =?UTF-8?q?k-user=20turns=20out=20of=20startup=20auto-retry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../agentSession.startupAutoRetry.test.ts | 59 ++++++++++++++++++- src/node/services/agentSession.ts | 26 +------- 2 files changed, 61 insertions(+), 24 deletions(-) diff --git a/src/node/services/agentSession.startupAutoRetry.test.ts b/src/node/services/agentSession.startupAutoRetry.test.ts index c12cfa0279..33a47f5b86 100644 --- a/src/node/services/agentSession.startupAutoRetry.test.ts +++ b/src/node/services/agentSession.startupAutoRetry.test.ts @@ -1019,7 +1019,7 @@ describe("AgentSession startup auto-retry recovery", () => { session.dispose(); }); - test("schedules startup auto-retry when a failed tool follows ask_user_question", async () => { + test("does not schedule startup auto-retry when a failed tool follows ask_user_question", async () => { const workspaceId = "startup-retry-ask-user-failed-tail"; const { session, historyService, events, cleanup } = await createSessionBundle(workspaceId); cleanups.push(cleanup); @@ -1065,6 +1065,63 @@ describe("AgentSession startup auto-retry recovery", () => { ); expect(writePartialResult.success).toBe(true); + const startupRetryModelHint = await session.getStartupAutoRetryModelHint(); + expect(startupRetryModelHint).toBeNull(); + + session.ensureStartupAutoRetryCheck(); + + const startupCheckPromise = ( + session as unknown as { startupAutoRetryCheckPromise: Promise | null } + ).startupAutoRetryCheckPromise; + await startupCheckPromise; + + expect(events.some((event) => event.type === "auto-retry-scheduled")).toBe(false); + + session.dispose(); + }); + + test("schedules startup auto-retry when text follows ask_user_question", async () => { + const workspaceId = "startup-retry-ask-user-text-tail"; + const { session, historyService, events, cleanup } = await createSessionBundle(workspaceId); + cleanups.push(cleanup); + + const appendUserResult = await historyService.appendToHistory( + workspaceId, + createMuxMessage("user-1", "user", "Hello", { + timestamp: Date.now(), + }) + ); + expect(appendUserResult.success).toBe(true); + + const writePartialResult = await historyService.writePartial( + workspaceId, + createMuxMessage( + "assistant-1", + "assistant", + "", + { + timestamp: Date.now(), + model: "anthropic:claude-sonnet-4-5", + partial: true, + agentId: "exec", + }, + [ + { + type: "dynamic-tool", + state: "input-available", + toolCallId: "tool-ask", + toolName: "ask_user_question", + input: { question: "Name?" }, + }, + { + type: "text", + text: "Continuing with interrupted output", + }, + ] + ) + ); + expect(writePartialResult.success).toBe(true); + const startupRetryModelHint = await session.getStartupAutoRetryModelHint(); expect(startupRetryModelHint).toBe("anthropic:claude-sonnet-4-5"); diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index 9299000a37..be1eedb2b0 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -975,29 +975,9 @@ export class AgentSession { return false; } - const hasLaterFailedTool = message.parts.some((part, partIndex) => { - if ( - partIndex <= latestPendingQuestionIndex || - part.type !== "dynamic-tool" || - part.state !== "output-available" - ) { - return false; - } - - const output = part.output; - if (typeof output !== "object" || output === null) { - return false; - } - - if ("success" in output && output.success === false) { - return true; - } - - return "error" in output && Boolean(output.error); - }); - if (hasLaterFailedTool) { - return false; - } + // Keep logical tool failures after ask_user_question in the waiting state. + // The persisted partial can still be answered/resumed via answerAskUserQuestion + // after restart, so startup auto-retry should not immediately re-run the turn. // If the stream produced text/reasoning after asking the question, this // should recover as an interrupted tail after restart. From 74cb31e071542608e9218d063cd817204214279e Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Thu, 19 Mar 2026 13:18:40 +0000 Subject: [PATCH 15/33] =?UTF-8?q?=F0=9F=A4=96=20fix:=20treat=20failed=20re?= =?UTF-8?q?dacted=20tools=20as=20interruption=20tails?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../StreamingMessageAggregator.status.test.ts | 47 +++++++++++++++++++ .../messages/StreamingMessageAggregator.ts | 4 +- 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/src/browser/utils/messages/StreamingMessageAggregator.status.test.ts b/src/browser/utils/messages/StreamingMessageAggregator.status.test.ts index 0da87248f8..412810232f 100644 --- a/src/browser/utils/messages/StreamingMessageAggregator.status.test.ts +++ b/src/browser/utils/messages/StreamingMessageAggregator.status.test.ts @@ -188,6 +188,53 @@ describe("ask_user_question waiting state", () => { expect(aggregator.hasAwaitingUserQuestion()).toBe(false); }); + it("does not report awaiting input when a later failed redacted tool follows the question", () => { + const aggregator = new StreamingMessageAggregator("2024-01-01T00:00:00.000Z"); + + aggregator.loadHistoricalMessages([ + { + id: "assistant-1", + role: "assistant" as const, + parts: [ + { + type: "dynamic-tool" as const, + toolCallId: "call-ask-1", + toolName: "ask_user_question", + state: "input-available" as const, + input: { + questions: [ + { + header: "Approach", + question: "Which approach should we take?", + options: [ + { label: "A", description: "Approach A" }, + { label: "B", description: "Approach B" }, + ], + multiSelect: false, + }, + ], + }, + }, + { + type: "dynamic-tool" as const, + toolCallId: "call-bash-1", + toolName: "bash", + state: "output-redacted" as const, + input: { script: "exit 1" }, + failed: true, + }, + ], + metadata: { + timestamp: 1000, + historySequence: 1, + partial: true, + }, + }, + ]); + + expect(aggregator.hasAwaitingUserQuestion()).toBe(false); + }); + it("does not report awaiting input when a later partial text segment follows the question", () => { const aggregator = new StreamingMessageAggregator("2024-01-01T00:00:00.000Z"); diff --git a/src/browser/utils/messages/StreamingMessageAggregator.ts b/src/browser/utils/messages/StreamingMessageAggregator.ts index d3d058c33b..967053e517 100644 --- a/src/browser/utils/messages/StreamingMessageAggregator.ts +++ b/src/browser/utils/messages/StreamingMessageAggregator.ts @@ -782,8 +782,8 @@ export class StreamingMessageAggregator { (part, partIndex) => partIndex > latestPendingQuestionIndex && isDynamicToolPart(part) && - part.state === "output-available" && - hasFailureResult(part.output) + ((part.state === "output-available" && hasFailureResult(part.output)) || + (part.state === "output-redacted" && part.failed === true)) ); if (hasLaterFailedTool) { return false; From 8e0032425794acfc4220b8552924546fa8f7acef Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Thu, 19 Mar 2026 13:35:18 +0000 Subject: [PATCH 16/33] =?UTF-8?q?=F0=9F=A4=96=20fix:=20keep=20stale=20ask-?= =?UTF-8?q?user=20tool=20rows=20from=20showing=20as=20executing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../StreamingMessageAggregator.status.test.ts | 16 ++ .../messages/StreamingMessageAggregator.ts | 160 ++++++++++-------- 2 files changed, 107 insertions(+), 69 deletions(-) diff --git a/src/browser/utils/messages/StreamingMessageAggregator.status.test.ts b/src/browser/utils/messages/StreamingMessageAggregator.status.test.ts index 412810232f..b61f60b05d 100644 --- a/src/browser/utils/messages/StreamingMessageAggregator.status.test.ts +++ b/src/browser/utils/messages/StreamingMessageAggregator.status.test.ts @@ -233,6 +233,14 @@ describe("ask_user_question waiting state", () => { ]); expect(aggregator.hasAwaitingUserQuestion()).toBe(false); + + const askRow = aggregator + .getDisplayedMessages() + .find((message) => message.type === "tool" && message.toolName === "ask_user_question"); + if (askRow?.type !== "tool") { + throw new Error("Expected ask_user_question tool row"); + } + expect(askRow.status).toBe("interrupted"); }); it("does not report awaiting input when a later partial text segment follows the question", () => { @@ -273,6 +281,14 @@ describe("ask_user_question waiting state", () => { ]); expect(aggregator.hasAwaitingUserQuestion()).toBe(false); + + const askRow = aggregator + .getDisplayedMessages() + .find((message) => message.type === "tool" && message.toolName === "ask_user_question"); + if (askRow?.type !== "tool") { + throw new Error("Expected ask_user_question tool row"); + } + expect(askRow.status).toBe("interrupted"); }); it("does not treat older question turns as awaiting after chat moves on", () => { const aggregator = new StreamingMessageAggregator("2024-01-01T00:00:00.000Z"); diff --git a/src/browser/utils/messages/StreamingMessageAggregator.ts b/src/browser/utils/messages/StreamingMessageAggregator.ts index 967053e517..4998308145 100644 --- a/src/browser/utils/messages/StreamingMessageAggregator.ts +++ b/src/browser/utils/messages/StreamingMessageAggregator.ts @@ -168,6 +168,88 @@ function hasFailureResult(result: unknown): boolean { return false; } +/** + * Returns the toolCallId of the latest ask_user_question that is still truly + * awaiting user input in this assistant turn, or null when waiting should be + * suppressed in favor of interruption/retry UX. + */ +function getAwaitingAskUserQuestionToolCallId(message: MuxMessage): string | null { + if (message.role !== "assistant") { + return null; + } + + // Error metadata means this turn ended in failure; surface retry/error state + // instead of presenting the turn as awaiting user input. + if (message.metadata?.error != null) { + return null; + } + + let latestPendingQuestionIndex = -1; + let latestPendingQuestionToolCallId: string | null = null; + for (let partIndex = 0; partIndex < message.parts.length; partIndex++) { + const part = message.parts[partIndex]; + if ( + isDynamicToolPart(part) && + part.toolName === "ask_user_question" && + part.state === "input-available" + ) { + latestPendingQuestionIndex = partIndex; + latestPendingQuestionToolCallId = part.toolCallId; + } + } + + if (latestPendingQuestionIndex === -1 || latestPendingQuestionToolCallId === null) { + return null; + } + + // If a later tool call is still unfinished, that later interruption should + // win over the earlier ask_user_question waiting state. + const hasLaterPendingTool = message.parts.some( + (part, partIndex) => + partIndex > latestPendingQuestionIndex && + isDynamicToolPart(part) && + part.state === "input-available" + ); + if (hasLaterPendingTool) { + return null; + } + + // If a later tool has already failed, the latest visible state is an + // interruption/error and retry affordances should remain visible. + const hasLaterFailedTool = message.parts.some( + (part, partIndex) => + partIndex > latestPendingQuestionIndex && + isDynamicToolPart(part) && + ((part.state === "output-available" && hasFailureResult(part.output)) || + (part.state === "output-redacted" && part.failed === true)) + ); + if (hasLaterFailedTool) { + return null; + } + + // Persisted partial turns can include additional trailing text/reasoning + // emitted after the question. Treat that as an interrupted tail rather than + // a pure waiting state. + if (message.metadata?.partial === true) { + const hasLaterTextOrReasoning = message.parts.some((part, partIndex) => { + if (partIndex <= latestPendingQuestionIndex) { + return false; + } + + return ( + (part.type === "text" && part.text.length > 0) || + (part.type === "reasoning" && part.text.length > 0) + ); + }); + + if (hasLaterTextOrReasoning) { + return null; + } + } + + return latestPendingQuestionToolCallId; +} + function resolveRouteProvider( routeProvider: string | undefined, routedThroughGateway: boolean | undefined @@ -742,74 +824,7 @@ export class StreamingMessageAggregator { return false; } - // Error metadata means this turn ended in failure; surface retry/error state - // instead of presenting the turn as awaiting user input. - if (message.metadata?.error != null) { - return false; - } - - let latestPendingQuestionIndex = -1; - for (let partIndex = 0; partIndex < message.parts.length; partIndex++) { - const part = message.parts[partIndex]; - if ( - isDynamicToolPart(part) && - part.toolName === "ask_user_question" && - part.state === "input-available" - ) { - latestPendingQuestionIndex = partIndex; - } - } - - if (latestPendingQuestionIndex === -1) { - return false; - } - - // If a later tool call is still unfinished, that later interruption should - // win over the earlier ask_user_question waiting state. - const hasLaterPendingTool = message.parts.some( - (part, partIndex) => - partIndex > latestPendingQuestionIndex && - isDynamicToolPart(part) && - part.state === "input-available" - ); - if (hasLaterPendingTool) { - return false; - } - - // If a later tool has already failed, the latest visible state is an - // interruption/error and retry affordances should remain visible. - const hasLaterFailedTool = message.parts.some( - (part, partIndex) => - partIndex > latestPendingQuestionIndex && - isDynamicToolPart(part) && - ((part.state === "output-available" && hasFailureResult(part.output)) || - (part.state === "output-redacted" && part.failed === true)) - ); - if (hasLaterFailedTool) { - return false; - } - - // Persisted partial turns can include additional trailing text/reasoning - // emitted after the question. Treat that as an interrupted tail rather than - // a pure waiting state. - if (message.metadata?.partial === true) { - const hasLaterTextOrReasoning = message.parts.some((part, partIndex) => { - if (partIndex <= latestPendingQuestionIndex) { - return false; - } - - return ( - (part.type === "text" && part.text.length > 0) || - (part.type === "reasoning" && part.text.length > 0) - ); - }); - - if (hasLaterTextOrReasoning) { - return false; - } - } - - return true; + return getAwaitingAskUserQuestionToolCallId(message) !== null; } return false; @@ -2720,6 +2735,8 @@ export class StreamingMessageAggregator { // Merge adjacent text/reasoning parts for display const mergedParts = mergeAdjacentParts(message.parts); + const awaitingAskUserQuestionToolCallId = getAwaitingAskUserQuestionToolCallId(message); + // Find the last part that will produce a DisplayedMessage // (reasoning, text parts with content, OR tool parts) let lastPartIndex = -1; @@ -2804,7 +2821,12 @@ export class StreamingMessageAggregator { // so after restart we should keep it answerable ("executing") instead of // showing retry/auto-resume UX. if (part.toolName === "ask_user_question") { - status = "executing"; + status = + part.toolCallId === awaitingAskUserQuestionToolCallId + ? "executing" + : isPartial + ? "interrupted" + : "executing"; } else if (isPartial) { status = "interrupted"; } else { From 38c4428579de7e0c2457ae0935933c0004b4686f Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Thu, 19 Mar 2026 13:47:15 +0000 Subject: [PATCH 17/33] =?UTF-8?q?=F0=9F=A4=96=20fix:=20keep=20failed-tail?= =?UTF-8?q?=20ask-user=20prompts=20answerable?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../StreamingMessageAggregator.status.test.ts | 10 +++- .../messages/StreamingMessageAggregator.ts | 60 +++++++++++++------ 2 files changed, 52 insertions(+), 18 deletions(-) diff --git a/src/browser/utils/messages/StreamingMessageAggregator.status.test.ts b/src/browser/utils/messages/StreamingMessageAggregator.status.test.ts index b61f60b05d..77b9e534f4 100644 --- a/src/browser/utils/messages/StreamingMessageAggregator.status.test.ts +++ b/src/browser/utils/messages/StreamingMessageAggregator.status.test.ts @@ -186,6 +186,14 @@ describe("ask_user_question waiting state", () => { ]); expect(aggregator.hasAwaitingUserQuestion()).toBe(false); + + const askRow = aggregator + .getDisplayedMessages() + .find((message) => message.type === "tool" && message.toolName === "ask_user_question"); + if (askRow?.type !== "tool") { + throw new Error("Expected ask_user_question tool row"); + } + expect(askRow.status).toBe("executing"); }); it("does not report awaiting input when a later failed redacted tool follows the question", () => { @@ -240,7 +248,7 @@ describe("ask_user_question waiting state", () => { if (askRow?.type !== "tool") { throw new Error("Expected ask_user_question tool row"); } - expect(askRow.status).toBe("interrupted"); + expect(askRow.status).toBe("executing"); }); it("does not report awaiting input when a later partial text segment follows the question", () => { diff --git a/src/browser/utils/messages/StreamingMessageAggregator.ts b/src/browser/utils/messages/StreamingMessageAggregator.ts index 4998308145..fd73d86e1a 100644 --- a/src/browser/utils/messages/StreamingMessageAggregator.ts +++ b/src/browser/utils/messages/StreamingMessageAggregator.ts @@ -168,12 +168,19 @@ function hasFailureResult(result: unknown): boolean { return false; } +interface AskUserQuestionResolutionOptions { + suppressForLaterFailedTool: boolean; +} + /** - * Returns the toolCallId of the latest ask_user_question that is still truly - * awaiting user input in this assistant turn, or null when waiting should be - * suppressed in favor of interruption/retry UX. + * Returns the toolCallId of the latest ask_user_question in this assistant turn + * that should remain answerable in the UI, or null when it should be treated as + * an interruption/retry tail. */ -function getAwaitingAskUserQuestionToolCallId(message: MuxMessage): string | null { +function resolveAskUserQuestionToolCallId( + message: MuxMessage, + options: AskUserQuestionResolutionOptions +): string | null { if (message.role !== "assistant") { return null; } @@ -214,17 +221,19 @@ function getAwaitingAskUserQuestionToolCallId(message: MuxMessage): string | nul return null; } - // If a later tool has already failed, the latest visible state is an - // interruption/error and retry affordances should remain visible. - const hasLaterFailedTool = message.parts.some( - (part, partIndex) => - partIndex > latestPendingQuestionIndex && - isDynamicToolPart(part) && - ((part.state === "output-available" && hasFailureResult(part.output)) || - (part.state === "output-redacted" && part.failed === true)) - ); - if (hasLaterFailedTool) { - return null; + if (options.suppressForLaterFailedTool) { + // If a later tool has already failed, the latest visible state is an + // interruption/error and retry affordances should remain visible. + const hasLaterFailedTool = message.parts.some( + (part, partIndex) => + partIndex > latestPendingQuestionIndex && + isDynamicToolPart(part) && + ((part.state === "output-available" && hasFailureResult(part.output)) || + (part.state === "output-redacted" && part.failed === true)) + ); + if (hasLaterFailedTool) { + return null; + } } // Persisted partial turns can include additional trailing text/reasoning @@ -250,6 +259,23 @@ function getAwaitingAskUserQuestionToolCallId(message: MuxMessage): string | nul return latestPendingQuestionToolCallId; } +/** + * Awaiting-input workspace state should clear when later failed tools appear so + * retry/interruption affordances can be shown. + */ +function getAwaitingAskUserQuestionToolCallId(message: MuxMessage): string | null { + return resolveAskUserQuestionToolCallId(message, { suppressForLaterFailedTool: true }); +} + +/** + * Answer UI should remain available for pending ask_user_question turns even if + * a later tool reported logical failure (e.g. success:false) and startup + * auto-retry intentionally defers. + */ +function getAnswerableAskUserQuestionToolCallId(message: MuxMessage): string | null { + return resolveAskUserQuestionToolCallId(message, { suppressForLaterFailedTool: false }); +} + function resolveRouteProvider( routeProvider: string | undefined, routedThroughGateway: boolean | undefined @@ -2735,7 +2761,7 @@ export class StreamingMessageAggregator { // Merge adjacent text/reasoning parts for display const mergedParts = mergeAdjacentParts(message.parts); - const awaitingAskUserQuestionToolCallId = getAwaitingAskUserQuestionToolCallId(message); + const answerableAskUserQuestionToolCallId = getAnswerableAskUserQuestionToolCallId(message); // Find the last part that will produce a DisplayedMessage // (reasoning, text parts with content, OR tool parts) @@ -2822,7 +2848,7 @@ export class StreamingMessageAggregator { // showing retry/auto-resume UX. if (part.toolName === "ask_user_question") { status = - part.toolCallId === awaitingAskUserQuestionToolCallId + part.toolCallId === answerableAskUserQuestionToolCallId ? "executing" : isPartial ? "interrupted" From 9b3e5272949396cbe3bbb21fd9eebbee3f51bfaa Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Thu, 19 Mar 2026 14:02:13 +0000 Subject: [PATCH 18/33] =?UTF-8?q?=F0=9F=A4=96=20fix:=20preserve=20pending?= =?UTF-8?q?=20ask-user=20recovery=20when=20streams=20error?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../StreamingMessageAggregator.status.test.ts | 8 ++++ .../messages/StreamingMessageAggregator.ts | 17 +++++-- .../agentSession.startupAutoRetry.test.ts | 47 +++++++++++++++++++ src/node/services/agentSession.ts | 6 --- 4 files changed, 67 insertions(+), 11 deletions(-) diff --git a/src/browser/utils/messages/StreamingMessageAggregator.status.test.ts b/src/browser/utils/messages/StreamingMessageAggregator.status.test.ts index 77b9e534f4..65f89571ce 100644 --- a/src/browser/utils/messages/StreamingMessageAggregator.status.test.ts +++ b/src/browser/utils/messages/StreamingMessageAggregator.status.test.ts @@ -440,6 +440,14 @@ describe("ask_user_question waiting state", () => { ]); expect(aggregator.hasAwaitingUserQuestion()).toBe(false); + + const askRow = aggregator + .getDisplayedMessages() + .find((message) => message.type === "tool" && message.toolName === "ask_user_question"); + if (askRow?.type !== "tool") { + throw new Error("Expected ask_user_question tool row"); + } + expect(askRow.status).toBe("executing"); }); it("ignores plan-display rows when inferring awaiting input", () => { diff --git a/src/browser/utils/messages/StreamingMessageAggregator.ts b/src/browser/utils/messages/StreamingMessageAggregator.ts index fd73d86e1a..c0268a5c22 100644 --- a/src/browser/utils/messages/StreamingMessageAggregator.ts +++ b/src/browser/utils/messages/StreamingMessageAggregator.ts @@ -169,6 +169,7 @@ function hasFailureResult(result: unknown): boolean { } interface AskUserQuestionResolutionOptions { + suppressForMessageError: boolean; suppressForLaterFailedTool: boolean; } @@ -185,9 +186,9 @@ function resolveAskUserQuestionToolCallId( return null; } - // Error metadata means this turn ended in failure; surface retry/error state - // instead of presenting the turn as awaiting user input. - if (message.metadata?.error != null) { + if (options.suppressForMessageError && message.metadata?.error != null) { + // Error metadata means this turn ended in failure; surface retry/error state + // instead of presenting the turn as awaiting user input. return null; } @@ -264,7 +265,10 @@ function resolveAskUserQuestionToolCallId( * retry/interruption affordances can be shown. */ function getAwaitingAskUserQuestionToolCallId(message: MuxMessage): string | null { - return resolveAskUserQuestionToolCallId(message, { suppressForLaterFailedTool: true }); + return resolveAskUserQuestionToolCallId(message, { + suppressForMessageError: true, + suppressForLaterFailedTool: true, + }); } /** @@ -273,7 +277,10 @@ function getAwaitingAskUserQuestionToolCallId(message: MuxMessage): string | nul * auto-retry intentionally defers. */ function getAnswerableAskUserQuestionToolCallId(message: MuxMessage): string | null { - return resolveAskUserQuestionToolCallId(message, { suppressForLaterFailedTool: false }); + return resolveAskUserQuestionToolCallId(message, { + suppressForMessageError: false, + suppressForLaterFailedTool: false, + }); } function resolveRouteProvider( diff --git a/src/node/services/agentSession.startupAutoRetry.test.ts b/src/node/services/agentSession.startupAutoRetry.test.ts index 33a47f5b86..f924803090 100644 --- a/src/node/services/agentSession.startupAutoRetry.test.ts +++ b/src/node/services/agentSession.startupAutoRetry.test.ts @@ -1019,6 +1019,53 @@ describe("AgentSession startup auto-retry recovery", () => { session.dispose(); }); + test("does not schedule startup auto-retry when pending ask_user_question turn has error metadata", async () => { + const workspaceId = "startup-retry-ask-user-with-error"; + const { session, historyService, events, cleanup } = await createSessionBundle(workspaceId); + cleanups.push(cleanup); + + const writePartialResult = await historyService.writePartial( + workspaceId, + createMuxMessage( + "assistant-1", + "assistant", + "", + { + timestamp: Date.now(), + model: "anthropic:claude-sonnet-4-5", + partial: true, + agentId: "exec", + error: "Connection dropped", + errorType: "network", + }, + [ + { + type: "dynamic-tool", + state: "input-available", + toolCallId: "tool-ask", + toolName: "ask_user_question", + input: { question: "Name?" }, + }, + ] + ) + ); + expect(writePartialResult.success).toBe(true); + + const startupRetryModelHint = await session.getStartupAutoRetryModelHint(); + expect(startupRetryModelHint).toBeNull(); + + session.ensureStartupAutoRetryCheck(); + + const startupCheckPromise = ( + session as unknown as { startupAutoRetryCheckPromise: Promise | null } + ).startupAutoRetryCheckPromise; + await startupCheckPromise; + + expect(events.some((event) => event.type === "auto-retry-scheduled")).toBe(false); + + session.dispose(); + }); + test("does not schedule startup auto-retry when a failed tool follows ask_user_question", async () => { const workspaceId = "startup-retry-ask-user-failed-tail"; const { session, historyService, events, cleanup } = await createSessionBundle(workspaceId); diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index be1eedb2b0..c3b1fc3661 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -943,12 +943,6 @@ export class AgentSession { return false; } - // Error metadata means the turn has failed; startup auto-retry should treat - // this as interrupted/retryable, not an intentional waiting state. - if (message.metadata?.error != null) { - return false; - } - let latestPendingQuestionIndex = -1; for (let partIndex = 0; partIndex < message.parts.length; partIndex += 1) { const part = message.parts[partIndex]; From 1a4dbb85fe2c182aa760d9c8d36b0cf6636f27ea Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Thu, 19 Mar 2026 14:15:24 +0000 Subject: [PATCH 19/33] =?UTF-8?q?=F0=9F=A4=96=20fix:=20only=20suppress=20r?= =?UTF-8?q?etry=20UI=20when=20awaiting=20question=20is=20visible?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/browser/components/ChatPane/ChatPane.tsx | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/browser/components/ChatPane/ChatPane.tsx b/src/browser/components/ChatPane/ChatPane.tsx index 6f943dcfff..fdde6c7fc3 100644 --- a/src/browser/components/ChatPane/ChatPane.tsx +++ b/src/browser/components/ChatPane/ChatPane.tsx @@ -293,6 +293,15 @@ export const ChatPane: React.FC = (props) => { ); const deferredMessages = shouldBypassDeferral ? transformedMessages : deferredTransformedMessages; + const hasVisibleExecutingAskUserQuestion = deferredMessages.some( + (message) => + message.type === "tool" && + message.toolName === "ask_user_question" && + message.status === "executing" + ); + const suppressRetryForVisibleAwaitingQuestion = + workspaceState.awaitingUserQuestion && hasVisibleExecutingAskUserQuestion; + const latestMessageId = getLastNonDecorativeMessage(deferredMessages)?.id ?? null; const messageListContextValue = useMemo( () => ({ @@ -573,7 +582,7 @@ export const ChatPane: React.FC = (props) => { workspaceState.pendingStreamStartTime, workspaceState.runtimeStatus, workspaceState.lastAbortReason, - workspaceState.awaitingUserQuestion + suppressRetryForVisibleAwaitingQuestion ) : null; @@ -864,7 +873,7 @@ export const ChatPane: React.FC = (props) => { {shouldShowInterruptedBarrier( msg, deferredMessages, - workspaceState.awaitingUserQuestion + suppressRetryForVisibleAwaitingQuestion ) && } ); From f039f0b7811d07fccbcaab787a04316ada17b0f0 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Thu, 19 Mar 2026 14:27:28 +0000 Subject: [PATCH 20/33] Use authoritative awaiting flag for interruption barriers --- src/browser/components/ChatPane/ChatPane.tsx | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/src/browser/components/ChatPane/ChatPane.tsx b/src/browser/components/ChatPane/ChatPane.tsx index fdde6c7fc3..6f943dcfff 100644 --- a/src/browser/components/ChatPane/ChatPane.tsx +++ b/src/browser/components/ChatPane/ChatPane.tsx @@ -293,15 +293,6 @@ export const ChatPane: React.FC = (props) => { ); const deferredMessages = shouldBypassDeferral ? transformedMessages : deferredTransformedMessages; - const hasVisibleExecutingAskUserQuestion = deferredMessages.some( - (message) => - message.type === "tool" && - message.toolName === "ask_user_question" && - message.status === "executing" - ); - const suppressRetryForVisibleAwaitingQuestion = - workspaceState.awaitingUserQuestion && hasVisibleExecutingAskUserQuestion; - const latestMessageId = getLastNonDecorativeMessage(deferredMessages)?.id ?? null; const messageListContextValue = useMemo( () => ({ @@ -582,7 +573,7 @@ export const ChatPane: React.FC = (props) => { workspaceState.pendingStreamStartTime, workspaceState.runtimeStatus, workspaceState.lastAbortReason, - suppressRetryForVisibleAwaitingQuestion + workspaceState.awaitingUserQuestion ) : null; @@ -873,7 +864,7 @@ export const ChatPane: React.FC = (props) => { {shouldShowInterruptedBarrier( msg, deferredMessages, - suppressRetryForVisibleAwaitingQuestion + workspaceState.awaitingUserQuestion ) && } ); From bc8bc13f6e26eaeb9573645cfa02a242b7884707 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Thu, 19 Mar 2026 14:39:05 +0000 Subject: [PATCH 21/33] Clear awaiting state when question row is truncated --- .../StreamingMessageAggregator.status.test.ts | 4 ++-- .../messages/StreamingMessageAggregator.ts | 21 +++++++++++++++---- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/src/browser/utils/messages/StreamingMessageAggregator.status.test.ts b/src/browser/utils/messages/StreamingMessageAggregator.status.test.ts index 65f89571ce..33b7484834 100644 --- a/src/browser/utils/messages/StreamingMessageAggregator.status.test.ts +++ b/src/browser/utils/messages/StreamingMessageAggregator.status.test.ts @@ -346,7 +346,7 @@ describe("ask_user_question waiting state", () => { expect(aggregator.hasAwaitingUserQuestion()).toBe(false); }); - it("keeps awaiting state even when display truncation hides the ask_user_question row", () => { + it("does not report awaiting input when truncation hides the ask_user_question row", () => { const aggregator = new StreamingMessageAggregator("2024-01-01T00:00:00.000Z"); const trailingToolParts = Array.from({ length: 80 }, (_, index) => ({ @@ -398,7 +398,7 @@ describe("ask_user_question waiting state", () => { (message) => message.type === "tool" && message.toolName === "ask_user_question" ) ).toBe(false); - expect(aggregator.hasAwaitingUserQuestion()).toBe(true); + expect(aggregator.hasAwaitingUserQuestion()).toBe(false); }); it("does not report awaiting input when latest assistant turn has stream error metadata", () => { diff --git a/src/browser/utils/messages/StreamingMessageAggregator.ts b/src/browser/utils/messages/StreamingMessageAggregator.ts index c0268a5c22..ab08297903 100644 --- a/src/browser/utils/messages/StreamingMessageAggregator.ts +++ b/src/browser/utils/messages/StreamingMessageAggregator.ts @@ -834,11 +834,10 @@ export class StreamingMessageAggregator { const showSyntheticMessages = typeof window !== "undefined" && window.api?.debugLlmRequest === true; - // Use untruncated history so long turns cannot hide an awaiting question - // behind history-hidden markers in getDisplayedMessages(). + // Start from untruncated history so we identify the latest assistant turn + // even when recent transcript rows include structural markers. const allMessages = this.getAllMessages(); - // Find the latest history message that is visible in the transcript. for (let i = allMessages.length - 1; i >= 0; i--) { const message = allMessages[i]; const isSynthetic = message.metadata?.synthetic === true; @@ -857,7 +856,21 @@ export class StreamingMessageAggregator { return false; } - return getAwaitingAskUserQuestionToolCallId(message) !== null; + const awaitingToolCallId = getAwaitingAskUserQuestionToolCallId(message); + if (awaitingToolCallId === null) { + return false; + } + + // Only surface workspace-level awaiting state when the matching + // ask_user_question row is still visible. Otherwise the transcript has no + // answer affordance and should recover through interrupted/retry UI. + return this.getDisplayedMessages().some( + (displayedMessage) => + displayedMessage.type === "tool" && + displayedMessage.toolName === "ask_user_question" && + displayedMessage.toolCallId === awaitingToolCallId && + displayedMessage.status === "executing" + ); } return false; From 8ea39e8877f97a3cae6df47300941dbe39af36f2 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Thu, 19 Mar 2026 15:02:31 +0000 Subject: [PATCH 22/33] Treat post-question tool output as interrupted tail state --- .../StreamingMessageAggregator.status.test.ts | 16 ++++++-- .../messages/StreamingMessageAggregator.ts | 37 ++++--------------- .../utils/messages/retryEligibility.test.ts | 12 +----- src/common/utils/messages/retryEligibility.ts | 12 +++++- .../agentSession.startupAutoRetry.test.ts | 6 +-- src/node/services/agentSession.ts | 16 +++----- 6 files changed, 42 insertions(+), 57 deletions(-) diff --git a/src/browser/utils/messages/StreamingMessageAggregator.status.test.ts b/src/browser/utils/messages/StreamingMessageAggregator.status.test.ts index 33b7484834..d2ecb4d1ed 100644 --- a/src/browser/utils/messages/StreamingMessageAggregator.status.test.ts +++ b/src/browser/utils/messages/StreamingMessageAggregator.status.test.ts @@ -94,7 +94,7 @@ describe("ask_user_question waiting state", () => { expect(aggregator.hasAwaitingUserQuestion()).toBe(true); }); - it("keeps awaiting state when ask_user_question is followed by completed tool parts", () => { + it("does not report awaiting input when completed tool output follows ask_user_question", () => { const aggregator = new StreamingMessageAggregator("2024-01-01T00:00:00.000Z"); aggregator.loadHistoricalMessages([ @@ -138,7 +138,15 @@ describe("ask_user_question waiting state", () => { }, ]); - expect(aggregator.hasAwaitingUserQuestion()).toBe(true); + expect(aggregator.hasAwaitingUserQuestion()).toBe(false); + + const askRow = aggregator + .getDisplayedMessages() + .find((message) => message.type === "tool" && message.toolName === "ask_user_question"); + if (askRow?.type !== "tool") { + throw new Error("Expected ask_user_question tool row"); + } + expect(askRow.status).toBe("interrupted"); }); it("does not report awaiting input when a later tool result fails after the question", () => { @@ -193,7 +201,7 @@ describe("ask_user_question waiting state", () => { if (askRow?.type !== "tool") { throw new Error("Expected ask_user_question tool row"); } - expect(askRow.status).toBe("executing"); + expect(askRow.status).toBe("interrupted"); }); it("does not report awaiting input when a later failed redacted tool follows the question", () => { @@ -248,7 +256,7 @@ describe("ask_user_question waiting state", () => { if (askRow?.type !== "tool") { throw new Error("Expected ask_user_question tool row"); } - expect(askRow.status).toBe("executing"); + expect(askRow.status).toBe("interrupted"); }); it("does not report awaiting input when a later partial text segment follows the question", () => { diff --git a/src/browser/utils/messages/StreamingMessageAggregator.ts b/src/browser/utils/messages/StreamingMessageAggregator.ts index ab08297903..a682900731 100644 --- a/src/browser/utils/messages/StreamingMessageAggregator.ts +++ b/src/browser/utils/messages/StreamingMessageAggregator.ts @@ -170,7 +170,6 @@ function hasFailureResult(result: unknown): boolean { interface AskUserQuestionResolutionOptions { suppressForMessageError: boolean; - suppressForLaterFailedTool: boolean; } /** @@ -210,33 +209,16 @@ function resolveAskUserQuestionToolCallId( return null; } - // If a later tool call is still unfinished, that later interruption should - // win over the earlier ask_user_question waiting state. - const hasLaterPendingTool = message.parts.some( - (part, partIndex) => - partIndex > latestPendingQuestionIndex && - isDynamicToolPart(part) && - part.state === "input-available" + // ask_user_question execution blocks until the user has answered. If any + // later tool part exists, the question has already been consumed and the turn + // should recover as interrupted/output tail instead of awaiting input. + const hasLaterToolPart = message.parts.some( + (part, partIndex) => partIndex > latestPendingQuestionIndex && isDynamicToolPart(part) ); - if (hasLaterPendingTool) { + if (hasLaterToolPart) { return null; } - if (options.suppressForLaterFailedTool) { - // If a later tool has already failed, the latest visible state is an - // interruption/error and retry affordances should remain visible. - const hasLaterFailedTool = message.parts.some( - (part, partIndex) => - partIndex > latestPendingQuestionIndex && - isDynamicToolPart(part) && - ((part.state === "output-available" && hasFailureResult(part.output)) || - (part.state === "output-redacted" && part.failed === true)) - ); - if (hasLaterFailedTool) { - return null; - } - } - // Persisted partial turns can include additional trailing text/reasoning // emitted after the question. Treat that as an interrupted tail rather than // a pure waiting state. @@ -267,19 +249,16 @@ function resolveAskUserQuestionToolCallId( function getAwaitingAskUserQuestionToolCallId(message: MuxMessage): string | null { return resolveAskUserQuestionToolCallId(message, { suppressForMessageError: true, - suppressForLaterFailedTool: true, }); } /** - * Answer UI should remain available for pending ask_user_question turns even if - * a later tool reported logical failure (e.g. success:false) and startup - * auto-retry intentionally defers. + * Answer UI can stay available for message-level error tails, but once any later + * tool part exists the question has already been consumed and is no longer answerable. */ function getAnswerableAskUserQuestionToolCallId(message: MuxMessage): string | null { return resolveAskUserQuestionToolCallId(message, { suppressForMessageError: false, - suppressForLaterFailedTool: false, }); } diff --git a/src/common/utils/messages/retryEligibility.test.ts b/src/common/utils/messages/retryEligibility.test.ts index 2a36cb6cc8..ca8080f718 100644 --- a/src/common/utils/messages/retryEligibility.test.ts +++ b/src/common/utils/messages/retryEligibility.test.ts @@ -257,7 +257,7 @@ describe("hasInterruptedStream", () => { expect(hasInterruptedStream(messages)).toBe(true); }); - it("returns false when pending ask_user_question turn is followed by plan-display row", () => { + it("returns true when completed tool output follows ask_user_question", () => { const messages: DisplayedMessage[] = [ { type: "tool", @@ -286,17 +286,9 @@ describe("hasInterruptedStream", () => { streamSequence: 1, isLastPartOfMessage: true, }, - { - type: "plan-display", - id: "plan-display-1", - historyId: "plan-display-1", - content: "# Plan", - path: "/tmp/plan.md", - historySequence: Number.MAX_SAFE_INTEGER, - }, ]; - expect(hasInterruptedStream(messages)).toBe(false); + expect(hasInterruptedStream(messages)).toBe(true); }); it("returns true when latest row is stream-error even if turn includes executing ask_user_question", () => { const messages: DisplayedMessage[] = [ diff --git a/src/common/utils/messages/retryEligibility.ts b/src/common/utils/messages/retryEligibility.ts index e9b10f3e47..804567e2f9 100644 --- a/src/common/utils/messages/retryEligibility.ts +++ b/src/common/utils/messages/retryEligibility.ts @@ -163,7 +163,8 @@ export function hasExecutingAskUserQuestionInLatestTurn(messages: DisplayedMessa message.status === "executing" || message.status === "pending" || message.status === "interrupted" || - message.status === "failed" + message.status === "failed" || + message.status === "completed" ); case "assistant": case "reasoning": @@ -175,6 +176,15 @@ export function hasExecutingAskUserQuestionInLatestTurn(messages: DisplayedMessa for (let i = messages.length - 1; i >= 0; i--) { const message = messages[i]; + if ( + message.type === "plan-display" || + message.type === "history-hidden" || + message.type === "workspace-init" || + message.type === "compaction-boundary" + ) { + continue; + } + if (!("historyId" in message)) { continue; } diff --git a/src/node/services/agentSession.startupAutoRetry.test.ts b/src/node/services/agentSession.startupAutoRetry.test.ts index f924803090..1a41f63b0d 100644 --- a/src/node/services/agentSession.startupAutoRetry.test.ts +++ b/src/node/services/agentSession.startupAutoRetry.test.ts @@ -1066,7 +1066,7 @@ describe("AgentSession startup auto-retry recovery", () => { session.dispose(); }); - test("does not schedule startup auto-retry when a failed tool follows ask_user_question", async () => { + test("schedules startup auto-retry when a failed tool follows ask_user_question", async () => { const workspaceId = "startup-retry-ask-user-failed-tail"; const { session, historyService, events, cleanup } = await createSessionBundle(workspaceId); cleanups.push(cleanup); @@ -1113,7 +1113,7 @@ describe("AgentSession startup auto-retry recovery", () => { expect(writePartialResult.success).toBe(true); const startupRetryModelHint = await session.getStartupAutoRetryModelHint(); - expect(startupRetryModelHint).toBeNull(); + expect(startupRetryModelHint).toBe("anthropic:claude-sonnet-4-5"); session.ensureStartupAutoRetryCheck(); @@ -1122,7 +1122,7 @@ describe("AgentSession startup auto-retry recovery", () => { ).startupAutoRetryCheckPromise; await startupCheckPromise; - expect(events.some((event) => event.type === "auto-retry-scheduled")).toBe(false); + expect(events.some((event) => event.type === "auto-retry-scheduled")).toBe(true); session.dispose(); }); diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index c3b1fc3661..8dca5c6b0f 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -959,20 +959,16 @@ export class AgentSession { return false; } - const hasLaterPendingTool = message.parts.some( - (part, partIndex) => - partIndex > latestPendingQuestionIndex && - part.type === "dynamic-tool" && - part.state === "input-available" + // ask_user_question execution blocks on user input. Any later dynamic-tool + // part means the question already resolved and startup recovery should treat + // the turn as interrupted tail output rather than pending input. + const hasLaterToolPart = message.parts.some( + (part, partIndex) => partIndex > latestPendingQuestionIndex && part.type === "dynamic-tool" ); - if (hasLaterPendingTool) { + if (hasLaterToolPart) { return false; } - // Keep logical tool failures after ask_user_question in the waiting state. - // The persisted partial can still be answered/resumed via answerAskUserQuestion - // after restart, so startup auto-retry should not immediately re-run the turn. - // If the stream produced text/reasoning after asking the question, this // should recover as an interrupted tail after restart. if (message.metadata?.partial === true) { From f6fcd1eab8c9355d7a9c8b8b05cb792d9d328cb6 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Thu, 19 Mar 2026 15:14:22 +0000 Subject: [PATCH 23/33] Suppress interrupted marker for answerable question rows --- .../utils/messages/messageUtils.test.ts | 27 +++++++++++++++++++ src/browser/utils/messages/messageUtils.ts | 7 +++++ 2 files changed, 34 insertions(+) diff --git a/src/browser/utils/messages/messageUtils.test.ts b/src/browser/utils/messages/messageUtils.test.ts index ea2309ba9e..8ae1c6fb56 100644 --- a/src/browser/utils/messages/messageUtils.test.ts +++ b/src/browser/utils/messages/messageUtils.test.ts @@ -28,6 +28,33 @@ describe("shouldShowInterruptedBarrier", () => { expect(shouldShowInterruptedBarrier(msg)).toBe(false); }); + it("returns false for executing ask_user_question when the turn also has trailing stream-error", () => { + const askRow: DisplayedMessage = { + type: "tool", + id: "tool-ask", + historyId: "assistant-1", + toolName: "ask_user_question", + toolCallId: "call-ask", + args: { questions: [] }, + status: "executing", + isPartial: true, + historySequence: 2, + streamSequence: 0, + isLastPartOfMessage: true, + }; + + const streamError: DisplayedMessage = { + type: "stream-error", + id: "stream-error-1", + historyId: "assistant-1", + error: "Connection dropped", + errorType: "network", + historySequence: 2, + }; + + expect(shouldShowInterruptedBarrier(askRow, [askRow, streamError])).toBe(false); + }); + it("returns true for trailing partial rows when fallback inference sees later unfinished output", () => { const questionTool: DisplayedMessage = { type: "tool", diff --git a/src/browser/utils/messages/messageUtils.ts b/src/browser/utils/messages/messageUtils.ts index f0094b2182..e07a11d499 100644 --- a/src/browser/utils/messages/messageUtils.ts +++ b/src/browser/utils/messages/messageUtils.ts @@ -135,6 +135,13 @@ export function shouldShowInterruptedBarrier( return false; } + // Keep executing ask_user_question rows free of interruption markers even when + // the same turn has a trailing stream-error row; those questions remain + // answerable and should not show contradictory "interrupted" affordances. + if (msg.type === "tool" && msg.toolName === "ask_user_question" && msg.status === "executing") { + return false; + } + // Only show on the last part of multi-part messages if (!msg.isLastPartOfMessage) return false; From 0de1cde157a627b998e0b4692c6573e61bfe696b Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Thu, 19 Mar 2026 15:28:51 +0000 Subject: [PATCH 24/33] Preserve persisted ask_user_question recovery semantics --- .../StreamingMessageAggregator.status.test.ts | 8 ++--- .../messages/StreamingMessageAggregator.ts | 32 +++++++++++++------ .../agentSession.startupAutoRetry.test.ts | 12 +++---- src/node/services/agentSession.ts | 31 ++++-------------- 4 files changed, 38 insertions(+), 45 deletions(-) diff --git a/src/browser/utils/messages/StreamingMessageAggregator.status.test.ts b/src/browser/utils/messages/StreamingMessageAggregator.status.test.ts index d2ecb4d1ed..4d79ff4ba2 100644 --- a/src/browser/utils/messages/StreamingMessageAggregator.status.test.ts +++ b/src/browser/utils/messages/StreamingMessageAggregator.status.test.ts @@ -146,7 +146,7 @@ describe("ask_user_question waiting state", () => { if (askRow?.type !== "tool") { throw new Error("Expected ask_user_question tool row"); } - expect(askRow.status).toBe("interrupted"); + expect(askRow.status).toBe("executing"); }); it("does not report awaiting input when a later tool result fails after the question", () => { @@ -201,7 +201,7 @@ describe("ask_user_question waiting state", () => { if (askRow?.type !== "tool") { throw new Error("Expected ask_user_question tool row"); } - expect(askRow.status).toBe("interrupted"); + expect(askRow.status).toBe("executing"); }); it("does not report awaiting input when a later failed redacted tool follows the question", () => { @@ -256,7 +256,7 @@ describe("ask_user_question waiting state", () => { if (askRow?.type !== "tool") { throw new Error("Expected ask_user_question tool row"); } - expect(askRow.status).toBe("interrupted"); + expect(askRow.status).toBe("executing"); }); it("does not report awaiting input when a later partial text segment follows the question", () => { @@ -304,7 +304,7 @@ describe("ask_user_question waiting state", () => { if (askRow?.type !== "tool") { throw new Error("Expected ask_user_question tool row"); } - expect(askRow.status).toBe("interrupted"); + expect(askRow.status).toBe("executing"); }); it("does not treat older question turns as awaiting after chat moves on", () => { const aggregator = new StreamingMessageAggregator("2024-01-01T00:00:00.000Z"); diff --git a/src/browser/utils/messages/StreamingMessageAggregator.ts b/src/browser/utils/messages/StreamingMessageAggregator.ts index a682900731..19b6bb7804 100644 --- a/src/browser/utils/messages/StreamingMessageAggregator.ts +++ b/src/browser/utils/messages/StreamingMessageAggregator.ts @@ -170,6 +170,8 @@ function hasFailureResult(result: unknown): boolean { interface AskUserQuestionResolutionOptions { suppressForMessageError: boolean; + suppressForLaterToolPart: boolean; + suppressForLaterTextOrReasoning: boolean; } /** @@ -209,20 +211,25 @@ function resolveAskUserQuestionToolCallId( return null; } - // ask_user_question execution blocks until the user has answered. If any - // later tool part exists, the question has already been consumed and the turn - // should recover as interrupted/output tail instead of awaiting input. + // A later unfinished tool takes precedence over the earlier question. + const hasLaterPendingTool = message.parts.some( + (part, partIndex) => + partIndex > latestPendingQuestionIndex && + isDynamicToolPart(part) && + part.state === "input-available" + ); + if (hasLaterPendingTool) { + return null; + } + const hasLaterToolPart = message.parts.some( (part, partIndex) => partIndex > latestPendingQuestionIndex && isDynamicToolPart(part) ); - if (hasLaterToolPart) { + if (options.suppressForLaterToolPart && hasLaterToolPart) { return null; } - // Persisted partial turns can include additional trailing text/reasoning - // emitted after the question. Treat that as an interrupted tail rather than - // a pure waiting state. - if (message.metadata?.partial === true) { + if (options.suppressForLaterTextOrReasoning && message.metadata?.partial === true) { const hasLaterTextOrReasoning = message.parts.some((part, partIndex) => { if (partIndex <= latestPendingQuestionIndex) { return false; @@ -249,16 +256,21 @@ function resolveAskUserQuestionToolCallId( function getAwaitingAskUserQuestionToolCallId(message: MuxMessage): string | null { return resolveAskUserQuestionToolCallId(message, { suppressForMessageError: true, + suppressForLaterToolPart: true, + suppressForLaterTextOrReasoning: true, }); } /** - * Answer UI can stay available for message-level error tails, but once any later - * tool part exists the question has already been consumed and is no longer answerable. + * Keep persisted ask_user_question rows answerable after restart, even when + * later partial output exists. answerAskUserQuestion can still resolve the + * pending prompt from disk without replaying the full turn. */ function getAnswerableAskUserQuestionToolCallId(message: MuxMessage): string | null { return resolveAskUserQuestionToolCallId(message, { suppressForMessageError: false, + suppressForLaterToolPart: false, + suppressForLaterTextOrReasoning: false, }); } diff --git a/src/node/services/agentSession.startupAutoRetry.test.ts b/src/node/services/agentSession.startupAutoRetry.test.ts index 1a41f63b0d..6799a75b31 100644 --- a/src/node/services/agentSession.startupAutoRetry.test.ts +++ b/src/node/services/agentSession.startupAutoRetry.test.ts @@ -1066,7 +1066,7 @@ describe("AgentSession startup auto-retry recovery", () => { session.dispose(); }); - test("schedules startup auto-retry when a failed tool follows ask_user_question", async () => { + test("does not schedule startup auto-retry when a failed tool follows ask_user_question", async () => { const workspaceId = "startup-retry-ask-user-failed-tail"; const { session, historyService, events, cleanup } = await createSessionBundle(workspaceId); cleanups.push(cleanup); @@ -1113,7 +1113,7 @@ describe("AgentSession startup auto-retry recovery", () => { expect(writePartialResult.success).toBe(true); const startupRetryModelHint = await session.getStartupAutoRetryModelHint(); - expect(startupRetryModelHint).toBe("anthropic:claude-sonnet-4-5"); + expect(startupRetryModelHint).toBeNull(); session.ensureStartupAutoRetryCheck(); @@ -1122,12 +1122,12 @@ describe("AgentSession startup auto-retry recovery", () => { ).startupAutoRetryCheckPromise; await startupCheckPromise; - expect(events.some((event) => event.type === "auto-retry-scheduled")).toBe(true); + expect(events.some((event) => event.type === "auto-retry-scheduled")).toBe(false); session.dispose(); }); - test("schedules startup auto-retry when text follows ask_user_question", async () => { + test("does not schedule startup auto-retry when text follows ask_user_question", async () => { const workspaceId = "startup-retry-ask-user-text-tail"; const { session, historyService, events, cleanup } = await createSessionBundle(workspaceId); cleanups.push(cleanup); @@ -1170,7 +1170,7 @@ describe("AgentSession startup auto-retry recovery", () => { expect(writePartialResult.success).toBe(true); const startupRetryModelHint = await session.getStartupAutoRetryModelHint(); - expect(startupRetryModelHint).toBe("anthropic:claude-sonnet-4-5"); + expect(startupRetryModelHint).toBeNull(); session.ensureStartupAutoRetryCheck(); @@ -1179,7 +1179,7 @@ describe("AgentSession startup auto-retry recovery", () => { ).startupAutoRetryCheckPromise; await startupCheckPromise; - expect(events.some((event) => event.type === "auto-retry-scheduled")).toBe(true); + expect(events.some((event) => event.type === "auto-retry-scheduled")).toBe(false); session.dispose(); }); diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index 8dca5c6b0f..b9042febf3 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -959,35 +959,16 @@ export class AgentSession { return false; } - // ask_user_question execution blocks on user input. Any later dynamic-tool - // part means the question already resolved and startup recovery should treat - // the turn as interrupted tail output rather than pending input. - const hasLaterToolPart = message.parts.some( - (part, partIndex) => partIndex > latestPendingQuestionIndex && part.type === "dynamic-tool" + const hasLaterPendingTool = message.parts.some( + (part, partIndex) => + partIndex > latestPendingQuestionIndex && + part.type === "dynamic-tool" && + part.state === "input-available" ); - if (hasLaterToolPart) { + if (hasLaterPendingTool) { return false; } - // If the stream produced text/reasoning after asking the question, this - // should recover as an interrupted tail after restart. - if (message.metadata?.partial === true) { - const hasLaterTextOrReasoning = message.parts.some((part, partIndex) => { - if (partIndex <= latestPendingQuestionIndex) { - return false; - } - - return ( - (part.type === "text" && part.text.length > 0) || - (part.type === "reasoning" && part.text.length > 0) - ); - }); - - if (hasLaterTextOrReasoning) { - return false; - } - } - return true; } From e5dec8203cc1d563d3f6f4647459aaac035cac83 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Thu, 19 Mar 2026 15:41:04 +0000 Subject: [PATCH 25/33] Handle redacted and completed tool tails in retry recovery --- .../utils/messages/retryEligibility.test.ts | 33 ++++++++++ src/common/utils/messages/retryEligibility.ts | 3 +- .../agentSession.startupAutoRetry.test.ts | 61 +++++++++++++++++++ src/node/services/agentSession.ts | 28 +++++++++ 4 files changed, 124 insertions(+), 1 deletion(-) diff --git a/src/common/utils/messages/retryEligibility.test.ts b/src/common/utils/messages/retryEligibility.test.ts index ca8080f718..3378acdcc9 100644 --- a/src/common/utils/messages/retryEligibility.test.ts +++ b/src/common/utils/messages/retryEligibility.test.ts @@ -257,6 +257,39 @@ describe("hasInterruptedStream", () => { expect(hasInterruptedStream(messages)).toBe(true); }); + it("returns true when a redacted tool follows executing ask_user_question", () => { + const messages: DisplayedMessage[] = [ + { + type: "tool", + id: "tool-ask", + historyId: "assistant-1", + toolName: "ask_user_question", + toolCallId: "call-ask", + args: { questions: [] }, + status: "executing", + isPartial: true, + historySequence: 2, + streamSequence: 0, + isLastPartOfMessage: false, + }, + { + type: "tool", + id: "tool-bash", + historyId: "assistant-1", + toolName: "bash", + toolCallId: "call-bash", + args: { script: "echo hi" }, + status: "redacted", + isPartial: true, + historySequence: 2, + streamSequence: 1, + isLastPartOfMessage: true, + }, + ]; + + expect(hasInterruptedStream(messages)).toBe(true); + }); + it("returns true when completed tool output follows ask_user_question", () => { const messages: DisplayedMessage[] = [ { diff --git a/src/common/utils/messages/retryEligibility.ts b/src/common/utils/messages/retryEligibility.ts index 804567e2f9..139f796f8e 100644 --- a/src/common/utils/messages/retryEligibility.ts +++ b/src/common/utils/messages/retryEligibility.ts @@ -164,7 +164,8 @@ export function hasExecutingAskUserQuestionInLatestTurn(messages: DisplayedMessa message.status === "pending" || message.status === "interrupted" || message.status === "failed" || - message.status === "completed" + message.status === "completed" || + message.status === "redacted" ); case "assistant": case "reasoning": diff --git a/src/node/services/agentSession.startupAutoRetry.test.ts b/src/node/services/agentSession.startupAutoRetry.test.ts index 6799a75b31..c1a91e2daa 100644 --- a/src/node/services/agentSession.startupAutoRetry.test.ts +++ b/src/node/services/agentSession.startupAutoRetry.test.ts @@ -1127,6 +1127,67 @@ describe("AgentSession startup auto-retry recovery", () => { session.dispose(); }); + test("schedules startup auto-retry when completed tool output follows ask_user_question", async () => { + const workspaceId = "startup-retry-ask-user-completed-tail"; + const { session, historyService, events, cleanup } = await createSessionBundle(workspaceId); + cleanups.push(cleanup); + + const appendUserResult = await historyService.appendToHistory( + workspaceId, + createMuxMessage("user-1", "user", "Hello", { + timestamp: Date.now(), + }) + ); + expect(appendUserResult.success).toBe(true); + + const writePartialResult = await historyService.writePartial( + workspaceId, + createMuxMessage( + "assistant-1", + "assistant", + "", + { + timestamp: Date.now(), + model: "anthropic:claude-sonnet-4-5", + partial: true, + agentId: "exec", + }, + [ + { + type: "dynamic-tool", + state: "input-available", + toolCallId: "tool-ask", + toolName: "ask_user_question", + input: { question: "Name?" }, + }, + { + type: "dynamic-tool", + state: "output-available", + toolCallId: "tool-todo", + toolName: "todo_write", + input: { todos: [] }, + output: { success: true }, + }, + ] + ) + ); + expect(writePartialResult.success).toBe(true); + + const startupRetryModelHint = await session.getStartupAutoRetryModelHint(); + expect(startupRetryModelHint).toBe("anthropic:claude-sonnet-4-5"); + + session.ensureStartupAutoRetryCheck(); + + const startupCheckPromise = ( + session as unknown as { startupAutoRetryCheckPromise: Promise | null } + ).startupAutoRetryCheckPromise; + await startupCheckPromise; + + expect(events.some((event) => event.type === "auto-retry-scheduled")).toBe(true); + + session.dispose(); + }); + test("does not schedule startup auto-retry when text follows ask_user_question", async () => { const workspaceId = "startup-retry-ask-user-text-tail"; const { session, historyService, events, cleanup } = await createSessionBundle(workspaceId); diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index b9042febf3..c6234ad8c2 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -969,6 +969,34 @@ export class AgentSession { return false; } + // Completed/redacted tool output after ask_user_question means restart recovery + // should treat the turn as an interrupted tail (auto-retry), not pending input. + const hasLaterCompletedToolOutput = message.parts.some((part, partIndex) => { + if (partIndex <= latestPendingQuestionIndex || part.type !== "dynamic-tool") { + return false; + } + + if (part.state === "output-redacted") { + return part.failed !== true; + } + + if (part.state !== "output-available") { + return false; + } + + const output = part.output; + const isExplicitFailure = + typeof output === "object" && + output !== null && + (("success" in output && output.success === false) || + ("error" in output && Boolean(output.error))); + + return !isExplicitFailure; + }); + if (hasLaterCompletedToolOutput) { + return false; + } + return true; } From 2a9d63a50024c2c57ced47cfbbf1edcbdef77c1d Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Thu, 19 Mar 2026 15:56:15 +0000 Subject: [PATCH 26/33] Keep sibling pending-question recovery paths intact --- .../StreamingMessageAggregator.status.test.ts | 54 +++++++++++++++++ .../messages/StreamingMessageAggregator.ts | 17 ++---- .../agentSession.startupAutoRetry.test.ts | 60 +++++++++++++++++++ src/node/services/agentSession.ts | 10 ---- 4 files changed, 120 insertions(+), 21 deletions(-) diff --git a/src/browser/utils/messages/StreamingMessageAggregator.status.test.ts b/src/browser/utils/messages/StreamingMessageAggregator.status.test.ts index 4d79ff4ba2..f97bde1733 100644 --- a/src/browser/utils/messages/StreamingMessageAggregator.status.test.ts +++ b/src/browser/utils/messages/StreamingMessageAggregator.status.test.ts @@ -94,6 +94,60 @@ describe("ask_user_question waiting state", () => { expect(aggregator.hasAwaitingUserQuestion()).toBe(true); }); + it("keeps awaiting input when sibling input-available tools follow ask_user_question", () => { + const aggregator = new StreamingMessageAggregator("2024-01-01T00:00:00.000Z"); + + aggregator.loadHistoricalMessages([ + { + id: "assistant-1", + role: "assistant" as const, + parts: [ + { + type: "dynamic-tool" as const, + toolCallId: "call-ask-1", + toolName: "ask_user_question", + state: "input-available" as const, + input: { + questions: [ + { + header: "Approach", + question: "Which approach should we take?", + options: [ + { label: "A", description: "Approach A" }, + { label: "B", description: "Approach B" }, + ], + multiSelect: false, + }, + ], + }, + }, + { + type: "dynamic-tool" as const, + toolCallId: "call-todo-1", + toolName: "todo_write", + state: "input-available" as const, + input: { todos: [{ content: "Waiting for answers", status: "in_progress" }] }, + }, + ], + metadata: { + timestamp: 1000, + historySequence: 1, + partial: true, + }, + }, + ]); + + expect(aggregator.hasAwaitingUserQuestion()).toBe(true); + + const askRow = aggregator + .getDisplayedMessages() + .find((message) => message.type === "tool" && message.toolName === "ask_user_question"); + if (askRow?.type !== "tool") { + throw new Error("Expected ask_user_question tool row"); + } + expect(askRow.status).toBe("executing"); + }); + it("does not report awaiting input when completed tool output follows ask_user_question", () => { const aggregator = new StreamingMessageAggregator("2024-01-01T00:00:00.000Z"); diff --git a/src/browser/utils/messages/StreamingMessageAggregator.ts b/src/browser/utils/messages/StreamingMessageAggregator.ts index 19b6bb7804..8728291b44 100644 --- a/src/browser/utils/messages/StreamingMessageAggregator.ts +++ b/src/browser/utils/messages/StreamingMessageAggregator.ts @@ -211,21 +211,16 @@ function resolveAskUserQuestionToolCallId( return null; } - // A later unfinished tool takes precedence over the earlier question. - const hasLaterPendingTool = message.parts.some( + // Provider-planned sibling tools can remain input-available after the question; + // keep ask_user_question answerable in that case. Only resolved tool output + // tails should suppress the awaiting-input workspace signal. + const hasLaterResolvedToolPart = message.parts.some( (part, partIndex) => partIndex > latestPendingQuestionIndex && isDynamicToolPart(part) && - part.state === "input-available" - ); - if (hasLaterPendingTool) { - return null; - } - - const hasLaterToolPart = message.parts.some( - (part, partIndex) => partIndex > latestPendingQuestionIndex && isDynamicToolPart(part) + (part.state === "output-available" || part.state === "output-redacted") ); - if (options.suppressForLaterToolPart && hasLaterToolPart) { + if (options.suppressForLaterToolPart && hasLaterResolvedToolPart) { return null; } diff --git a/src/node/services/agentSession.startupAutoRetry.test.ts b/src/node/services/agentSession.startupAutoRetry.test.ts index c1a91e2daa..0e09c0bbf8 100644 --- a/src/node/services/agentSession.startupAutoRetry.test.ts +++ b/src/node/services/agentSession.startupAutoRetry.test.ts @@ -1127,6 +1127,66 @@ describe("AgentSession startup auto-retry recovery", () => { session.dispose(); }); + test("does not schedule startup auto-retry when sibling input-available tool follows ask_user_question", async () => { + const workspaceId = "startup-retry-ask-user-sibling-pending"; + const { session, historyService, events, cleanup } = await createSessionBundle(workspaceId); + cleanups.push(cleanup); + + const appendUserResult = await historyService.appendToHistory( + workspaceId, + createMuxMessage("user-1", "user", "Hello", { + timestamp: Date.now(), + }) + ); + expect(appendUserResult.success).toBe(true); + + const writePartialResult = await historyService.writePartial( + workspaceId, + createMuxMessage( + "assistant-1", + "assistant", + "", + { + timestamp: Date.now(), + model: "anthropic:claude-sonnet-4-5", + partial: true, + agentId: "exec", + }, + [ + { + type: "dynamic-tool", + state: "input-available", + toolCallId: "tool-ask", + toolName: "ask_user_question", + input: { question: "Name?" }, + }, + { + type: "dynamic-tool", + state: "input-available", + toolCallId: "tool-todo", + toolName: "todo_write", + input: { todos: [] }, + }, + ] + ) + ); + expect(writePartialResult.success).toBe(true); + + const startupRetryModelHint = await session.getStartupAutoRetryModelHint(); + expect(startupRetryModelHint).toBeNull(); + + session.ensureStartupAutoRetryCheck(); + + const startupCheckPromise = ( + session as unknown as { startupAutoRetryCheckPromise: Promise | null } + ).startupAutoRetryCheckPromise; + await startupCheckPromise; + + expect(events.some((event) => event.type === "auto-retry-scheduled")).toBe(false); + + session.dispose(); + }); + test("schedules startup auto-retry when completed tool output follows ask_user_question", async () => { const workspaceId = "startup-retry-ask-user-completed-tail"; const { session, historyService, events, cleanup } = await createSessionBundle(workspaceId); diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index c6234ad8c2..8bccf22fcd 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -959,16 +959,6 @@ export class AgentSession { return false; } - const hasLaterPendingTool = message.parts.some( - (part, partIndex) => - partIndex > latestPendingQuestionIndex && - part.type === "dynamic-tool" && - part.state === "input-available" - ); - if (hasLaterPendingTool) { - return false; - } - // Completed/redacted tool output after ask_user_question means restart recovery // should treat the turn as an interrupted tail (auto-retry), not pending input. const hasLaterCompletedToolOutput = message.parts.some((part, partIndex) => { From a739e6dda9dd4d89cccf445b6500cefdc81c00b8 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Thu, 19 Mar 2026 16:11:36 +0000 Subject: [PATCH 27/33] Keep pending question state across truncation and restart --- .../StreamingMessageAggregator.status.test.ts | 20 ++++++------- .../messages/StreamingMessageAggregator.ts | 20 ++----------- .../agentSession.startupAutoRetry.test.ts | 6 ++-- src/node/services/agentSession.ts | 28 ------------------- 4 files changed, 16 insertions(+), 58 deletions(-) diff --git a/src/browser/utils/messages/StreamingMessageAggregator.status.test.ts b/src/browser/utils/messages/StreamingMessageAggregator.status.test.ts index f97bde1733..5f06fcd150 100644 --- a/src/browser/utils/messages/StreamingMessageAggregator.status.test.ts +++ b/src/browser/utils/messages/StreamingMessageAggregator.status.test.ts @@ -148,7 +148,7 @@ describe("ask_user_question waiting state", () => { expect(askRow.status).toBe("executing"); }); - it("does not report awaiting input when completed tool output follows ask_user_question", () => { + it("keeps awaiting input when completed tool output follows ask_user_question", () => { const aggregator = new StreamingMessageAggregator("2024-01-01T00:00:00.000Z"); aggregator.loadHistoricalMessages([ @@ -192,7 +192,7 @@ describe("ask_user_question waiting state", () => { }, ]); - expect(aggregator.hasAwaitingUserQuestion()).toBe(false); + expect(aggregator.hasAwaitingUserQuestion()).toBe(true); const askRow = aggregator .getDisplayedMessages() @@ -203,7 +203,7 @@ describe("ask_user_question waiting state", () => { expect(askRow.status).toBe("executing"); }); - it("does not report awaiting input when a later tool result fails after the question", () => { + it("keeps awaiting input when a later tool result fails after the question", () => { const aggregator = new StreamingMessageAggregator("2024-01-01T00:00:00.000Z"); aggregator.loadHistoricalMessages([ @@ -247,7 +247,7 @@ describe("ask_user_question waiting state", () => { }, ]); - expect(aggregator.hasAwaitingUserQuestion()).toBe(false); + expect(aggregator.hasAwaitingUserQuestion()).toBe(true); const askRow = aggregator .getDisplayedMessages() @@ -258,7 +258,7 @@ describe("ask_user_question waiting state", () => { expect(askRow.status).toBe("executing"); }); - it("does not report awaiting input when a later failed redacted tool follows the question", () => { + it("keeps awaiting input when a later failed redacted tool follows the question", () => { const aggregator = new StreamingMessageAggregator("2024-01-01T00:00:00.000Z"); aggregator.loadHistoricalMessages([ @@ -302,7 +302,7 @@ describe("ask_user_question waiting state", () => { }, ]); - expect(aggregator.hasAwaitingUserQuestion()).toBe(false); + expect(aggregator.hasAwaitingUserQuestion()).toBe(true); const askRow = aggregator .getDisplayedMessages() @@ -313,7 +313,7 @@ describe("ask_user_question waiting state", () => { expect(askRow.status).toBe("executing"); }); - it("does not report awaiting input when a later partial text segment follows the question", () => { + it("keeps awaiting input when a later partial text segment follows the question", () => { const aggregator = new StreamingMessageAggregator("2024-01-01T00:00:00.000Z"); aggregator.loadHistoricalMessages([ @@ -350,7 +350,7 @@ describe("ask_user_question waiting state", () => { }, ]); - expect(aggregator.hasAwaitingUserQuestion()).toBe(false); + expect(aggregator.hasAwaitingUserQuestion()).toBe(true); const askRow = aggregator .getDisplayedMessages() @@ -408,7 +408,7 @@ describe("ask_user_question waiting state", () => { expect(aggregator.hasAwaitingUserQuestion()).toBe(false); }); - it("does not report awaiting input when truncation hides the ask_user_question row", () => { + it("keeps awaiting input when truncation hides the ask_user_question row", () => { const aggregator = new StreamingMessageAggregator("2024-01-01T00:00:00.000Z"); const trailingToolParts = Array.from({ length: 80 }, (_, index) => ({ @@ -460,7 +460,7 @@ describe("ask_user_question waiting state", () => { (message) => message.type === "tool" && message.toolName === "ask_user_question" ) ).toBe(false); - expect(aggregator.hasAwaitingUserQuestion()).toBe(false); + expect(aggregator.hasAwaitingUserQuestion()).toBe(true); }); it("does not report awaiting input when latest assistant turn has stream error metadata", () => { diff --git a/src/browser/utils/messages/StreamingMessageAggregator.ts b/src/browser/utils/messages/StreamingMessageAggregator.ts index 8728291b44..150c27a1a8 100644 --- a/src/browser/utils/messages/StreamingMessageAggregator.ts +++ b/src/browser/utils/messages/StreamingMessageAggregator.ts @@ -251,8 +251,8 @@ function resolveAskUserQuestionToolCallId( function getAwaitingAskUserQuestionToolCallId(message: MuxMessage): string | null { return resolveAskUserQuestionToolCallId(message, { suppressForMessageError: true, - suppressForLaterToolPart: true, - suppressForLaterTextOrReasoning: true, + suppressForLaterToolPart: false, + suppressForLaterTextOrReasoning: false, }); } @@ -842,21 +842,7 @@ export class StreamingMessageAggregator { return false; } - const awaitingToolCallId = getAwaitingAskUserQuestionToolCallId(message); - if (awaitingToolCallId === null) { - return false; - } - - // Only surface workspace-level awaiting state when the matching - // ask_user_question row is still visible. Otherwise the transcript has no - // answer affordance and should recover through interrupted/retry UI. - return this.getDisplayedMessages().some( - (displayedMessage) => - displayedMessage.type === "tool" && - displayedMessage.toolName === "ask_user_question" && - displayedMessage.toolCallId === awaitingToolCallId && - displayedMessage.status === "executing" - ); + return getAwaitingAskUserQuestionToolCallId(message) !== null; } return false; diff --git a/src/node/services/agentSession.startupAutoRetry.test.ts b/src/node/services/agentSession.startupAutoRetry.test.ts index 0e09c0bbf8..f473b7aa99 100644 --- a/src/node/services/agentSession.startupAutoRetry.test.ts +++ b/src/node/services/agentSession.startupAutoRetry.test.ts @@ -1187,7 +1187,7 @@ describe("AgentSession startup auto-retry recovery", () => { session.dispose(); }); - test("schedules startup auto-retry when completed tool output follows ask_user_question", async () => { + test("does not schedule startup auto-retry when completed tool output follows ask_user_question", async () => { const workspaceId = "startup-retry-ask-user-completed-tail"; const { session, historyService, events, cleanup } = await createSessionBundle(workspaceId); cleanups.push(cleanup); @@ -1234,7 +1234,7 @@ describe("AgentSession startup auto-retry recovery", () => { expect(writePartialResult.success).toBe(true); const startupRetryModelHint = await session.getStartupAutoRetryModelHint(); - expect(startupRetryModelHint).toBe("anthropic:claude-sonnet-4-5"); + expect(startupRetryModelHint).toBeNull(); session.ensureStartupAutoRetryCheck(); @@ -1243,7 +1243,7 @@ describe("AgentSession startup auto-retry recovery", () => { ).startupAutoRetryCheckPromise; await startupCheckPromise; - expect(events.some((event) => event.type === "auto-retry-scheduled")).toBe(true); + expect(events.some((event) => event.type === "auto-retry-scheduled")).toBe(false); session.dispose(); }); diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index 8bccf22fcd..c6e0aac7e3 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -959,34 +959,6 @@ export class AgentSession { return false; } - // Completed/redacted tool output after ask_user_question means restart recovery - // should treat the turn as an interrupted tail (auto-retry), not pending input. - const hasLaterCompletedToolOutput = message.parts.some((part, partIndex) => { - if (partIndex <= latestPendingQuestionIndex || part.type !== "dynamic-tool") { - return false; - } - - if (part.state === "output-redacted") { - return part.failed !== true; - } - - if (part.state !== "output-available") { - return false; - } - - const output = part.output; - const isExplicitFailure = - typeof output === "object" && - output !== null && - (("success" in output && output.success === false) || - ("error" in output && Boolean(output.error))); - - return !isExplicitFailure; - }); - if (hasLaterCompletedToolOutput) { - return false; - } - return true; } From 0b562f349c55a78e2b92d9ef9c9d882c69f10518 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Thu, 19 Mar 2026 16:29:38 +0000 Subject: [PATCH 28/33] Clear awaiting flag for interrupted ask_user_question tails --- .../StreamingMessageAggregator.status.test.ts | 16 ++++++------ .../messages/StreamingMessageAggregator.ts | 26 ++++++++++++++++--- 2 files changed, 31 insertions(+), 11 deletions(-) diff --git a/src/browser/utils/messages/StreamingMessageAggregator.status.test.ts b/src/browser/utils/messages/StreamingMessageAggregator.status.test.ts index 5f06fcd150..4a1a25b8e6 100644 --- a/src/browser/utils/messages/StreamingMessageAggregator.status.test.ts +++ b/src/browser/utils/messages/StreamingMessageAggregator.status.test.ts @@ -148,7 +148,7 @@ describe("ask_user_question waiting state", () => { expect(askRow.status).toBe("executing"); }); - it("keeps awaiting input when completed tool output follows ask_user_question", () => { + it("clears awaiting input when completed tool output follows ask_user_question", () => { const aggregator = new StreamingMessageAggregator("2024-01-01T00:00:00.000Z"); aggregator.loadHistoricalMessages([ @@ -192,7 +192,7 @@ describe("ask_user_question waiting state", () => { }, ]); - expect(aggregator.hasAwaitingUserQuestion()).toBe(true); + expect(aggregator.hasAwaitingUserQuestion()).toBe(false); const askRow = aggregator .getDisplayedMessages() @@ -203,7 +203,7 @@ describe("ask_user_question waiting state", () => { expect(askRow.status).toBe("executing"); }); - it("keeps awaiting input when a later tool result fails after the question", () => { + it("clears awaiting input when a later tool result fails after the question", () => { const aggregator = new StreamingMessageAggregator("2024-01-01T00:00:00.000Z"); aggregator.loadHistoricalMessages([ @@ -247,7 +247,7 @@ describe("ask_user_question waiting state", () => { }, ]); - expect(aggregator.hasAwaitingUserQuestion()).toBe(true); + expect(aggregator.hasAwaitingUserQuestion()).toBe(false); const askRow = aggregator .getDisplayedMessages() @@ -258,7 +258,7 @@ describe("ask_user_question waiting state", () => { expect(askRow.status).toBe("executing"); }); - it("keeps awaiting input when a later failed redacted tool follows the question", () => { + it("clears awaiting input when a later failed redacted tool follows the question", () => { const aggregator = new StreamingMessageAggregator("2024-01-01T00:00:00.000Z"); aggregator.loadHistoricalMessages([ @@ -302,7 +302,7 @@ describe("ask_user_question waiting state", () => { }, ]); - expect(aggregator.hasAwaitingUserQuestion()).toBe(true); + expect(aggregator.hasAwaitingUserQuestion()).toBe(false); const askRow = aggregator .getDisplayedMessages() @@ -313,7 +313,7 @@ describe("ask_user_question waiting state", () => { expect(askRow.status).toBe("executing"); }); - it("keeps awaiting input when a later partial text segment follows the question", () => { + it("clears awaiting input when a later partial text segment follows the question", () => { const aggregator = new StreamingMessageAggregator("2024-01-01T00:00:00.000Z"); aggregator.loadHistoricalMessages([ @@ -350,7 +350,7 @@ describe("ask_user_question waiting state", () => { }, ]); - expect(aggregator.hasAwaitingUserQuestion()).toBe(true); + expect(aggregator.hasAwaitingUserQuestion()).toBe(false); const askRow = aggregator .getDisplayedMessages() diff --git a/src/browser/utils/messages/StreamingMessageAggregator.ts b/src/browser/utils/messages/StreamingMessageAggregator.ts index 150c27a1a8..782bc57e9a 100644 --- a/src/browser/utils/messages/StreamingMessageAggregator.ts +++ b/src/browser/utils/messages/StreamingMessageAggregator.ts @@ -251,8 +251,8 @@ function resolveAskUserQuestionToolCallId( function getAwaitingAskUserQuestionToolCallId(message: MuxMessage): string | null { return resolveAskUserQuestionToolCallId(message, { suppressForMessageError: true, - suppressForLaterToolPart: false, - suppressForLaterTextOrReasoning: false, + suppressForLaterToolPart: true, + suppressForLaterTextOrReasoning: true, }); } @@ -842,7 +842,27 @@ export class StreamingMessageAggregator { return false; } - return getAwaitingAskUserQuestionToolCallId(message) !== null; + const awaitingToolCallId = getAwaitingAskUserQuestionToolCallId(message); + if (awaitingToolCallId !== null) { + return true; + } + + const answerableToolCallId = getAnswerableAskUserQuestionToolCallId(message); + if (answerableToolCallId === null) { + return false; + } + + // Keep workspace-level awaiting state for recoverable questions that are + // currently hidden by transcript truncation; otherwise users can only hit + // retry paths that drop unfinished tool calls. + const isAnswerableQuestionVisible = this.getDisplayedMessages().some( + (displayedMessage) => + displayedMessage.type === "tool" && + displayedMessage.toolName === "ask_user_question" && + displayedMessage.toolCallId === answerableToolCallId && + displayedMessage.status === "executing" + ); + return !isAnswerableQuestionVisible; } return false; From 42b18f6aea362c0220e1a0442c80fbb5e7c16bd9 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Thu, 19 Mar 2026 16:41:16 +0000 Subject: [PATCH 29/33] Avoid awaiting fallback on errored truncated turns --- .../StreamingMessageAggregator.status.test.ts | 58 ++++++++++++++++++- .../messages/StreamingMessageAggregator.ts | 22 +------ 2 files changed, 57 insertions(+), 23 deletions(-) diff --git a/src/browser/utils/messages/StreamingMessageAggregator.status.test.ts b/src/browser/utils/messages/StreamingMessageAggregator.status.test.ts index 4a1a25b8e6..e1235e24c0 100644 --- a/src/browser/utils/messages/StreamingMessageAggregator.status.test.ts +++ b/src/browser/utils/messages/StreamingMessageAggregator.status.test.ts @@ -408,7 +408,61 @@ describe("ask_user_question waiting state", () => { expect(aggregator.hasAwaitingUserQuestion()).toBe(false); }); - it("keeps awaiting input when truncation hides the ask_user_question row", () => { + it("keeps awaiting input when truncation hides ask_user_question behind pending sibling tools", () => { + const aggregator = new StreamingMessageAggregator("2024-01-01T00:00:00.000Z"); + + const trailingToolParts = Array.from({ length: 80 }, (_, index) => ({ + type: "dynamic-tool" as const, + toolCallId: `call-todo-${index}`, + toolName: "todo_write", + state: "input-available" as const, + input: { todos: [{ content: `Task ${index}`, status: "in_progress" }] }, + })); + + aggregator.loadHistoricalMessages([ + { + id: "assistant-1", + role: "assistant" as const, + parts: [ + { + type: "dynamic-tool" as const, + toolCallId: "call-ask-1", + toolName: "ask_user_question", + state: "input-available" as const, + input: { + questions: [ + { + header: "Approach", + question: "Which approach should we take?", + options: [ + { label: "A", description: "Approach A" }, + { label: "B", description: "Approach B" }, + ], + multiSelect: false, + }, + ], + }, + }, + ...trailingToolParts, + ], + metadata: { + timestamp: 1000, + historySequence: 1, + partial: true, + }, + }, + ]); + + const displayed = aggregator.getDisplayedMessages(); + expect( + displayed.some( + (message) => message.type === "tool" && message.toolName === "ask_user_question" + ) + ).toBe(false); + expect(aggregator.hasAwaitingUserQuestion()).toBe(true); + }); + + it("clears awaiting input when truncation hides ask_user_question behind resolved tool tails", () => { const aggregator = new StreamingMessageAggregator("2024-01-01T00:00:00.000Z"); const trailingToolParts = Array.from({ length: 80 }, (_, index) => ({ @@ -460,7 +514,7 @@ describe("ask_user_question waiting state", () => { (message) => message.type === "tool" && message.toolName === "ask_user_question" ) ).toBe(false); - expect(aggregator.hasAwaitingUserQuestion()).toBe(true); + expect(aggregator.hasAwaitingUserQuestion()).toBe(false); }); it("does not report awaiting input when latest assistant turn has stream error metadata", () => { diff --git a/src/browser/utils/messages/StreamingMessageAggregator.ts b/src/browser/utils/messages/StreamingMessageAggregator.ts index 782bc57e9a..8148e41090 100644 --- a/src/browser/utils/messages/StreamingMessageAggregator.ts +++ b/src/browser/utils/messages/StreamingMessageAggregator.ts @@ -842,27 +842,7 @@ export class StreamingMessageAggregator { return false; } - const awaitingToolCallId = getAwaitingAskUserQuestionToolCallId(message); - if (awaitingToolCallId !== null) { - return true; - } - - const answerableToolCallId = getAnswerableAskUserQuestionToolCallId(message); - if (answerableToolCallId === null) { - return false; - } - - // Keep workspace-level awaiting state for recoverable questions that are - // currently hidden by transcript truncation; otherwise users can only hit - // retry paths that drop unfinished tool calls. - const isAnswerableQuestionVisible = this.getDisplayedMessages().some( - (displayedMessage) => - displayedMessage.type === "tool" && - displayedMessage.toolName === "ask_user_question" && - displayedMessage.toolCallId === answerableToolCallId && - displayedMessage.status === "executing" - ); - return !isAnswerableQuestionVisible; + return getAwaitingAskUserQuestionToolCallId(message) !== null; } return false; From 989783cca9f17173be594fea1ace2ecb1ed26fa5 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Thu, 19 Mar 2026 16:52:53 +0000 Subject: [PATCH 30/33] Keep all pending ask_user_question tool calls answerable --- .../StreamingMessageAggregator.status.test.ts | 70 +++++++++++++++++++ .../messages/StreamingMessageAggregator.ts | 43 +++++++----- 2 files changed, 97 insertions(+), 16 deletions(-) diff --git a/src/browser/utils/messages/StreamingMessageAggregator.status.test.ts b/src/browser/utils/messages/StreamingMessageAggregator.status.test.ts index e1235e24c0..481bff03d1 100644 --- a/src/browser/utils/messages/StreamingMessageAggregator.status.test.ts +++ b/src/browser/utils/messages/StreamingMessageAggregator.status.test.ts @@ -148,6 +148,76 @@ describe("ask_user_question waiting state", () => { expect(askRow.status).toBe("executing"); }); + it("keeps every pending ask_user_question row answerable in the same turn", () => { + const aggregator = new StreamingMessageAggregator("2024-01-01T00:00:00.000Z"); + + aggregator.loadHistoricalMessages([ + { + id: "assistant-1", + role: "assistant" as const, + parts: [ + { + type: "dynamic-tool" as const, + toolCallId: "call-ask-1", + toolName: "ask_user_question", + state: "input-available" as const, + input: { + questions: [ + { + header: "Approach", + question: "Which approach should we take first?", + options: [ + { label: "A", description: "Approach A" }, + { label: "B", description: "Approach B" }, + ], + multiSelect: false, + }, + ], + }, + }, + { + type: "dynamic-tool" as const, + toolCallId: "call-ask-2", + toolName: "ask_user_question", + state: "input-available" as const, + input: { + questions: [ + { + header: "Verification", + question: "Need anything else before we continue?", + options: [ + { label: "No", description: "Continue" }, + { label: "Yes", description: "Add more checks" }, + ], + multiSelect: false, + }, + ], + }, + }, + ], + metadata: { + timestamp: 1000, + historySequence: 1, + partial: true, + }, + }, + ]); + + expect(aggregator.hasAwaitingUserQuestion()).toBe(true); + + const askRows = aggregator + .getDisplayedMessages() + .filter((message) => message.type === "tool" && message.toolName === "ask_user_question"); + + expect(askRows).toHaveLength(2); + for (const askRow of askRows) { + if (askRow.type !== "tool") { + throw new Error("Expected ask_user_question tool row"); + } + expect(askRow.status).toBe("executing"); + } + }); + it("clears awaiting input when completed tool output follows ask_user_question", () => { const aggregator = new StreamingMessageAggregator("2024-01-01T00:00:00.000Z"); diff --git a/src/browser/utils/messages/StreamingMessageAggregator.ts b/src/browser/utils/messages/StreamingMessageAggregator.ts index 8148e41090..af4a207662 100644 --- a/src/browser/utils/messages/StreamingMessageAggregator.ts +++ b/src/browser/utils/messages/StreamingMessageAggregator.ts @@ -257,16 +257,28 @@ function getAwaitingAskUserQuestionToolCallId(message: MuxMessage): string | nul } /** - * Keep persisted ask_user_question rows answerable after restart, even when - * later partial output exists. answerAskUserQuestion can still resolve the - * pending prompt from disk without replaying the full turn. + * Keep every pending ask_user_question row answerable after restart, even when + * later partial output exists. answerAskUserQuestion resolves by toolCallId, + * so each still-pending tool call must remain executable in replayed UI. */ -function getAnswerableAskUserQuestionToolCallId(message: MuxMessage): string | null { - return resolveAskUserQuestionToolCallId(message, { - suppressForMessageError: false, - suppressForLaterToolPart: false, - suppressForLaterTextOrReasoning: false, - }); +function getAnswerableAskUserQuestionToolCallIds(message: MuxMessage): Set { + const answerableToolCallIds = new Set(); + + if (message.role !== "assistant") { + return answerableToolCallIds; + } + + for (const part of message.parts) { + if ( + isDynamicToolPart(part) && + part.toolName === "ask_user_question" && + part.state === "input-available" + ) { + answerableToolCallIds.add(part.toolCallId); + } + } + + return answerableToolCallIds; } function resolveRouteProvider( @@ -2753,7 +2765,7 @@ export class StreamingMessageAggregator { // Merge adjacent text/reasoning parts for display const mergedParts = mergeAdjacentParts(message.parts); - const answerableAskUserQuestionToolCallId = getAnswerableAskUserQuestionToolCallId(message); + const answerableAskUserQuestionToolCallIds = getAnswerableAskUserQuestionToolCallIds(message); // Find the last part that will produce a DisplayedMessage // (reasoning, text parts with content, OR tool parts) @@ -2839,12 +2851,11 @@ export class StreamingMessageAggregator { // so after restart we should keep it answerable ("executing") instead of // showing retry/auto-resume UX. if (part.toolName === "ask_user_question") { - status = - part.toolCallId === answerableAskUserQuestionToolCallId - ? "executing" - : isPartial - ? "interrupted" - : "executing"; + status = answerableAskUserQuestionToolCallIds.has(part.toolCallId) + ? "executing" + : isPartial + ? "interrupted" + : "executing"; } else if (isPartial) { status = "interrupted"; } else { From 43b39503cfafa59e0b5506441099d34b0c26205c Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Thu, 19 Mar 2026 17:10:33 +0000 Subject: [PATCH 31/33] Keep awaiting question rows visible and sibling tools pending --- .../StreamingMessageAggregator.status.test.ts | 40 +++++++++----- .../messages/StreamingMessageAggregator.ts | 54 +++++++++++++++++++ .../messages/transcriptTruncationPlan.test.ts | 22 ++++++++ .../messages/transcriptTruncationPlan.ts | 12 +++-- 4 files changed, 113 insertions(+), 15 deletions(-) diff --git a/src/browser/utils/messages/StreamingMessageAggregator.status.test.ts b/src/browser/utils/messages/StreamingMessageAggregator.status.test.ts index 481bff03d1..14cf727c16 100644 --- a/src/browser/utils/messages/StreamingMessageAggregator.status.test.ts +++ b/src/browser/utils/messages/StreamingMessageAggregator.status.test.ts @@ -146,6 +146,14 @@ describe("ask_user_question waiting state", () => { throw new Error("Expected ask_user_question tool row"); } expect(askRow.status).toBe("executing"); + + const todoRow = aggregator + .getDisplayedMessages() + .find((message) => message.type === "tool" && message.toolName === "todo_write"); + if (todoRow?.type !== "tool") { + throw new Error("Expected todo_write tool row"); + } + expect(todoRow.status).toBe("pending"); }); it("keeps every pending ask_user_question row answerable in the same turn", () => { @@ -478,7 +486,7 @@ describe("ask_user_question waiting state", () => { expect(aggregator.hasAwaitingUserQuestion()).toBe(false); }); - it("keeps awaiting input when truncation hides ask_user_question behind pending sibling tools", () => { + it("keeps awaiting input and keeps ask_user_question visible with pending sibling tools", () => { const aggregator = new StreamingMessageAggregator("2024-01-01T00:00:00.000Z"); const trailingToolParts = Array.from({ length: 80 }, (_, index) => ({ @@ -524,15 +532,19 @@ describe("ask_user_question waiting state", () => { ]); const displayed = aggregator.getDisplayedMessages(); - expect( - displayed.some( - (message) => message.type === "tool" && message.toolName === "ask_user_question" - ) - ).toBe(false); + const askRow = displayed.find( + (message) => message.type === "tool" && message.toolName === "ask_user_question" + ); + + expect(askRow).toBeDefined(); + if (askRow?.type !== "tool") { + throw new Error("Expected ask_user_question tool row"); + } + expect(askRow.status).toBe("executing"); expect(aggregator.hasAwaitingUserQuestion()).toBe(true); }); - it("clears awaiting input when truncation hides ask_user_question behind resolved tool tails", () => { + it("clears awaiting input when truncation keeps ask_user_question visible behind resolved tool tails", () => { const aggregator = new StreamingMessageAggregator("2024-01-01T00:00:00.000Z"); const trailingToolParts = Array.from({ length: 80 }, (_, index) => ({ @@ -579,11 +591,15 @@ describe("ask_user_question waiting state", () => { ]); const displayed = aggregator.getDisplayedMessages(); - expect( - displayed.some( - (message) => message.type === "tool" && message.toolName === "ask_user_question" - ) - ).toBe(false); + const askRow = displayed.find( + (message) => message.type === "tool" && message.toolName === "ask_user_question" + ); + + expect(askRow).toBeDefined(); + if (askRow?.type !== "tool") { + throw new Error("Expected ask_user_question tool row"); + } + expect(askRow.status).toBe("executing"); expect(aggregator.hasAwaitingUserQuestion()).toBe(false); }); diff --git a/src/browser/utils/messages/StreamingMessageAggregator.ts b/src/browser/utils/messages/StreamingMessageAggregator.ts index af4a207662..2679310153 100644 --- a/src/browser/utils/messages/StreamingMessageAggregator.ts +++ b/src/browser/utils/messages/StreamingMessageAggregator.ts @@ -281,6 +281,41 @@ function getAnswerableAskUserQuestionToolCallIds(message: MuxMessage): Set { + const blockedToolCallIds = new Set(); + + if (message.role !== "assistant") { + return blockedToolCallIds; + } + + const awaitingToolCallId = getAwaitingAskUserQuestionToolCallId(message); + if (awaitingToolCallId === null) { + return blockedToolCallIds; + } + + let hasReachedAwaitingQuestion = false; + for (const part of message.parts) { + if (!hasReachedAwaitingQuestion) { + hasReachedAwaitingQuestion = + isDynamicToolPart(part) && + part.toolName === "ask_user_question" && + part.state === "input-available" && + part.toolCallId === awaitingToolCallId; + continue; + } + + if ( + isDynamicToolPart(part) && + part.state === "input-available" && + part.toolName !== "ask_user_question" + ) { + blockedToolCallIds.add(part.toolCallId); + } + } + + return blockedToolCallIds; +} + function resolveRouteProvider( routeProvider: string | undefined, routedThroughGateway: boolean | undefined @@ -2766,6 +2801,8 @@ export class StreamingMessageAggregator { const mergedParts = mergeAdjacentParts(message.parts); const answerableAskUserQuestionToolCallIds = getAnswerableAskUserQuestionToolCallIds(message); + const inputAvailableToolCallIdsBlockedByAwaitingQuestion = + getInputAvailableToolCallIdsBlockedByAwaitingQuestion(message); // Find the last part that will produce a DisplayedMessage // (reasoning, text parts with content, OR tool parts) @@ -2856,6 +2893,11 @@ export class StreamingMessageAggregator { : isPartial ? "interrupted" : "executing"; + } else if ( + isPartial && + inputAvailableToolCallIdsBlockedByAwaitingQuestion.has(part.toolCallId) + ) { + status = "pending"; } else if (isPartial) { status = "interrupted"; } else { @@ -3043,10 +3085,22 @@ export class StreamingMessageAggregator { // and materialize omission runs as explicit history-hidden marker rows. // Full history is still maintained internally for token counting. if (!this.showAllMessages && displayedMessages.length > MAX_DISPLAYED_MESSAGES) { + const alwaysKeepMessageIds = new Set( + displayedMessages + .filter( + (message) => + message.type === "tool" && + message.toolName === "ask_user_question" && + message.status === "executing" + ) + .map((message) => message.id) + ); + const truncationPlan = buildTranscriptTruncationPlan({ displayedMessages, maxDisplayedMessages: MAX_DISPLAYED_MESSAGES, alwaysKeepMessageTypes: ALWAYS_KEEP_MESSAGE_TYPES, + alwaysKeepMessageIds, }); resultMessages = diff --git a/src/browser/utils/messages/transcriptTruncationPlan.test.ts b/src/browser/utils/messages/transcriptTruncationPlan.test.ts index 3facf942a2..4e40484d1b 100644 --- a/src/browser/utils/messages/transcriptTruncationPlan.test.ts +++ b/src/browser/utils/messages/transcriptTruncationPlan.test.ts @@ -133,6 +133,28 @@ describe("buildTranscriptTruncationPlan", () => { expect(plan.rows[trailingMarkerIndex + 1]?.id).toBe("a2"); }); + test("preserves explicitly pinned message IDs in truncated history", () => { + const displayedMessages: DisplayedMessage[] = [ + user("u0", 0), + assistant("a0", 1), + user("u1", 2), + assistant("a1", 3), + tool("tool-1", 4), + assistant("a2", 5), + user("u2", 6), + assistant("a3", 7), + ]; + + const plan = buildTranscriptTruncationPlan({ + displayedMessages, + maxDisplayedMessages: 3, + alwaysKeepMessageTypes: ALWAYS_KEEP_MESSAGE_TYPES, + alwaysKeepMessageIds: new Set(["a1"]), + }); + + expect(plan.rows.some((message) => message.id === "a1")).toBe(true); + }); + test("caps omission markers by merging older runs", () => { const displayedMessages: DisplayedMessage[] = []; for (let i = 0; i < 20; i++) { diff --git a/src/browser/utils/messages/transcriptTruncationPlan.ts b/src/browser/utils/messages/transcriptTruncationPlan.ts index a5e59dc8cd..5ba5246ab0 100644 --- a/src/browser/utils/messages/transcriptTruncationPlan.ts +++ b/src/browser/utils/messages/transcriptTruncationPlan.ts @@ -30,6 +30,7 @@ export interface BuildTranscriptTruncationPlanArgs { displayedMessages: DisplayedMessage[]; maxDisplayedMessages: number; alwaysKeepMessageTypes: Set; + alwaysKeepMessageIds?: Set; maxHiddenSegments?: number; } @@ -47,7 +48,8 @@ interface OmissionRunState { function collectOmissions( oldMessages: DisplayedMessage[], - alwaysKeepMessageTypes: Set + alwaysKeepMessageTypes: Set, + alwaysKeepMessageIds: Set ): CollectedOmissions { const keptOldMessages: DisplayedMessage[] = []; const segments: OmissionSegment[] = []; @@ -55,7 +57,7 @@ function collectOmissions( let activeRun: OmissionRunState | null = null; for (const message of oldMessages) { - if (alwaysKeepMessageTypes.has(message.type)) { + if (alwaysKeepMessageTypes.has(message.type) || alwaysKeepMessageIds.has(message.id)) { if (activeRun !== null) { segments.push(activeRun); activeRun = null; @@ -192,7 +194,11 @@ export function buildTranscriptTruncationPlan( const recentMessages = args.displayedMessages.slice(-args.maxDisplayedMessages); const oldMessages = args.displayedMessages.slice(0, -args.maxDisplayedMessages); - const omissionCollection = collectOmissions(oldMessages, args.alwaysKeepMessageTypes); + const omissionCollection = collectOmissions( + oldMessages, + args.alwaysKeepMessageTypes, + args.alwaysKeepMessageIds ?? new Set() + ); if (omissionCollection.hiddenCount === 0) { return { rows: [...omissionCollection.keptOldMessages, ...recentMessages], From 87ac791746b46b433ec4d2ba3cc441997beb1087 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Thu, 19 Mar 2026 17:23:15 +0000 Subject: [PATCH 32/33] Pin only latest answerable ask_user_question rows --- .../StreamingMessageAggregator.status.test.ts | 73 +++++++++++++++++++ .../messages/StreamingMessageAggregator.ts | 34 ++++++++- 2 files changed, 106 insertions(+), 1 deletion(-) diff --git a/src/browser/utils/messages/StreamingMessageAggregator.status.test.ts b/src/browser/utils/messages/StreamingMessageAggregator.status.test.ts index 14cf727c16..ca28ee7658 100644 --- a/src/browser/utils/messages/StreamingMessageAggregator.status.test.ts +++ b/src/browser/utils/messages/StreamingMessageAggregator.status.test.ts @@ -486,6 +486,79 @@ describe("ask_user_question waiting state", () => { expect(aggregator.hasAwaitingUserQuestion()).toBe(false); }); + it("does not pin ask_user_question rows from older turns during truncation", () => { + const aggregator = new StreamingMessageAggregator("2024-01-01T00:00:00.000Z"); + + const trailingToolParts = Array.from({ length: 80 }, (_, index) => ({ + type: "dynamic-tool" as const, + toolCallId: `call-todo-${index}`, + toolName: "todo_write", + state: "output-available" as const, + input: { todos: [{ content: `Task ${index}`, status: "in_progress" }] }, + output: { success: true }, + })); + + aggregator.loadHistoricalMessages([ + { + id: "assistant-1", + role: "assistant" as const, + parts: [ + { + type: "dynamic-tool" as const, + toolCallId: "call-ask-1", + toolName: "ask_user_question", + state: "input-available" as const, + input: { + questions: [ + { + header: "Approach", + question: "Which approach should we take?", + options: [ + { label: "A", description: "Approach A" }, + { label: "B", description: "Approach B" }, + ], + multiSelect: false, + }, + ], + }, + }, + ], + metadata: { + timestamp: 1000, + historySequence: 1, + partial: true, + }, + }, + { + id: "user-2", + role: "user" as const, + parts: [{ type: "text" as const, text: "Move on" }], + metadata: { + timestamp: 2000, + historySequence: 2, + }, + }, + { + id: "assistant-3", + role: "assistant" as const, + parts: trailingToolParts, + metadata: { + timestamp: 3000, + historySequence: 3, + partial: true, + }, + }, + ]); + + const displayed = aggregator.getDisplayedMessages(); + expect( + displayed.some( + (message) => message.type === "tool" && message.toolName === "ask_user_question" + ) + ).toBe(false); + expect(aggregator.hasAwaitingUserQuestion()).toBe(false); + }); + it("keeps awaiting input and keeps ask_user_question visible with pending sibling tools", () => { const aggregator = new StreamingMessageAggregator("2024-01-01T00:00:00.000Z"); diff --git a/src/browser/utils/messages/StreamingMessageAggregator.ts b/src/browser/utils/messages/StreamingMessageAggregator.ts index 2679310153..4e825bb533 100644 --- a/src/browser/utils/messages/StreamingMessageAggregator.ts +++ b/src/browser/utils/messages/StreamingMessageAggregator.ts @@ -316,6 +316,32 @@ function getInputAvailableToolCallIdsBlockedByAwaitingQuestion(message: MuxMessa return blockedToolCallIds; } +function getLatestAnswerableAskUserQuestionMessageId( + allMessages: MuxMessage[], + showSyntheticMessages: boolean +): string | null { + for (let i = allMessages.length - 1; i >= 0; i--) { + const message = allMessages[i]; + const isSynthetic = message.metadata?.synthetic === true; + const isUiVisibleSynthetic = message.metadata?.uiVisible === true; + if (isSynthetic && !showSyntheticMessages && !isUiVisibleSynthetic) { + continue; + } + + if (message.metadata?.muxMetadata?.type === "plan-display") { + continue; + } + + if (message.role !== "assistant") { + return null; + } + + return getAnswerableAskUserQuestionToolCallIds(message).size > 0 ? message.id : null; + } + + return null; +} + function resolveRouteProvider( routeProvider: string | undefined, routedThroughGateway: boolean | undefined @@ -3020,6 +3046,11 @@ export class StreamingMessageAggregator { const showSyntheticMessages = typeof window !== "undefined" && window.api?.debugLlmRequest === true; + const latestAnswerableAskUserQuestionMessageId = getLatestAnswerableAskUserQuestionMessageId( + allMessages, + showSyntheticMessages + ); + // Synthetic agent-skill snapshot messages are hidden from the transcript unless // debugLlmRequest is enabled. We still want to surface their content in the UI by // attaching the resolved snapshot (frontmatterYaml + body) to the *subsequent* @@ -3091,7 +3122,8 @@ export class StreamingMessageAggregator { (message) => message.type === "tool" && message.toolName === "ask_user_question" && - message.status === "executing" + message.status === "executing" && + message.historyId === latestAnswerableAskUserQuestionMessageId ) .map((message) => message.id) ); From 36840c1d89c95edb441a14def5750f0fe2fa3558 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Thu, 19 Mar 2026 17:36:45 +0000 Subject: [PATCH 33/33] Align startup ask_user_question pending detection with interrupted tails --- .../agentSession.startupAutoRetry.test.ts | 24 +++++++------- src/node/services/agentSession.ts | 31 +++++++++++++++++++ 2 files changed, 43 insertions(+), 12 deletions(-) diff --git a/src/node/services/agentSession.startupAutoRetry.test.ts b/src/node/services/agentSession.startupAutoRetry.test.ts index f473b7aa99..0b16269fe3 100644 --- a/src/node/services/agentSession.startupAutoRetry.test.ts +++ b/src/node/services/agentSession.startupAutoRetry.test.ts @@ -1019,7 +1019,7 @@ describe("AgentSession startup auto-retry recovery", () => { session.dispose(); }); - test("does not schedule startup auto-retry when pending ask_user_question turn has error metadata", async () => { + test("schedules startup auto-retry when pending ask_user_question turn has error metadata", async () => { const workspaceId = "startup-retry-ask-user-with-error"; const { session, historyService, events, cleanup } = await createSessionBundle(workspaceId); cleanups.push(cleanup); @@ -1052,7 +1052,7 @@ describe("AgentSession startup auto-retry recovery", () => { expect(writePartialResult.success).toBe(true); const startupRetryModelHint = await session.getStartupAutoRetryModelHint(); - expect(startupRetryModelHint).toBeNull(); + expect(startupRetryModelHint).toBe("anthropic:claude-sonnet-4-5"); session.ensureStartupAutoRetryCheck(); @@ -1061,12 +1061,12 @@ describe("AgentSession startup auto-retry recovery", () => { ).startupAutoRetryCheckPromise; await startupCheckPromise; - expect(events.some((event) => event.type === "auto-retry-scheduled")).toBe(false); + expect(events.some((event) => event.type === "auto-retry-scheduled")).toBe(true); session.dispose(); }); - test("does not schedule startup auto-retry when a failed tool follows ask_user_question", async () => { + test("schedules startup auto-retry when a failed tool follows ask_user_question", async () => { const workspaceId = "startup-retry-ask-user-failed-tail"; const { session, historyService, events, cleanup } = await createSessionBundle(workspaceId); cleanups.push(cleanup); @@ -1113,7 +1113,7 @@ describe("AgentSession startup auto-retry recovery", () => { expect(writePartialResult.success).toBe(true); const startupRetryModelHint = await session.getStartupAutoRetryModelHint(); - expect(startupRetryModelHint).toBeNull(); + expect(startupRetryModelHint).toBe("anthropic:claude-sonnet-4-5"); session.ensureStartupAutoRetryCheck(); @@ -1122,7 +1122,7 @@ describe("AgentSession startup auto-retry recovery", () => { ).startupAutoRetryCheckPromise; await startupCheckPromise; - expect(events.some((event) => event.type === "auto-retry-scheduled")).toBe(false); + expect(events.some((event) => event.type === "auto-retry-scheduled")).toBe(true); session.dispose(); }); @@ -1187,7 +1187,7 @@ describe("AgentSession startup auto-retry recovery", () => { session.dispose(); }); - test("does not schedule startup auto-retry when completed tool output follows ask_user_question", async () => { + test("schedules startup auto-retry when completed tool output follows ask_user_question", async () => { const workspaceId = "startup-retry-ask-user-completed-tail"; const { session, historyService, events, cleanup } = await createSessionBundle(workspaceId); cleanups.push(cleanup); @@ -1234,7 +1234,7 @@ describe("AgentSession startup auto-retry recovery", () => { expect(writePartialResult.success).toBe(true); const startupRetryModelHint = await session.getStartupAutoRetryModelHint(); - expect(startupRetryModelHint).toBeNull(); + expect(startupRetryModelHint).toBe("anthropic:claude-sonnet-4-5"); session.ensureStartupAutoRetryCheck(); @@ -1243,12 +1243,12 @@ describe("AgentSession startup auto-retry recovery", () => { ).startupAutoRetryCheckPromise; await startupCheckPromise; - expect(events.some((event) => event.type === "auto-retry-scheduled")).toBe(false); + expect(events.some((event) => event.type === "auto-retry-scheduled")).toBe(true); session.dispose(); }); - test("does not schedule startup auto-retry when text follows ask_user_question", async () => { + test("schedules startup auto-retry when text follows ask_user_question", async () => { const workspaceId = "startup-retry-ask-user-text-tail"; const { session, historyService, events, cleanup } = await createSessionBundle(workspaceId); cleanups.push(cleanup); @@ -1291,7 +1291,7 @@ describe("AgentSession startup auto-retry recovery", () => { expect(writePartialResult.success).toBe(true); const startupRetryModelHint = await session.getStartupAutoRetryModelHint(); - expect(startupRetryModelHint).toBeNull(); + expect(startupRetryModelHint).toBe("anthropic:claude-sonnet-4-5"); session.ensureStartupAutoRetryCheck(); @@ -1300,7 +1300,7 @@ describe("AgentSession startup auto-retry recovery", () => { ).startupAutoRetryCheckPromise; await startupCheckPromise; - expect(events.some((event) => event.type === "auto-retry-scheduled")).toBe(false); + expect(events.some((event) => event.type === "auto-retry-scheduled")).toBe(true); session.dispose(); }); diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index c6e0aac7e3..f07681bf92 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -943,6 +943,10 @@ export class AgentSession { return false; } + if (message.metadata?.error != null) { + return false; + } + let latestPendingQuestionIndex = -1; for (let partIndex = 0; partIndex < message.parts.length; partIndex += 1) { const part = message.parts[partIndex]; @@ -959,6 +963,33 @@ export class AgentSession { return false; } + const hasLaterResolvedToolPart = message.parts.some( + (part, partIndex) => + partIndex > latestPendingQuestionIndex && + part.type === "dynamic-tool" && + (part.state === "output-available" || part.state === "output-redacted") + ); + if (hasLaterResolvedToolPart) { + return false; + } + + if (message.metadata?.partial === true) { + const hasLaterTextOrReasoning = message.parts.some((part, partIndex) => { + if (partIndex <= latestPendingQuestionIndex) { + return false; + } + + return ( + (part.type === "text" && part.text.length > 0) || + (part.type === "reasoning" && part.text.length > 0) + ); + }); + + if (hasLaterTextOrReasoning) { + return false; + } + } + return true; }