Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
92 changes: 92 additions & 0 deletions src/tui/agent-event-reducer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,98 @@ describe("reduceTuiState", () => {
expect(errMsg?.text).toBe("Turn failed [tool]: boom");
});

it("renders a calm stopped-by-user notice with a retry prompt on a cancelled loop_failed", () => {
const initial = createInitialTuiState(fakeSession());
const next = apply(initial, [
{ type: "agent_event", event: { type: "user_message", text: "count the stars" } },
{ type: "message_submitted" },
{
type: "agent_event",
event: {
type: "loop_failed",
error: new Error("This operation was aborted"),
category: "cancelled",
},
},
]);
expect(next.status).toBe("idle");
expect(next.lastRunStatus).toBe("stopped by user");
expect(next.runHistory[0]?.outcome).toBe("cancelled");
// No warn-styled "Turn failed" wall: the operator did this on
// purpose and the notice says so, carrying the aborted turn's
// prompt for the [try again] affordance.
const warn = next.messages.find(
(m) => m.role === "system" && m.variant === "warn",
);
expect(warn).toBeUndefined();
const notice = next.messages.find((m) => m.role === "system");
expect(notice?.text).toBe("Agent stopped by user.");
expect(notice?.retryText).toBe("count the stars");
});

it("treats any loop_failed during a requested abort as stopped-by-user", () => {
// The abort races the LLM stream: a killed response can surface as
// `[model] model returned empty content` before the AbortError
// does. With `abort_requested` on the books, that is still the
// operator's stop, not a provider failure.
const initial = createInitialTuiState(fakeSession());
const next = apply(initial, [
{ type: "agent_event", event: { type: "user_message", text: "count the stars" } },
{ type: "message_submitted" },
{ type: "abort_requested" },
{
type: "agent_event",
event: {
type: "loop_failed",
error: new Error("model returned empty content"),
category: "model",
},
},
]);
expect(next.lastRunStatus).toBe("stopped by user");
expect(next.aborting).toBe(false);
const notice = next.messages.find((m) => m.role === "system");
expect(notice?.text).toBe("Agent stopped by user.");
expect(notice?.retryText).toBe("count the stars");
});

it("keeps the warn styling for a loop_failed with no abort on the books", () => {
const initial = createInitialTuiState(fakeSession());
const next = apply(initial, [
{ type: "message_submitted" },
{
type: "agent_event",
event: {
type: "loop_failed",
error: new Error("model returned empty content"),
category: "model",
},
},
]);
const warn = next.messages.find(
(m) => m.role === "system" && m.variant === "warn",
);
expect(warn?.text).toBe("Turn failed [model]: model returned empty content");
});

it("leaves retryText off the stopped notice when no user message exists to re-run", () => {
const initial = createInitialTuiState(fakeSession());
const next = apply(initial, [
{ type: "message_submitted" },
{
type: "agent_event",
event: {
type: "loop_failed",
error: new Error("This operation was aborted"),
category: "cancelled",
},
},
]);
const notice = next.messages.find((m) => m.role === "system");
expect(notice?.text).toBe("Agent stopped by user.");
expect(notice?.retryText).toBeUndefined();
});

