summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-05-29 18:45:45 +0900
committerAdam Malczewski <[email protected]>2026-05-29 18:45:45 +0900
commitdcbc51e22dcd3d7b5a01d6c3b7b0285efa49bca4 (patch)
tree72e1a635662ed20deaee1f0b01408a5954445b26
parent5b3e1ac64681e233f35e1b4d2230d9988667c37e (diff)
downloaddispatch-dcbc51e22dcd3d7b5a01d6c3b7b0285efa49bca4.tar.gz
dispatch-dcbc51e22dcd3d7b5a01d6c3b7b0285efa49bca4.zip
feat: stop generation button with abort signal plumbing
- Add POST /chat/stop endpoint on API - Thread abortSignal from agent-manager through Agent.run() to streamText - Thread abortSignal option through the Agent.run() signature - Emit status:idle on stopTab() so frontend WS gets the update - Add stopGeneration() store method on frontend tabStore - Add stop button in ChatInput (btn-sm lg:btn-xs for mobile tap target) - Add tests for /chat/stop endpoint - Refactor processMessage to pass abortSignal to agent.run
-rw-r--r--packages/api/src/agent-manager.ts9
-rw-r--r--packages/api/src/app.ts9
-rw-r--r--packages/api/tests/routes.test.ts22
-rw-r--r--packages/core/src/agent/agent.ts6
-rw-r--r--packages/frontend/src/lib/components/ChatInput.svelte11
-rw-r--r--packages/frontend/src/lib/tabs.svelte.ts13
-rw-r--r--wishlist.md7
7 files changed, 67 insertions, 10 deletions
diff --git a/packages/api/src/agent-manager.ts b/packages/api/src/agent-manager.ts
index 69c071d..c09a607 100644
--- a/packages/api/src/agent-manager.ts
+++ b/packages/api/src/agent-manager.ts
@@ -885,6 +885,7 @@ export class AgentManager {
}
tabAgent.abortController?.abort();
tabAgent.status = "idle";
+ this.emit({ type: "status", status: "idle" }, tabId);
tabAgent.agent = null;
// Resolve any pending completion promise so retrieve doesn't hang
tabAgent.completionResolve?.({ status: "error", error: "Agent was stopped." });
@@ -1193,10 +1194,10 @@ export class AgentManager {
// Best-effort — if this fails, appendMessage will throw and we'll catch it below
}
- for await (const event of agent.run(
- message,
- reasoningEffort ? { reasoningEffort } : undefined,
- )) {
+ for await (const event of agent.run(message, {
+ ...(reasoningEffort ? { reasoningEffort } : {}),
+ abortSignal: tabAgent.abortController?.signal,
+ })) {
// Stop processing if the tab was aborted (closed/stopped).
// stopTab() already injected a `cancelled` system chunk into
// `chunks` before flipping the abort flag, so we just need
diff --git a/packages/api/src/app.ts b/packages/api/src/app.ts
index ba5dabd..73d3de5 100644
--- a/packages/api/src/app.ts
+++ b/packages/api/src/app.ts
@@ -94,6 +94,15 @@ app.post("/chat/cancel", async (c) => {
return c.json({ success: cancelled });
});
+app.post("/chat/stop", async (c) => {
+ const body = await c.req.json();
+ if (typeof body.tabId !== "string") {
+ return c.json({ error: "tabId is required" }, 400);
+ }
+ agentManager.stopTab(body.tabId);
+ return c.json({ success: true });
+});
+
app.route("/skills", skillsRoutes);
app.route("/models", modelsRoutes);
app.route("/tabs", tabsRoutes);
diff --git a/packages/api/tests/routes.test.ts b/packages/api/tests/routes.test.ts
index 49bae49..eba2226 100644
--- a/packages/api/tests/routes.test.ts
+++ b/packages/api/tests/routes.test.ts
@@ -318,3 +318,25 @@ describe("POST /chat", () => {
expect(typeof body.messageId).toBe("string");
});
});
+
+describe("POST /chat/stop", () => {
+ it("returns 200 with success: true for valid tabId", async () => {
+ const res = await app.request("/chat/stop", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ tabId: "tab-stop-1" }),
+ });
+ expect(res.status).toBe(200);
+ const body = await res.json();
+ expect(body).toEqual({ success: true });
+ });
+
+ it("returns 400 when tabId is missing", async () => {
+ const res = await app.request("/chat/stop", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({}),
+ });
+ expect(res.status).toBe(400);
+ });
+});
diff --git a/packages/core/src/agent/agent.ts b/packages/core/src/agent/agent.ts
index 4eace0c..82b98df 100644
--- a/packages/core/src/agent/agent.ts
+++ b/packages/core/src/agent/agent.ts
@@ -673,7 +673,10 @@ export class Agent {
async *run(
userMessage: string,
- options?: { reasoningEffort?: "none" | "low" | "medium" | "high" | "max" },
+ options?: {
+ reasoningEffort?: "none" | "low" | "medium" | "high" | "max";
+ abortSignal?: AbortSignal;
+ },
): AsyncGenerator<AgentEvent> {
this.status = "running";
yield { type: "status", status: "running" };
@@ -779,6 +782,7 @@ export class Agent {
model,
messages: coreMessages,
tools,
+ abortSignal: options?.abortSignal,
};
// Encourage tool use on Anthropic. Without an explicit
diff --git a/packages/frontend/src/lib/components/ChatInput.svelte b/packages/frontend/src/lib/components/ChatInput.svelte
index 3bbc4ab..f9c9546 100644
--- a/packages/frontend/src/lib/components/ChatInput.svelte
+++ b/packages/frontend/src/lib/components/ChatInput.svelte
@@ -5,6 +5,7 @@ let inputEl: HTMLInputElement | undefined;
let inputValue = $state("");
const agentStatus = $derived(tabStore.activeTab?.agentStatus ?? "idle");
+const tabId = $derived(tabStore.activeTab?.id ?? "");
$effect(() => {
inputEl?.focus();
@@ -27,7 +28,15 @@ function submit() {
<div class="flex items-center gap-2 p-3">
{#if agentStatus === "running"}
- <span class="loading loading-spinner loading-sm text-primary"></span>
+ <button
+ type="button"
+ class="btn btn-ghost gap-1 btn-sm lg:btn-xs"
+ onclick={() => tabStore.stopGeneration(tabId)}
+ title="Stop generation"
+ >
+ <span class="loading loading-spinner loading-sm text-primary"></span>
+ <span class="text-xs">Stop</span>
+ </button>
{:else if agentStatus === "idle"}
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" class="w-5 h-5 text-success">
<polyline points="20 6 9 17 4 12"></polyline>
diff --git a/packages/frontend/src/lib/tabs.svelte.ts b/packages/frontend/src/lib/tabs.svelte.ts
index ccdd587..f96a35e 100644
--- a/packages/frontend/src/lib/tabs.svelte.ts
+++ b/packages/frontend/src/lib/tabs.svelte.ts
@@ -1595,6 +1595,18 @@ export function createTabStore() {
}
}
+ async function stopGeneration(tabId: string): Promise<void> {
+ try {
+ await fetch(`${config.apiBase}/chat/stop`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ tabId }),
+ });
+ } catch {
+ // ignore
+ }
+ }
+
function copyConversation(): string {
const tab = getActiveTab();
if (!tab) return "";
@@ -1738,6 +1750,7 @@ export function createTabStore() {
closeTab,
sendMessage,
cancelQueuedMessage,
+ stopGeneration,
changeModel,
setKey,
setAgent,
diff --git a/wishlist.md b/wishlist.md
index b5eb60f..f896dc0 100644
--- a/wishlist.md
+++ b/wishlist.md
@@ -19,7 +19,6 @@
- **ntfy push notifications.** Configurable ntfy.sh notifications — ping on chat completion, errors, permission prompts, and other events. Configure topic URL and which events trigger notifications.
-- **Failed tool calls should produce proper tool results, not get silently dropped.**
- - Repro: agent calls `read_file` but the tool is unavailable → SDK emits `tried to call unavailable tool`. User grants permission and asks agent to retry. Agent tries again, but the SDK now errors with `Tool results are missing for tool calls call_00_..., call_01_...` — the previous step's tool calls were recorded (IDs registered in the assistant message) but their results were never written (because the stream aborted when the unavailable-tool error was caught). The synthetic error path in agent.ts (lines 856-882) only covers the ONE tool that triggered the error; sibling tool calls in the same batch that the LLM sent alongside the unavailable one have their IDs in the history but no matching tool-result, which trips the v6 SDK validation.
- - Fix: when the unavailable-tool error is caught, every tool call in `stepToolCalls` that doesn't already have a result should receive a synthetic tool-result explaining the failure (e.g. "This step was aborted because tool X is unavailable."). The existing per-tool synthetic result only covers the offending tool; the rest of the batch becomes orphaned.
- - Beyond the unavailable-tool case more generally: any path that yields tool-call events without a matching tool-result leaves the history in an un-roundtrippable state. Consider adding a `failed` or `aborted` state to tool results so the model can distinguish "the tool ran and returned this output" from "the tool never ran because something went wrong upstream."
+- **Fix the todo system.** The current task list tool and its UI have bugs or limitations that need addressing.
+
+