Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
b12b2bd
fix(queue): ignore withdrawn entries in correlation readers
ibetitsmike Sep 4, 2026
42a70e1
fix(usage): price aborted streams against the effective model
ibetitsmike Sep 4, 2026
c91286c
fix(bash): defer monitor attention until the active turn ends
ibetitsmike Sep 4, 2026
27acae7
fix(bash): retire owed attention before Stop without the history lock
ibetitsmike Sep 6, 2026
12dd020
fix(bash): withdrawn wakes never claim PREPARING; retire attention on…
ibetitsmike Sep 7, 2026
a8f06c1
fix(bash): retire attention on compaction Stop; withdraw wakes synchr…
ibetitsmike Sep 7, 2026
1f3cc0b
fix(bash): keep compaction pending through the follow-up dispatch; co…
ibetitsmike Sep 7, 2026
4876b42
fix(bash): join the withdrawn wake's send before acknowledging Stop
ibetitsmike Sep 7, 2026
47f3759
fix(bash): retire only the attention owed when Stop was requested
ibetitsmike Sep 7, 2026
72038d8
Record the abandon marker on every withdrawn wake exit past the point…
ibetitsmike Sep 7, 2026
80e6716
Check wake withdrawal after the acceptance I/O on every exit past the…
ibetitsmike Sep 7, 2026
8c9f5f6
🤖 fix: fail Stop when a withdrawn wake's abandon marker is not written
ibetitsmike Sep 7, 2026
f8223a3
🤖 fix: keep Stop retirement owed until it lands and retry unrecorded …
ibetitsmike Sep 7, 2026
0508fd5
🤖 fix: fail an unrecorded Stop retirement and bound it to the stop-ti…
ibetitsmike Sep 7, 2026
573bc3f
🤖 fix: surface an unrecorded Stop to the user
ibetitsmike Sep 7, 2026
3ae3905
Retain chat error toasts per workspace; settle ACP cancel on an unrec…
ibetitsmike Sep 7, 2026
0e40dbf
Serialize auto-retry state writes; show queued chat errors one at a t…
ibetitsmike Sep 7, 2026
bf73007
Load the auto-retry preference once and make every state change wait …
ibetitsmike Sep 7, 2026
db77089
Merge remote-tracking branch 'origin/main' into mike/bash-wake-turn-end
ibetitsmike Sep 7, 2026
f4157df
Merge remote-tracking branch 'origin/main' into mike/bash-wake-turn-end
ibetitsmike Sep 7, 2026
e16a04d
fix: retire wakes on CLI budget stops, gate Stop on unrecorded opt-ou…
ibetitsmike Sep 7, 2026
7584e9c
fix: await the CLI budget stop before teardown
ibetitsmike Sep 7, 2026
9be951f
fix: land the RetryBarrier opt-out before issuing Stop
ibetitsmike Sep 7, 2026
24d2253
Finalize a cancelable wake refused as stale past the rollback horizon
ibetitsmike Sep 7, 2026
6e68e07
Retry failed wake acceptance I/O ahead of dispatch instead of redeliv…
ibetitsmike Sep 7, 2026
3dd58de
Merge origin/main into mike/bash-wake-turn-end
ibetitsmike Sep 7, 2026
551e2c1
Consume a wake the transcript already carries instead of redelivering it
ibetitsmike Sep 7, 2026
4dfe2b9
Accept a wake the moment its row is durable; filter malformed wake rows
ibetitsmike Sep 7, 2026
7ad183f
Land the retry opt-out inside stopStream; key withdrawn-wake markers …
ibetitsmike Sep 7, 2026
f00391c
Hold archived owners' wakes; read compacted wake rows; keep discard a…
ibetitsmike Sep 7, 2026
acbf958
Move the retry opt-out into the Stop transaction; wake held monitor a…
ibetitsmike Sep 7, 2026
ebce837
Interrupt before the opt-out write lands; scan the transcript only fo…
ibetitsmike Sep 7, 2026
ff3a832
Parse process ages before bounding the transcript scan; an unparseabl…
ibetitsmike Sep 7, 2026
82f8013
Merge origin/main (#4097 token-budget rollovers, #4123 reconnect stre…
ibetitsmike Sep 7, 2026
ed66d65
Gate post-acceptance withdrawal behind withdrawAcceptedOnCancel, set …
ibetitsmike Sep 7, 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
19 changes: 2 additions & 17 deletions src/browser/features/ChatInput/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ import {
} from "@/browser/utils/workflowRunMessages";
import { Button } from "@/browser/components/Button/Button";
import { CUSTOM_EVENTS } from "@/common/constants/events";
import { useChatErrorToasts } from "@/browser/utils/chatErrorToasts";
import { EXPERIMENT_IDS } from "@/common/constants/experiments";
import { extractInlineSkillReferenceCandidates } from "@/browser/utils/agentSkills/inlineSkillReferences";
import {
Expand Down Expand Up @@ -1467,23 +1468,7 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {
window.removeEventListener(CUSTOM_EVENTS.THINKING_LEVEL_TOAST, handler as EventListener);
}, [variant, props, pushToast]);

// Show the backend's one-shot child-budget warning on the matching parent workspace.
useEffect(() => {
if (variant !== "workspace") return;

const handler = (event: Event) => {
const detail = (event as CustomEvent<{ workspaceId: string; message: string }>).detail;
if (detail?.workspaceId !== workspaceId || !detail.message) {
return;
}

pushToast({ type: "error", message: detail.message });
};

window.addEventListener(CUSTOM_EVENTS.GOAL_CHILD_BUDGET_TOAST, handler as EventListener);
return () =>
window.removeEventListener(CUSTOM_EVENTS.GOAL_CHILD_BUDGET_TOAST, handler as EventListener);
}, [variant, workspaceId, pushToast]);
useChatErrorToasts(workspaceId, toast?.message ?? null, pushToast);

// Show toast feedback for analytics rebuild command palette action.
useEffect(() => {
Expand Down
26 changes: 26 additions & 0 deletions src/browser/features/Messages/ChatBarrier/RetryBarrier.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ function createDeferred<T>() {
let resumeStreamResult: ResumeStreamResult = { success: true, data: { started: true } };
let previousAutoRetryEnabled = false;
const resumeStream = mock((_input: unknown) => Promise.resolve(resumeStreamResult));
const interruptStream = mock((_input: unknown) => Promise.resolve({ success: true as const }));
const setAutoRetryEnabled = mock((input: unknown) => {
if (
typeof input === "object" &&
Expand Down Expand Up @@ -107,6 +108,7 @@ void mock.module("@/browser/contexts/API", () => ({
api: {
workspace: {
resumeStream,
interruptStream,
setAutoRetryEnabled,
},
},
Expand Down Expand Up @@ -152,6 +154,7 @@ describe("RetryBarrier", () => {
resumeStreamResult = { success: true, data: { started: true } };
previousAutoRetryEnabled = false;
resumeStream.mockClear();
interruptStream.mockClear();
setAutoRetryEnabled.mockClear();
});

Expand Down Expand Up @@ -394,4 +397,27 @@ describe("RetryBarrier", () => {
});
expect(resumeStream).toHaveBeenCalledTimes(1);
});

test("the Stop button opts out of auto-retry inside the same attention-retiring Stop as its shortcut", async () => {
currentWorkspaceState = createWorkspaceState({
autoRetryStatus: {
type: "auto-retry-scheduled",
attempt: 1,
delayMs: 5_000,
scheduledAt: Date.now(),
},
});

const view = render(<RetryBarrier workspaceId="ws-1" />);

fireEvent.click(view.getByRole("button", { name: /^Stop/ }));

await waitFor(() => expect(interruptStream).toHaveBeenCalledTimes(1));
expect(interruptStream).toHaveBeenCalledWith({
workspaceId: "ws-1",
options: { disableAutoRetry: true, retireBashMonitorAttention: true },
});
// No separate opt-out call: it would release the retry idle gate ahead of the Stop.
expect(setAutoRetryEnabled).not.toHaveBeenCalled();
});
});
10 changes: 7 additions & 3 deletions src/browser/features/Messages/ChatBarrier/RetryBarrier.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { KEYBINDS, formatKeybind } from "@/browser/utils/ui/keybinds";
import { VIM_ENABLED_KEY } from "@/common/constants/storage";
import { getSendOptionsFromStorage } from "@/browser/utils/messages/sendOptions";
import { applyCompactionOverrides } from "@/browser/utils/messages/compactionOptions";
import { stopStream } from "@/browser/utils/stopStream";
import { formatSendMessageError } from "@/common/utils/errors/formatSendError";
import { getErrorMessage } from "@/common/utils/errors";

Expand Down Expand Up @@ -233,10 +234,11 @@ export const RetryBarrier: React.FC<RetryBarrierProps> = (props) => {
}
};

const handleStopAutoRetry = () => {
const handleStopAutoRetry = async () => {
setCountdown(0);
setManualRetryError(null);
void api?.workspace.setAutoRetryEnabled?.({ workspaceId: props.workspaceId, enabled: false });
if (!api) return;
await stopStream(api, props.workspaceId, { disableAutoRetry: true });
};

const lastMessage = getLastMainRetryCandidateMessage(workspaceState.messages);
Expand Down Expand Up @@ -301,7 +303,9 @@ export const RetryBarrier: React.FC<RetryBarrierProps> = (props) => {
actionButton = (
<button
className="border-warning font-primary text-warning hover:bg-warning-overlay cursor-pointer rounded border bg-transparent px-4 py-2 text-xs font-semibold whitespace-nowrap transition-all duration-200 hover:-translate-y-px active:translate-y-0 disabled:cursor-not-allowed disabled:opacity-50"
onClick={handleStopAutoRetry}
onClick={() => {
void handleStopAutoRetry();
}}
>
Stop <span className="mobile-hide-shortcut-hints">({stopKeybind})</span>
</button>
Expand Down
39 changes: 25 additions & 14 deletions src/browser/features/Messages/ChatBarrier/StreamingBarrier.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
import { cleanup, fireEvent, render } from "@testing-library/react";
import { cleanup, fireEvent, render, waitFor } from "@testing-library/react";
import { GlobalWindow } from "happy-dom";

import type * as WorkspaceStoreModule from "@/browser/stores/WorkspaceStore";
Expand Down Expand Up @@ -163,7 +163,7 @@ describe("StreamingBarrier", () => {
globalThis.document = undefined as unknown as Document;
});

test("clicking stop during normal streaming interrupts with default options", () => {
test("clicking stop during normal streaming interrupts with default options", async () => {
currentWorkspaceState = createWorkspaceState({
canInterrupt: true,
isCompacting: false,
Expand All @@ -175,12 +175,17 @@ describe("StreamingBarrier", () => {

fireEvent.click(view.getByRole("button", { name: "Stop streaming" }));

expect(setAutoRetryEnabled).toHaveBeenCalledWith({ workspaceId: "ws-1", enabled: false });
expect(setInterrupting).toHaveBeenCalledWith("ws-1");
expect(interruptStream).toHaveBeenCalledWith({ workspaceId: "ws-1" });
await waitFor(() =>
expect(interruptStream).toHaveBeenCalledWith({
workspaceId: "ws-1",
options: { disableAutoRetry: true, retireBashMonitorAttention: true },
})
);
expect(setAutoRetryEnabled).not.toHaveBeenCalled();
});

test("clicking stop during stream-start interrupts without setting interrupting state", () => {
test("clicking stop during stream-start interrupts without setting interrupting state", async () => {
currentWorkspaceState = createWorkspaceState({
canInterrupt: false,
pendingStreamStartTime: Date.now(),
Expand All @@ -195,9 +200,13 @@ describe("StreamingBarrier", () => {

fireEvent.click(stopButton);

expect(setAutoRetryEnabled).toHaveBeenCalledWith({ workspaceId: "ws-1", enabled: false });
expect(setInterrupting).not.toHaveBeenCalled();
expect(interruptStream).toHaveBeenCalledWith({ workspaceId: "ws-1" });
await waitFor(() =>
expect(interruptStream).toHaveBeenCalledWith({
workspaceId: "ws-1",
options: { disableAutoRetry: true, retireBashMonitorAttention: true },
})
);
});

test("shows the barrier immediately on first appearance", () => {
Expand Down Expand Up @@ -358,13 +367,14 @@ describe("StreamingBarrier", () => {

fireEvent.click(view.getByRole("button", { name: "Stop streaming" }));

expect(setAutoRetryEnabled).toHaveBeenCalledWith({ workspaceId: "ws-1", enabled: false });
// The compaction-cancel flow owns the retry opt-out along with its Stop.
expect(onCancelCompaction).toHaveBeenCalledTimes(1);
expect(setAutoRetryEnabled).not.toHaveBeenCalled();
expect(setInterrupting).not.toHaveBeenCalled();
expect(interruptStream).not.toHaveBeenCalled();
});

test("clicking stop during compaction falls back to abandonPartial interrupt", () => {
test("clicking stop during compaction falls back to abandonPartial interrupt", async () => {
currentWorkspaceState = createWorkspaceState({
canInterrupt: true,
isCompacting: true,
Expand All @@ -374,12 +384,13 @@ describe("StreamingBarrier", () => {

fireEvent.click(view.getByRole("button", { name: "Stop streaming" }));

expect(setAutoRetryEnabled).toHaveBeenCalledWith({ workspaceId: "ws-1", enabled: false });
expect(setInterrupting).not.toHaveBeenCalled();
expect(interruptStream).toHaveBeenCalledWith({
workspaceId: "ws-1",
options: { abandonPartial: true },
});
await waitFor(() =>
expect(interruptStream).toHaveBeenCalledWith({
workspaceId: "ws-1",
options: { abandonPartial: true, disableAutoRetry: true, retireBashMonitorAttention: true },
})
);
});

test("resets to new workspace text immediately on workspace switch", () => {
Expand Down
10 changes: 3 additions & 7 deletions src/browser/features/Messages/ChatBarrier/StreamingBarrier.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
import { getDefaultModel } from "@/browser/hooks/useModelsFromSettings";
import { useSettings } from "@/browser/contexts/SettingsContext";
import { useAPI } from "@/browser/contexts/API";
import { stopStream } from "@/browser/utils/stopStream";

type StreamingPhase =
| "starting" // Message sent, waiting for stream-start
Expand Down Expand Up @@ -264,8 +265,6 @@ export const StreamingBarrier: React.FC<StreamingBarrierProps> = ({
return;
}

void api.workspace.setAutoRetryEnabled?.({ workspaceId, enabled: false });

if (phase === "compacting") {
// Reuse the established compaction-cancel flow from keyboard shortcuts so we keep
// edit restoration + follow-up content behavior consistent across input methods.
Expand All @@ -274,18 +273,15 @@ export const StreamingBarrier: React.FC<StreamingBarrierProps> = ({
return;
}

void api.workspace.interruptStream({
workspaceId,
options: { abandonPartial: true },
});
void stopStream(api, workspaceId, { abandonPartial: true, disableAutoRetry: true });
return;
}

if (phase === "streaming") {
storeRaw.setInterrupting(workspaceId);
}

void api.workspace.interruptStream({ workspaceId });
void stopStream(api, workspaceId, { disableAutoRetry: true });
};

// Show settings hint during compaction if no custom compaction model is configured
Expand Down
63 changes: 56 additions & 7 deletions src/browser/hooks/useAIViewKeybinds.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { ReactNode, RefObject } from "react";
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
import { cleanup, renderHook } from "@testing-library/react";
import { cleanup, renderHook, waitFor } from "@testing-library/react";
import { copyFile, readFile, rm, writeFile } from "node:fs/promises";
import { randomUUID } from "node:crypto";
import { dirname, join } from "node:path";
Expand Down Expand Up @@ -90,7 +90,7 @@ describe("useAIViewKeybinds", () => {
isolatedModulePaths = [];
});

test("Escape interrupts an active stream in normal mode", () => {
test("Escape interrupts an active stream in normal mode", async () => {
const interruptStream = mock(() =>
Promise.resolve({ success: true as const, data: undefined })
);
Expand Down Expand Up @@ -124,7 +124,7 @@ describe("useAIViewKeybinds", () => {
})
);

expect(interruptStream.mock.calls.length).toBe(1);
await waitFor(() => expect(interruptStream.mock.calls.length).toBe(1));
});

test("Escape does not interrupt when the event target is an <input>", () => {
Expand Down Expand Up @@ -168,7 +168,7 @@ describe("useAIViewKeybinds", () => {
expect(interruptStream.mock.calls.length).toBe(0);
});

test("Escape interrupts when an editable element opts in", () => {
test("Escape interrupts when an editable element opts in", async () => {
const interruptStream = mock(() =>
Promise.resolve({ success: true as const, data: undefined })
);
Expand Down Expand Up @@ -207,10 +207,10 @@ describe("useAIViewKeybinds", () => {
})
);

expect(interruptStream.mock.calls.length).toBe(1);
await waitFor(() => expect(interruptStream.mock.calls.length).toBe(1));
});

test("Ctrl+C interrupts in vim mode even when an <input> is focused", () => {
test("Ctrl+C interrupts in vim mode even when an <input> is focused", async () => {
const interruptStream = mock(() =>
Promise.resolve({ success: true as const, data: undefined })
);
Expand Down Expand Up @@ -249,7 +249,56 @@ describe("useAIViewKeybinds", () => {
})
);

expect(interruptStream.mock.calls.length).toBe(1);
await waitFor(() => expect(interruptStream.mock.calls.length).toBe(1));
});

test("Escape on the retry barrier opts out of auto-retry inside the Stop itself", async () => {
const interruptStream = mock(() =>
Promise.resolve({ success: true as const, data: undefined })
);
const setAutoRetryEnabled = mock(() =>
Promise.resolve({
success: true as const,
data: { previousEnabled: true, enabled: false },
})
);
currentClientMock = {
workspace: {
interruptStream,
setAutoRetryEnabled,
},
};

const chatInputAPI: RefObject<ChatInputAPI | null> = { current: null };

renderUseAIViewKeybinds({
workspaceId: "ws",
canInterrupt: false,
showRetryBarrier: true,
chatInputAPI,
jumpToBottom: () => undefined,
loadOlderHistory: null,
handleOpenTerminal: () => undefined,
handleOpenInEditor: () => undefined,
aggregator: undefined,
setEditingMessage: () => undefined,
vimEnabled: false,
});

document.body.dispatchEvent(
new window.KeyboardEvent("keydown", {
key: "Escape",
bubbles: true,
cancelable: true,
})
);

await waitFor(() => expect(interruptStream.mock.calls.length).toBe(1));
expect(interruptStream).toHaveBeenCalledWith({
workspaceId: "ws",
options: { disableAutoRetry: true, retireBashMonitorAttention: true },
});
expect(setAutoRetryEnabled).not.toHaveBeenCalled();
});

test.each([
Expand Down
7 changes: 4 additions & 3 deletions src/browser/hooks/useAIViewKeybinds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
} from "@/browser/utils/ui/keybinds";
import type { StreamingMessageAggregator } from "@/browser/utils/messages/StreamingMessageAggregator";
import { isCompactingStream, cancelCompaction } from "@/browser/utils/compaction/handler";
import { stopStream } from "@/browser/utils/stopStream";
import { useAPI } from "@/browser/contexts/API";
import type { EditingMessageState } from "@/browser/utils/chatEditing";

Expand Down Expand Up @@ -111,7 +112,6 @@ export function useAIViewKeybinds({
if (api) {
void cancelCompaction(api, workspaceId, aggregator, setEditingMessage);
}
void api?.workspace.setAutoRetryEnabled?.({ workspaceId, enabled: false });
return;
}

Expand All @@ -120,8 +120,9 @@ export function useAIViewKeybinds({
// Non-vim mode: Esc interrupts (except when typing in inputs, unless explicitly opted in)
if (canInterrupt || showRetryBarrier) {
e.preventDefault();
void api?.workspace.setAutoRetryEnabled?.({ workspaceId, enabled: false });
void api?.workspace.interruptStream({ workspaceId });
if (api) {
void stopStream(api, workspaceId, { disableAutoRetry: true });
}
return;
}
}
Expand Down
Loading
Loading