Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
efdecf9
🤖 fix: detect pending ask_user_question within latest turn
jaaydenh Mar 19, 2026
5696c31
🤖 fix: suppress interruption UI for pending question turns
jaaydenh Mar 19, 2026
cff3aa7
🤖 fix: preserve error retry states for failed question turns
jaaydenh Mar 19, 2026
2d0fca8
🤖 fix: use authoritative awaiting-question signal in retry gating
jaaydenh Mar 19, 2026
f28e8a4
🤖 fix: suppress interrupted rows with authoritative awaiting state
jaaydenh Mar 19, 2026
a66fd6c
🤖 fix: ignore ephemeral plan-display rows for awaiting detection
jaaydenh Mar 19, 2026
e2703d5
🤖 fix: treat plan-display rows as decorative in interruption checks
jaaydenh Mar 19, 2026
ed9943f
🤖 fix: scope plan-display skipping to interrupted-row turn resolution
jaaydenh Mar 19, 2026
6105ed2
🤖 fix: ignore plan-display previews in ask-user waiting fallback
jaaydenh Mar 19, 2026
aaa6e52
🤖 fix: require latest unfinished part to be ask_user_question for awa…
jaaydenh Mar 19, 2026
bd89e87
🤖 fix: keep interruption visible when output continues after question
jaaydenh Mar 19, 2026
34d8778
🤖 fix: prefer later tool failures over ask-user awaiting state
jaaydenh Mar 19, 2026
9969383
🤖 fix: align retry suppression with ask-user question tail state
jaaydenh Mar 19, 2026
b929481
🤖 fix: keep pending ask-user turns out of startup auto-retry
jaaydenh Mar 19, 2026
74cb31e
🤖 fix: treat failed redacted tools as interruption tails
jaaydenh Mar 19, 2026
8e00324
🤖 fix: keep stale ask-user tool rows from showing as executing
jaaydenh Mar 19, 2026
38c4428
🤖 fix: keep failed-tail ask-user prompts answerable
jaaydenh Mar 19, 2026
9b3e527
🤖 fix: preserve pending ask-user recovery when streams error
jaaydenh Mar 19, 2026
1a4dbb8
🤖 fix: only suppress retry UI when awaiting question is visible
jaaydenh Mar 19, 2026
f039f0b
Use authoritative awaiting flag for interruption barriers
jaaydenh Mar 19, 2026
bc8bc13
Clear awaiting state when question row is truncated
jaaydenh Mar 19, 2026
8ea39e8
Treat post-question tool output as interrupted tail state
jaaydenh Mar 19, 2026
f6fcd1e
Suppress interrupted marker for answerable question rows
jaaydenh Mar 19, 2026
0de1cde
Preserve persisted ask_user_question recovery semantics
jaaydenh Mar 19, 2026
e5dec82
Handle redacted and completed tool tails in retry recovery
jaaydenh Mar 19, 2026
2a9d63a
Keep sibling pending-question recovery paths intact
jaaydenh Mar 19, 2026
a739e6d
Keep pending question state across truncation and restart
jaaydenh Mar 19, 2026
0b562f3
Clear awaiting flag for interrupted ask_user_question tails
jaaydenh Mar 19, 2026
42b18f6
Avoid awaiting fallback on errored truncated turns
jaaydenh Mar 19, 2026
989783c
Keep all pending ask_user_question tool calls answerable
jaaydenh Mar 19, 2026
43b3950
Keep awaiting question rows visible and sibling tools pending
jaaydenh Mar 19, 2026
87ac791
Pin only latest answerable ask_user_question rows
jaaydenh Mar 19, 2026
36840c1
Align startup ask_user_question pending detection with interrupted tails
jaaydenh Mar 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions src/browser/components/ChatPane/ChatPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -572,7 +572,8 @@ export const ChatPane: React.FC<ChatPaneProps> = (props) => {
workspaceState.messages,
workspaceState.pendingStreamStartTime,
workspaceState.runtimeStatus,
workspaceState.lastAbortReason
workspaceState.lastAbortReason,
workspaceState.awaitingUserQuestion
Comment thread
jaaydenh marked this conversation as resolved.
)
: null;

Expand Down Expand Up @@ -860,7 +861,9 @@ export const ChatPane: React.FC<ChatPaneProps> = (props) => {
/>
)}
{isAtCutoff && <EditCutoffBarrier />}
{shouldShowInterruptedBarrier(msg) && <InterruptedBarrier />}
{shouldShowInterruptedBarrier(msg, deferredMessages) && (
Comment thread
jaaydenh marked this conversation as resolved.
Outdated
<InterruptedBarrier />
)}
</React.Fragment>
);
})}
Expand Down
192 changes: 192 additions & 0 deletions src/browser/utils/messages/StreamingMessageAggregator.status.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,198 @@ 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);
});

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);
});

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", () => {
Expand Down
42 changes: 32 additions & 10 deletions src/browser/utils/messages/StreamingMessageAggregator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -716,19 +716,41 @@ 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];
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().
const allMessages = this.getAllMessages();
Comment thread
jaaydenh marked this conversation as resolved.

// 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 (last?.type !== "tool") {
return false;
if (message.role !== "assistant") {
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) &&
part.toolName === "ask_user_question" &&
part.state === "input-available"
Comment thread
jaaydenh marked this conversation as resolved.
Outdated
Comment thread
jaaydenh marked this conversation as resolved.
Outdated
);
Comment thread
jaaydenh marked this conversation as resolved.
Outdated
}

return last.toolName === "ask_user_question" && last.status === "executing";
return false;
}

/**
Expand Down
33 changes: 33 additions & 0 deletions src/browser/utils/messages/messageUtils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
21 changes: 17 additions & 4 deletions src/browser/utils/messages/messageUtils.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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" ||
Expand All @@ -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;
}
}
}
Comment thread
jaaydenh marked this conversation as resolved.

// Only show on the last part of multi-part messages
Expand Down
Loading
Loading