it("appends the llama hint on transport failure for a custom-id llama-server route", () => {
const initial = createInitialTuiState(fakeSession());
const next = apply(initial, [
Expand Down
37 changes: 37 additions & 0 deletions src/tui/agent-event-reducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
finishRun,
finishRunWithoutHistory,
finishTurn,
lastUserMessage,
pushRing,
startNewRun,
upsertReasoning,
Expand Down Expand Up @@ -458,6 +459,42 @@ function reduceAgentEvent(state: TuiState, event: AgentLoopEvent): TuiState {
);
}
case "loop_failed": {
// A user-initiated abort is not a failure and must not dress like
// one: the operator pressed stop (the chip, Esc, Ctrl+C or
// `/abort`) and already knows the turn is dead. Instead of the
// warn-styled `Turn failed [cancelled]: This operation was
// aborted` wall, leave a calm system notice that says who stopped
// it — and carry the aborted turn's user message as `retryText`,
// so a mistaken click is one `[try again]` away from undone.
//
// `state.aborting` is checked alongside the category because the
// abort races the LLM stream: killing a response mid-flight can
// surface as `[model] model returned empty content` (or another
// category) before the AbortError ever propagates, and an
// operator who just pressed stop would read that as a provider
// failure they caused. Every abort entry point dispatches
// `abort_requested` first, and `finishRun` clears the flag, so
// the window is exactly the abort the operator asked for.
if (event.category === "cancelled" || state.aborting) {
const lastRunStatus = "stopped by user";
const prompt = lastUserMessage(state);
return finishRun(
appendChatMessage(
appendFeed(state, {
kind: "loop_failed",
stepIndex: null,
line: `» ${lastRunStatus}`,
color: "yellow",
}),
{
role: "system",
text: "Agent stopped by user.",
retryText: prompt.length > 0 ? prompt : undefined,
},
),
{ outcome: "cancelled", reason: lastRunStatus, lastRunStatus },
);
}
const lastRunStatus = `failed [${event.category}]: ${event.error.message}`;
const chatError = formatAgentErrorForChat(
event.category,
Expand Down
27 changes: 27 additions & 0 deletions src/tui/components/chat-log.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -228,4 +228,31 @@ describe("ChatLog", () => {
expect(text).toContain("Hi");
expect(text).toMatch(/reasoning/);
});

it("hangs a [try again] under the stopped-by-user notice, and only there", () => {
const state: TuiState = {
...createInitialTuiState(BASE_SESSION),
messages: [
{
id: "m1",
role: "system",
text: "Agent stopped by user.",
retryText: "count the stars",
timestamp: 1,
},
{
id: "m2",
role: "system",
text: "queue cleared",
timestamp: 2,
},
],
};
const { lastFrame } = render(<ChatLog state={state} />);
const text = strip(lastFrame() ?? "");
expect(text).toContain("Agent stopped by user.");
// Exactly one button: the notice with `retryText` earns it, the
// plain runtime notice under it does not.
expect(text.match(/\[try again\]/g)).toHaveLength(1);
});
});
13 changes: 12 additions & 1 deletion src/tui/components/chat-log.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -270,7 +270,18 @@ function FinalisedMessage({
text={message.text}
warn={message.variant === "warn"}
/>
<ChatCopyButton text={message.text} />
<Box flexDirection="row">
<ChatCopyButton text={message.text} />
{/*
Only the abort notice sets `retryText`, and the button resends
THAT — the stopped turn's user prompt — not the notice's own
text. Same shared footer row as every other role, so
`estimateMessageHeight` stays role-blind.
*/}
{message.retryText !== undefined ? (
<ChatTryAgainButton text={message.retryText} />
) : null}
</Box>
</Box>
);
}
Expand Down
5 changes: 4 additions & 1 deletion src/tui/components/chat-try-again-button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,10 @@ export function resubmitChatMessage(
* prose; sending it back would open a turn whose prompt is the previous
* answer, which is not "try again" in any sense an operator means. A
* system message is TUI runtime output — queue listings, turn-failed
* lines — and re-sending one as a prompt is worse than nonsense. Asking
* lines — and re-sending one as a prompt is worse than nonsense. The
* one system notice that carries the button — "Agent stopped by user",
* via `ChatMessage.retryText` — is no exception: what it resends is the
* aborted turn's *user* prompt, never its own text. Asking
* the model to have another go at the *same* question is a different
* feature (it has to drop the last turn, not append one) and it is not
* this button.
Expand Down
160 changes: 160 additions & 0 deletions src/tui/components/composer-stop-button.mouse.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import { render } from "ink-testing-library";
import { describe, expect, it } from "vitest";

import { ClipboardProvider } from "../clipboard/clipboard-context.js";
import { makeMouseSource } from "../mouse/mouse-source.js";
import type { TuiMouseEvent } from "../mouse/mouse-event.js";
import { makeTuiEventBus, TuiApp, type TuiAppCallbacks } from "../tui-app.js";
import type { TuiSessionInfo } from "../tui-state.js";

/**
* The stop chip versus the running turn.
*
* Esc, Ctrl+C and `/abort` all stop the agent, but they are keyboard
* lore; the chip is the one *visible* control. These cases pin down the
* whole loop from the outside — through `TuiApp`, real Ink layout, real
* hit-testing: absent while idle, present while a turn is in flight,
* a click on it lands on `onAbort` (the same callback Esc reaches),
* and it leaves the field when the run ends.
*/

const SESSION: TuiSessionInfo = {
sessionId: "s1",
workingDir: "/tmp/stop-button-mouse",
llamaUrl: "http://127.0.0.1:8080",
browserChannel: "chrome",
browserHeadless: false,
approvalLevel: 5,
maxSteps: 10,
skillCount: 0,
};

/** The chip's on-screen label — glyph included, so a hint strip's plain
* "stop" wording can never satisfy the assertions below. */
const STOP_LABEL = "■ stop";

const strip = (value: string): string => value.replace(/\[[0-9;]*m/g, "");

const delay = (ms: number): Promise<void> =>
new Promise((resolve) => setTimeout(resolve, ms));

async function waitUntil(
condition: () => boolean,
what: string,
timeoutMs = 10_000,
): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (condition()) return;
await delay(25);
}
throw new Error(`timed out waiting for ${what}`);
}

function click(x: number, y: number): TuiMouseEvent {
return {
kind: "press",
button: "left",
wheel: null,
x,
y,
shift: false,
alt: false,
ctrl: false,
};
}

/** Screen cell of the LAST line containing `needle`. */
function locateLast(
frame: string,
needle: string,
): { x: number; y: number } {
const lines = frame.split("\n");
for (let y = lines.length - 1; y >= 0; y -= 1) {
const x = (lines[y] ?? "").indexOf(needle);
if (x !== -1) return { x, y };
}
throw new Error(`"${needle}" is not on screen:\n${frame}`);
}

function mountApp() {
const bus = makeTuiEventBus();
const mouse = makeMouseSource();
const submitted: string[] = [];
let aborted = 0;
const clipboard = {
copy: async () => true,
};
const callbacks: TuiAppCallbacks = {
onApprovalDecision: () => {},
onAbort: () => {
aborted += 1;
},
onQuit: () => {},
onMessageSubmitted: (message) => {
submitted.push(message);
},
};
const app = render(
<ClipboardProvider writer={clipboard}>
<TuiApp session={SESSION} bus={bus} callbacks={callbacks} mouse={mouse} />
</ClipboardProvider>,
);
return {
...app,
mouse,
submitted,
aborted: () => aborted,
finishRun: () =>
bus.emit({
type: "agent_event",
event: { type: "loop_completed", reason: "reply" },
}),
frame: () => strip(app.lastFrame() ?? ""),
};
}

describe("composer stop button", () => {
it("appears while a turn runs, aborts on click, leaves when the run ends", async () => {
const app = mountApp();
await waitUntil(() => app.frame().includes("send"), "composer on screen");
// Idle: no run to stop, so no chip to press.
expect(app.frame()).not.toContain(STOP_LABEL);

// Submitting a message starts a turn; the chip must come with it.
app.stdin.write("do the thing");
await waitUntil(() => app.frame().includes("do the thing"), "typed text");
app.stdin.write("\r");
await waitUntil(() => app.submitted.length === 1, "message submitted");
await waitUntil(
() => app.frame().includes(STOP_LABEL),
"stop chip on screen while running",
);

// A click on the chip is exactly Esc: `onAbort`, once per press.
// Re-click until it lands — targets register a frame after they
// first paint.
const spot = locateLast(app.frame(), STOP_LABEL);
await waitUntil(() => {
app.mouse.emit(click(spot.x + 1, spot.y));
return app.aborted() > 0;
}, "stop click to land");
const landed = app.aborted();
// The click stopped at the chip: it must not have doubled as a
// submit / steer of the (empty) buffer.
expect(app.submitted.length).toBe(1);

// One settled press must not have queued extra aborts behind the
// first that landed.
await delay(100);
expect(app.aborted()).toBe(landed);

// Run over: the chip has nothing left to act on and leaves the field.
app.finishRun();
await waitUntil(
() => !app.frame().includes(STOP_LABEL),
"stop chip gone after the run ended",
);
app.unmount();
});
});
Loading
Loading