@vellumai/assistant 0.10.3-dev.202606301846.839df2b → 0.10.3-dev.202606301940.da6000c

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/openapi.yaml CHANGED
@@ -17916,6 +17916,44 @@ paths:
17916
17916
  additionalProperties: false
17917
17917
  backgroundToolNotification:
17918
17918
  type: boolean
17919
+ backgroundToolCompletion:
17920
+ type: object
17921
+ properties:
17922
+ id:
17923
+ type: string
17924
+ toolName:
17925
+ type: string
17926
+ conversationId:
17927
+ type: string
17928
+ command:
17929
+ type: string
17930
+ startedAt:
17931
+ type: number
17932
+ status:
17933
+ type: string
17934
+ enum:
17935
+ - completed
17936
+ - failed
17937
+ - cancelled
17938
+ exitCode:
17939
+ anyOf:
17940
+ - type: number
17941
+ - type: "null"
17942
+ output:
17943
+ type: string
17944
+ completedAt:
17945
+ type: number
17946
+ required:
17947
+ - id
17948
+ - toolName
17949
+ - conversationId
17950
+ - command
17951
+ - startedAt
17952
+ - status
17953
+ - exitCode
17954
+ - output
17955
+ - completedAt
17956
+ additionalProperties: false
17919
17957
  slackMessage:
17920
17958
  type: object
17921
17959
  properties:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.10.3-dev.202606301846.839df2b",
3
+ "version": "0.10.3-dev.202606301940.da6000c",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -189,6 +189,10 @@ describe("bash tool background mode", () => {
189
189
  // Command stdout is fenced as untrusted output, not inlined in the hint.
190
190
  expect(wakeCall.untrustedOutput?.content).toContain("bg_output_12345");
191
191
  expect(wakeCall.untrustedOutput?.source).toBe("tool_result");
192
+ // Durable completion record stamped onto the persisted wake.
193
+ expect(wakeCall.backgroundToolCompletion?.id).toBe("bg-test1234");
194
+ expect(wakeCall.backgroundToolCompletion?.status).toBe("completed");
195
+ expect(wakeCall.backgroundToolCompletion?.exitCode).toBe(0);
192
196
  });
193
197
 
194
198
  test("failing background process delivers an error hint via wake", async () => {
@@ -210,6 +214,9 @@ describe("bash tool background mode", () => {
210
214
  expect(wakeCall.hint).toContain("bg-test1234");
211
215
  // The command fails with exit code 1, so the hint should reflect failure
212
216
  expect(wakeCall.hint).toContain("exit=1");
217
+ expect(wakeCall.backgroundToolCompletion?.id).toBe("bg-test1234");
218
+ expect(wakeCall.backgroundToolCompletion?.status).toBe("failed");
219
+ expect(wakeCall.backgroundToolCompletion?.exitCode).toBe(1);
213
220
  });
214
221
 
215
222
  test("cancelled background process wakes with the cancellation, not a completed result", async () => {
@@ -234,6 +241,8 @@ describe("bash tool background mode", () => {
234
241
  expect(wakeCall.hint).toContain("cancelled");
235
242
  expect(wakeCall.hint).not.toContain("completed");
236
243
  expect(wakeCall.untrustedOutput?.content).toContain("cancelled");
244
+ expect(wakeCall.backgroundToolCompletion?.id).toBe("bg-test1234");
245
+ expect(wakeCall.backgroundToolCompletion?.status).toBe("cancelled");
237
246
  });
238
247
 
239
248
  test("foreground mode still works when background is not set", async () => {
@@ -118,6 +118,7 @@ mock.module("../daemon/host-bash-proxy.js", () => ({
118
118
  // Import under test — MUST come after mock.module calls.
119
119
  // ---------------------------------------------------------------------------
120
120
 
121
+ import type { CompletedBackgroundTool } from "../tools/background-tool-registry.js";
121
122
  import { hostShellTool } from "../tools/host-terminal/host-shell.js";
122
123
  import type { ToolContext, ToolExecutionResult } from "../tools/types.js";
123
124
 
@@ -258,6 +259,11 @@ describe("host_bash background mode — proxy path", () => {
258
259
  maxChars: 40_000,
259
260
  });
260
261
  expect(wakeCall.source).toBe("background-tool");
262
+ const completion =
263
+ wakeCall.backgroundToolCompletion as CompletedBackgroundTool;
264
+ expect(completion.id).toBe("bg-test-0001");
265
+ expect(completion.status).toBe("completed");
266
+ expect(completion.exitCode).toBeNull();
261
267
  });
262
268
 
263
269
  test("calls wakeAgentForOpportunity on proxy error result", async () => {
@@ -398,6 +404,11 @@ describe("host_bash background mode — direct execution path", () => {
398
404
  expect((wakeCall.untrustedOutput as { content: string }).content).toContain(
399
405
  "hello world",
400
406
  );
407
+ const completion =
408
+ wakeCall.backgroundToolCompletion as CompletedBackgroundTool;
409
+ expect(completion.id).toBe("bg-test-0001");
410
+ expect(completion.status).toBe("completed");
411
+ expect(completion.exitCode).toBe(0);
401
412
  });
402
413
 
403
414
  test("calls wakeAgentForOpportunity with error hint on non-zero exit", async () => {
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Tests for handleListMessages background-tool completion projection.
3
+ *
4
+ * A backgrounded bash/host_bash run posts a `<background_event
5
+ * source="background-tool">` wake on completion, stamped with
6
+ * `metadata.backgroundToolCompletion`. The history projection must surface
7
+ * that structured record (and the `backgroundToolNotification` flag) so the
8
+ * web can rebuild a terminal inline card after a daemon restart.
9
+ */
10
+
11
+ import { beforeEach, describe, expect, mock, test } from "bun:test";
12
+
13
+ mock.module("../util/logger.js", () => ({
14
+ getLogger: () =>
15
+ new Proxy({} as Record<string, unknown>, {
16
+ get: () => () => {},
17
+ }),
18
+ }));
19
+
20
+ mock.module("../config/loader.js", () => ({
21
+ getConfig: () => ({
22
+ ui: {},
23
+ model: "test",
24
+ provider: "test",
25
+ memory: { enabled: false },
26
+ rateLimit: { maxRequestsPerMinute: 0 },
27
+ }),
28
+ }));
29
+
30
+ import { ConversationMessageSchema } from "../api/responses/conversation-message.js";
31
+ import {
32
+ addMessage,
33
+ createConversation,
34
+ } from "../persistence/conversation-crud.js";
35
+ import { getDb } from "../persistence/db-connection.js";
36
+ import { initializeDb } from "../persistence/db-init.js";
37
+ import { handleListMessages } from "../runtime/routes/conversation-routes.js";
38
+
39
+ await initializeDb();
40
+
41
+ function resetTables() {
42
+ const db = getDb();
43
+ db.run("DELETE FROM message_attachments");
44
+ db.run("DELETE FROM attachments");
45
+ db.run("DELETE FROM messages");
46
+ db.run("DELETE FROM conversations");
47
+ }
48
+
49
+ interface ProjectedMessage {
50
+ role: string;
51
+ backgroundToolNotification?: boolean;
52
+ backgroundToolCompletion?: Record<string, unknown>;
53
+ }
54
+
55
+ describe("handleListMessages background-tool completion projection", () => {
56
+ beforeEach(resetTables);
57
+
58
+ test("projects backgroundToolCompletion from the wake row metadata", async () => {
59
+ const conv = createConversation();
60
+ await addMessage(
61
+ conv.id,
62
+ "user",
63
+ JSON.stringify([{ type: "text", text: "run a long command" }]),
64
+ );
65
+
66
+ const completion = {
67
+ id: "bg-1",
68
+ toolName: "bash",
69
+ conversationId: conv.id,
70
+ command: "sleep 5 && echo done",
71
+ startedAt: 1000,
72
+ status: "completed" as const,
73
+ exitCode: 0,
74
+ output: "done\n",
75
+ completedAt: 2000,
76
+ };
77
+ // Mirror persistWakeTriggerMessage: a user-role
78
+ // `<background_event source="background-tool">` row carrying the structured
79
+ // completion. Not flagged `hidden`; the client suppresses it from the
80
+ // transcript via `backgroundToolNotification`.
81
+ await addMessage(
82
+ conv.id,
83
+ "user",
84
+ JSON.stringify([
85
+ {
86
+ type: "text",
87
+ text: '<background_event source="background-tool">Background command completed (id=bg-1, exit=0):</background_event>',
88
+ },
89
+ ]),
90
+ {
91
+ metadata: {
92
+ kind: "background-event",
93
+ backgroundEventSource: "background-tool",
94
+ automated: true,
95
+ backgroundToolCompletion: completion,
96
+ },
97
+ },
98
+ );
99
+
100
+ const response = handleListMessages({
101
+ queryParams: { conversationId: conv.id },
102
+ }) as { messages: ProjectedMessage[] };
103
+
104
+ // Every projected message validates against the wire schema.
105
+ for (const message of response.messages) {
106
+ expect(() => ConversationMessageSchema.parse(message)).not.toThrow();
107
+ }
108
+
109
+ const wakeRow = response.messages.find(
110
+ (m) => m.backgroundToolCompletion !== undefined,
111
+ );
112
+ expect(wakeRow).toBeDefined();
113
+ expect(wakeRow?.backgroundToolNotification).toBe(true);
114
+ expect(wakeRow?.backgroundToolCompletion).toEqual(completion);
115
+ });
116
+
117
+ test("omits backgroundToolCompletion when the row carries no completion metadata", async () => {
118
+ const conv = createConversation();
119
+ await addMessage(
120
+ conv.id,
121
+ "user",
122
+ JSON.stringify([{ type: "text", text: "hello" }]),
123
+ );
124
+ await addMessage(
125
+ conv.id,
126
+ "assistant",
127
+ JSON.stringify([{ type: "text", text: "hi there" }]),
128
+ );
129
+
130
+ const response = handleListMessages({
131
+ queryParams: { conversationId: conv.id },
132
+ }) as { messages: ProjectedMessage[] };
133
+
134
+ expect(response.messages).toHaveLength(2);
135
+ for (const message of response.messages) {
136
+ expect(message.backgroundToolCompletion).toBeUndefined();
137
+ expect(message.backgroundToolNotification).toBeUndefined();
138
+ expect(() => ConversationMessageSchema.parse(message)).not.toThrow();
139
+ }
140
+ });
141
+ });
package/src/api/index.ts CHANGED
@@ -404,6 +404,7 @@ export {
404
404
  DictationRequestSchema,
405
405
  } from "./requests/dictation.js";
406
406
  export {
407
+ type BackgroundToolCompletion,
407
408
  type ConversationAttachmentBlock,
408
409
  ConversationAttachmentBlockSchema,
409
410
  type ConversationContentBlock,
@@ -296,6 +296,29 @@ export type ConversationAcpNotification = z.infer<
296
296
  typeof ConversationAcpNotificationSchema
297
297
  >;
298
298
 
299
+ // ---------------------------------------------------------------------------
300
+ // Background-tool completion
301
+ // ---------------------------------------------------------------------------
302
+
303
+ /** Structured terminal record of a backgrounded bash/host_bash run, carrying
304
+ * everything a web `BackgroundTaskEntry` needs to rebuild a completed inline
305
+ * card from history. Mirrors the `background_tool_completed` SSE event plus
306
+ * the registry fields (`toolName`, `command`, `startedAt`). */
307
+ export const BackgroundToolCompletionSchema = z.object({
308
+ id: z.string(),
309
+ toolName: z.string(),
310
+ conversationId: z.string(),
311
+ command: z.string(),
312
+ startedAt: z.number(),
313
+ status: z.enum(["completed", "failed", "cancelled"]),
314
+ exitCode: z.number().nullable(),
315
+ output: z.string(),
316
+ completedAt: z.number(),
317
+ });
318
+ export type BackgroundToolCompletion = z.infer<
319
+ typeof BackgroundToolCompletionSchema
320
+ >;
321
+
299
322
  // ---------------------------------------------------------------------------
300
323
  // Slack message envelope
301
324
  // ---------------------------------------------------------------------------
@@ -549,6 +572,12 @@ export const ConversationMessageSchema = z.object({
549
572
  * notifications, the row stays in state (the LLM reads it) but is filtered
550
573
  * from the rendered transcript — the inline card carries the status. */
551
574
  backgroundToolNotification: z.boolean().optional(),
575
+ /** Structured completion of a backgrounded bash/host_bash run, stamped on the
576
+ * same persisted background-event wake row as `backgroundToolNotification`.
577
+ * Lets the web reconstruct a terminal inline card after a daemon restart
578
+ * (the in-memory completed ring does not survive restarts). `id` equals the
579
+ * spawning tool call's `{backgrounded,id}` id. */
580
+ backgroundToolCompletion: BackgroundToolCompletionSchema.optional(),
552
581
  slackMessage: ConversationSlackMessageSchema.optional(),
553
582
  /**
554
583
  * Queue state for a user message that is still waiting in the daemon's
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Abort contract when there is no live controller to signal.
3
+ *
4
+ * `abortConversation` signals `ctx.abortController?.abort()` and otherwise
5
+ * defers clearing the `processing` flag to the in-flight turn's `finally`.
6
+ * That deferral is only safe while a turn is actually live to reach its
7
+ * `finally`. The agent-loop `finally` nulls `abortController` *before* it
8
+ * clears the flag, and some paths set `processing` without ever installing a
9
+ * controller — so a conversation can be `isProcessing() === true` with a null
10
+ * `abortController`. In that state the old `?.abort()` was a silent no-op and
11
+ * nothing ever cleared the flag: every later submit was rejected with
12
+ * "already processing" and Stop did nothing.
13
+ *
14
+ * This pins the fix: when asked to abort while processing with no live
15
+ * controller, `abortConversation` force-clears the flag itself; when a
16
+ * controller IS live, it signals it and leaves the flag to the turn.
17
+ */
18
+ import { describe, expect, mock, test } from "bun:test";
19
+
20
+ // `conversation-lifecycle` transitively imports the messaging markdown
21
+ // renderers, which pull in `hast`/`mdast`/`remark`/`unified`. Those packages
22
+ // are declared in `assistant/package.json` but absent from this sandbox's
23
+ // partial node_modules install (CI has the full set). `abortConversation`
24
+ // never touches message rendering, so stub these leaf modules — the only ones
25
+ // importing the missing packages — with no-op named exports so the import
26
+ // graph resolves. Bun validates named imports statically, hence the explicit
27
+ // keys rather than a Proxy.
28
+ mock.module("../../messaging/content/parse.js", () => ({
29
+ parseMarkdown: () => ({}),
30
+ }));
31
+ mock.module("../../messaging/providers/slack/render.js", () => ({
32
+ renderSlackBlocks: () => undefined,
33
+ renderSlack: () => [],
34
+ }));
35
+ mock.module("../../messaging/providers/telegram-bot/render.js", () => ({
36
+ renderTelegramHtml: () => undefined,
37
+ }));
38
+
39
+ const { abortConversation } = await import("../conversation-lifecycle.js");
40
+ import type { AbortContext } from "../conversation-lifecycle.js";
41
+
42
+ interface Harness {
43
+ ctx: AbortContext;
44
+ setProcessingCalls: boolean[];
45
+ prompterDisposed: () => boolean;
46
+ secretPrompterDisposed: () => boolean;
47
+ queueClearedCount: () => number;
48
+ }
49
+
50
+ function makeContext(opts: {
51
+ processing: boolean;
52
+ controller: AbortController | null;
53
+ }): Harness {
54
+ let processing = opts.processing;
55
+ const setProcessingCalls: boolean[] = [];
56
+ let prompterDisposed = false;
57
+ let secretPrompterDisposed = false;
58
+ let queueCleared = 0;
59
+
60
+ const ctx = {
61
+ conversationId: "abort-null-controller-test",
62
+ isProcessing: () => processing,
63
+ setProcessing: (value: boolean) => {
64
+ setProcessingCalls.push(value);
65
+ processing = value;
66
+ },
67
+ abortController: opts.controller,
68
+ prompter: {
69
+ dispose: () => {
70
+ prompterDisposed = true;
71
+ },
72
+ },
73
+ secretPrompter: {
74
+ dispose: () => {
75
+ secretPrompterDisposed = true;
76
+ },
77
+ },
78
+ pendingSurfaceActions: new Map(),
79
+ surfaceActionRequestIds: new Set(),
80
+ surfaceState: new Map(),
81
+ accumulatedSurfaceState: new Map(),
82
+ queue: {
83
+ clear: () => {
84
+ queueCleared += 1;
85
+ },
86
+ [Symbol.iterator]: function* () {
87
+ // no queued messages
88
+ },
89
+ },
90
+ } as unknown as AbortContext;
91
+
92
+ return {
93
+ ctx,
94
+ setProcessingCalls,
95
+ prompterDisposed: () => prompterDisposed,
96
+ secretPrompterDisposed: () => secretPrompterDisposed,
97
+ queueClearedCount: () => queueCleared,
98
+ };
99
+ }
100
+
101
+ describe("abortConversation", () => {
102
+ test("force-clears the flag when processing with no live controller", () => {
103
+ // GIVEN a conversation flagged processing but with no controller to signal
104
+ const h = makeContext({ processing: true, controller: null });
105
+
106
+ // WHEN an abort is requested
107
+ abortConversation(h.ctx);
108
+
109
+ // THEN the flag is force-cleared so the conversation is no longer wedged
110
+ expect(h.setProcessingCalls).toContain(false);
111
+ expect(h.ctx.isProcessing()).toBe(false);
112
+ // AND the rest of the teardown still ran
113
+ expect(h.prompterDisposed()).toBe(true);
114
+ expect(h.secretPrompterDisposed()).toBe(true);
115
+ expect(h.queueClearedCount()).toBe(1);
116
+ });
117
+
118
+ test("signals a live controller and defers the flag to the turn's finally", () => {
119
+ // GIVEN a conversation with a live, un-aborted controller
120
+ const controller = new AbortController();
121
+ const h = makeContext({ processing: true, controller });
122
+
123
+ // WHEN an abort is requested
124
+ abortConversation(h.ctx);
125
+
126
+ // THEN the controller is signalled
127
+ expect(controller.signal.aborted).toBe(true);
128
+ // AND the flag is NOT force-cleared here — the turn's `finally` owns that
129
+ expect(h.setProcessingCalls).not.toContain(false);
130
+ expect(h.ctx.isProcessing()).toBe(true);
131
+ // AND the shared teardown still ran
132
+ expect(h.prompterDisposed()).toBe(true);
133
+ expect(h.queueClearedCount()).toBe(1);
134
+ });
135
+
136
+ test("propagates the abort reason to the live controller", () => {
137
+ // GIVEN a live controller and an explicit abort reason
138
+ const controller = new AbortController();
139
+ const h = makeContext({ processing: true, controller });
140
+ let observedReason: unknown;
141
+ controller.signal.addEventListener("abort", () => {
142
+ observedReason = controller.signal.reason;
143
+ });
144
+
145
+ // WHEN aborting with no explicit reason (a default reason is synthesized)
146
+ abortConversation(h.ctx);
147
+
148
+ // THEN the controller's reason carries the tagged AbortReason
149
+ expect(observedReason).toBeDefined();
150
+ expect((observedReason as { kind?: string }).kind).toBe(
151
+ "preempted_by_new_message",
152
+ );
153
+ });
154
+
155
+ test("is a no-op when the conversation is not processing", () => {
156
+ // GIVEN a conversation that is not processing
157
+ const h = makeContext({ processing: false, controller: null });
158
+
159
+ // WHEN an abort is requested
160
+ abortConversation(h.ctx);
161
+
162
+ // THEN nothing is torn down and the flag is never touched
163
+ expect(h.setProcessingCalls).toEqual([]);
164
+ expect(h.prompterDisposed()).toBe(false);
165
+ expect(h.queueClearedCount()).toBe(0);
166
+ });
167
+ });
@@ -0,0 +1,107 @@
1
+ import { beforeEach, describe, expect, mock, test } from "bun:test";
2
+
3
+ import { makeMockLogger } from "../../__tests__/helpers/mock-logger.js";
4
+
5
+ mock.module("../../util/logger.js", () => ({
6
+ getLogger: () => makeMockLogger(),
7
+ }));
8
+
9
+ // persistWakeTriggerMessage syncs each row to the disk view and publishes a
10
+ // sync invalidation; stub both so this unit test stays a pure DB round-trip
11
+ // without touching the filesystem or the event hub.
12
+ mock.module("../../persistence/conversation-disk-view.js", () => ({
13
+ syncMessageToDisk: () => {},
14
+ }));
15
+ mock.module("../../runtime/sync/resource-sync-events.js", () => ({
16
+ publishConversationMessagesChanged: () => {},
17
+ }));
18
+
19
+ import {
20
+ createConversation,
21
+ getMessages,
22
+ } from "../../persistence/conversation-crud.js";
23
+ import { initializeDb } from "../../persistence/db-init.js";
24
+ import type { Message } from "../../providers/types.js";
25
+ import type { CompletedBackgroundTool } from "../../tools/background-tool-registry.js";
26
+ import type { Conversation } from "../conversation.js";
27
+ import { persistWakeTriggerMessage } from "../wake-conversation-ops.js";
28
+
29
+ await initializeDb();
30
+
31
+ /** Minimal Conversation stub exercising only the fields the function reads. */
32
+ function makeConversationStub(conversationId: string): Conversation {
33
+ return {
34
+ conversationId,
35
+ trustContext: undefined,
36
+ getTurnChannelContext: () => null,
37
+ getTurnInterfaceContext: () => null,
38
+ } as unknown as Conversation;
39
+ }
40
+
41
+ function triggerMessage(): Message {
42
+ return {
43
+ role: "user",
44
+ content: [
45
+ {
46
+ type: "text",
47
+ text: '<background_event source="background-tool">Background command completed (id=bg-1, exit=0):</background_event>',
48
+ },
49
+ ],
50
+ };
51
+ }
52
+
53
+ function readMetadata(conversationId: string): Record<string, unknown> {
54
+ const rows = getMessages(conversationId);
55
+ expect(rows.length).toBe(1);
56
+ const raw = rows[0]?.metadata;
57
+ expect(raw).toBeTruthy();
58
+ return JSON.parse(raw as string) as Record<string, unknown>;
59
+ }
60
+
61
+ describe("persistWakeTriggerMessage backgroundToolCompletion", () => {
62
+ let conversationId: string;
63
+
64
+ beforeEach(() => {
65
+ conversationId = createConversation("wake-completion-test").id;
66
+ });
67
+
68
+ test("round-trips a completion onto the persisted metadata", async () => {
69
+ const completion: CompletedBackgroundTool = {
70
+ id: "bg-1",
71
+ toolName: "bash",
72
+ conversationId,
73
+ command: "sleep 1 && echo done",
74
+ startedAt: 1_700_000_000_000,
75
+ status: "completed",
76
+ exitCode: 0,
77
+ output: "done\n",
78
+ completedAt: 1_700_000_001_000,
79
+ };
80
+
81
+ await persistWakeTriggerMessage(
82
+ makeConversationStub(conversationId),
83
+ triggerMessage(),
84
+ "background-tool",
85
+ completion,
86
+ );
87
+
88
+ const metadata = readMetadata(conversationId);
89
+ expect(metadata.backgroundToolCompletion).toEqual(completion);
90
+ // Existing wake-trigger metadata is unaffected.
91
+ expect(metadata.kind).toBe("background-event");
92
+ expect(metadata.backgroundEventSource).toBe("background-tool");
93
+ expect(metadata.automated).toBe(true);
94
+ });
95
+
96
+ test("omits the key entirely when no completion is passed", async () => {
97
+ await persistWakeTriggerMessage(
98
+ makeConversationStub(conversationId),
99
+ triggerMessage(),
100
+ "background-tool",
101
+ );
102
+
103
+ const metadata = readMetadata(conversationId);
104
+ expect("backgroundToolCompletion" in metadata).toBe(false);
105
+ expect(metadata.kind).toBe("background-event");
106
+ });
107
+ });
@@ -69,6 +69,7 @@ export function reinjectImageSourcePaths(
69
69
  export interface AbortContext {
70
70
  readonly conversationId: string;
71
71
  isProcessing(): boolean;
72
+ setProcessing(value: boolean): void;
72
73
  abortController: AbortController | null;
73
74
  prompter: PermissionPrompter;
74
75
  secretPrompter: SecretPrompter;
@@ -125,7 +126,30 @@ export function abortConversation(
125
126
  { conversationId: ctx.conversationId, abortReason: effectiveReason },
126
127
  "Aborting in-flight processing",
127
128
  );
128
- ctx.abortController?.abort(effectiveReason);
129
+ if (ctx.abortController) {
130
+ // A live turn owns this controller. Signal it and let the agent loop's
131
+ // own `finally` observe the abort, unwind, and clear the processing flag
132
+ // — that path clears it with the correct sync-invalidation ordering
133
+ // (after the awaited turn-boundary commit), so we deliberately do NOT
134
+ // clear it here and risk clobbering a client's optimistic state.
135
+ ctx.abortController.abort(effectiveReason);
136
+ } else {
137
+ // The flag is set but there is no live controller to signal: the turn
138
+ // that owned it already tore its controller down (the agent-loop
139
+ // `finally` nulls `abortController` before clearing the flag) or died
140
+ // without ever installing one. Either way no agent-loop `finally` is
141
+ // going to run to clear the flag. Without this branch the abort is a
142
+ // silent no-op — `?.abort()` does nothing — and the conversation stays
143
+ // wedged: every later submit is rejected with "already processing" and
144
+ // Stop appears dead. Force-clear the flag directly so the conversation
145
+ // frees up. `setProcessing(false)` also nulls the persisted column and
146
+ // emits the metadata invalidation that drives clients to idle.
147
+ log.warn(
148
+ { conversationId: ctx.conversationId },
149
+ "Abort requested while processing but no live abort controller — force-clearing stale processing flag",
150
+ );
151
+ ctx.setProcessing(false);
152
+ }
129
153
  ctx.prompter.dispose();
130
154
  ctx.secretPrompter.dispose();
131
155
  ctx.pendingSurfaceActions.clear();
@@ -23,6 +23,7 @@ import { backfillMessageIdOnLogs } from "../persistence/llm-request-log-store.js
23
23
  import type { Message } from "../providers/types.js";
24
24
  import { broadcastMessage } from "../runtime/assistant-event-hub.js";
25
25
  import { publishConversationMessagesChanged } from "../runtime/sync/resource-sync-events.js";
26
+ import type { CompletedBackgroundTool } from "../tools/background-tool-registry.js";
26
27
  import { getLogger } from "../util/logger.js";
27
28
  import type { Conversation } from "./conversation.js";
28
29
  import type { ServerMessage } from "./message-protocol.js";
@@ -273,6 +274,7 @@ export async function persistWakeTriggerMessage(
273
274
  conversation: Conversation,
274
275
  message: Message,
275
276
  source: string,
277
+ completion?: CompletedBackgroundTool,
276
278
  ): Promise<void> {
277
279
  const turnChannelCtx = conversation.getTurnChannelContext();
278
280
  const turnInterfaceCtx = conversation.getTurnInterfaceContext();
@@ -287,6 +289,7 @@ export async function persistWakeTriggerMessage(
287
289
  kind: "background-event",
288
290
  backgroundEventSource: source,
289
291
  automated: true,
292
+ ...(completion ? { backgroundToolCompletion: completion } : {}),
290
293
  };
291
294
  const persisted = await addMessage(
292
295
  conversation.conversationId,
@@ -133,6 +133,18 @@ const acpNotificationSchema = z.object({
133
133
  agent: z.string().optional(),
134
134
  });
135
135
 
136
+ const backgroundToolCompletionMetadataSchema = z.object({
137
+ id: z.string(),
138
+ toolName: z.string(),
139
+ conversationId: z.string(),
140
+ command: z.string(),
141
+ startedAt: z.number(),
142
+ status: z.enum(["completed", "failed", "cancelled"]),
143
+ exitCode: z.number().nullable(),
144
+ output: z.string(),
145
+ completedAt: z.number(),
146
+ });
147
+
136
148
  export const messageMetadataSchema = z
137
149
  .object({
138
150
  userMessageChannel: channelIdSchema.optional(),
@@ -164,6 +176,12 @@ export const messageMetadataSchema = z
164
176
  provenanceGuardianExternalUserId: z.string().optional(),
165
177
  provenanceRequesterIdentifier: z.string().optional(),
166
178
  automated: z.boolean().optional(),
179
+ /**
180
+ * Structured terminal record stamped onto a `<background_event
181
+ * source="background-tool">` wake so the web can rebuild the inline
182
+ * bash/host_bash card from history after a daemon restart.
183
+ */
184
+ backgroundToolCompletion: backgroundToolCompletionMetadataSchema.optional(),
167
185
  forkSourceMessageId: z.string().optional(),
168
186
  /** Image source paths from desktop attachments, keyed by filename. */
169
187
  imageSourcePaths: z.record(z.string(), z.string()).optional(),
@@ -108,6 +108,7 @@ import {
108
108
  type UntrustedContentSource,
109
109
  wrapUntrustedContent,
110
110
  } from "../security/untrusted-content.js";
111
+ import type { CompletedBackgroundTool } from "../tools/background-tool-registry.js";
111
112
  import { getLogger } from "../util/logger.js";
112
113
 
113
114
  const log = getLogger("agent-wake");
@@ -323,6 +324,12 @@ export interface WakeOptions {
323
324
  * framing outside the fence.
324
325
  */
325
326
  untrustedOutput?: WakeUntrustedOutput;
327
+ /**
328
+ * Structured terminal record for a backgrounded bash/host_bash run, stamped
329
+ * onto the persisted background-event wake so the web can rebuild the inline
330
+ * card from history after a daemon restart.
331
+ */
332
+ backgroundToolCompletion?: CompletedBackgroundTool;
326
333
  /**
327
334
  * Schedule-run id to stamp on the usage rows this wake records. Set when the
328
335
  * wake is triggered by a script-mode schedule (the firing's run id), so the
@@ -784,7 +791,12 @@ export async function wakeAgentForOpportunity(
784
791
  };
785
792
  conversation.messages.push(triggerMessage);
786
793
  try {
787
- await persistWakeTriggerMessage(conversation, triggerMessage, source);
794
+ await persistWakeTriggerMessage(
795
+ conversation,
796
+ triggerMessage,
797
+ source,
798
+ opts.backgroundToolCompletion,
799
+ );
788
800
  } catch (err) {
789
801
  log.warn(
790
802
  { conversationId, source, err },
@@ -11,7 +11,9 @@ import {
11
11
  createUserMessage,
12
12
  } from "../../agent/message-types.js";
13
13
  import {
14
+ BackgroundToolCompletionSchema,
14
15
  type ConversationContentBlock,
16
+ type ConversationMessage,
15
17
  ConversationMessageSchema,
16
18
  } from "../../api/responses/conversation-message.js";
17
19
  import {
@@ -840,6 +842,7 @@ export function handleListMessages({
840
842
  | undefined;
841
843
  let acpNotification: { acpSessionId: string; agent?: string } | undefined;
842
844
  let backgroundToolNotification: boolean | undefined;
845
+ let backgroundToolCompletion: ConversationMessage["backgroundToolCompletion"];
843
846
  if (msg.metadata) {
844
847
  try {
845
848
  const meta = JSON.parse(msg.metadata);
@@ -852,6 +855,15 @@ export function handleListMessages({
852
855
  if (meta.backgroundEventSource === "background-tool") {
853
856
  backgroundToolNotification = true;
854
857
  }
858
+ // `persistWakeTriggerMessage` stamps the structured completion onto the
859
+ // same wake row, letting the web rebuild a terminal inline card from
860
+ // history after a restart (the in-memory completed ring does not survive).
861
+ const completionParse = BackgroundToolCompletionSchema.safeParse(
862
+ meta.backgroundToolCompletion,
863
+ );
864
+ if (completionParse.success) {
865
+ backgroundToolCompletion = completionParse.data;
866
+ }
855
867
  if (meta.subagentNotification) {
856
868
  const n = meta.subagentNotification;
857
869
  if (typeof n.subagentId === "string" && typeof n.label === "string") {
@@ -905,6 +917,7 @@ export function handleListMessages({
905
917
  subagentNotification,
906
918
  acpNotification,
907
919
  backgroundToolNotification,
920
+ backgroundToolCompletion,
908
921
  slackMessage,
909
922
  clientMessageId: msg.clientMessageId ?? undefined,
910
923
  };
@@ -1092,6 +1105,9 @@ export function handleListMessages({
1092
1105
  ...(m.backgroundToolNotification
1093
1106
  ? { backgroundToolNotification: true }
1094
1107
  : {}),
1108
+ ...(m.backgroundToolCompletion
1109
+ ? { backgroundToolCompletion: m.backgroundToolCompletion }
1110
+ : {}),
1095
1111
  ...(m.slackMessage ? { slackMessage: m.slackMessage } : {}),
1096
1112
  };
1097
1113
  });
@@ -31,6 +31,7 @@ import {
31
31
  import { isUntrustedShellLockdownActive } from "../../runtime/effective-capabilities.js";
32
32
  import { redactSecrets } from "../../security/secret-scanner.js";
33
33
  import { getLogger } from "../../util/logger.js";
34
+ import type { CompletedBackgroundTool } from "../background-tool-registry.js";
34
35
  import {
35
36
  generateBackgroundToolId,
36
37
  isBackgroundToolLimitReached,
@@ -318,7 +319,7 @@ export const hostShellTool = {
318
319
  ? `Background host command cancelled (id=${bgId}).`
319
320
  : result.content;
320
321
  const completedAt = Date.now();
321
- recordCompletedBackgroundTool({
322
+ const completion: CompletedBackgroundTool = {
322
323
  id: bgId,
323
324
  toolName: "host_bash",
324
325
  conversationId: context.conversationId,
@@ -328,7 +329,8 @@ export const hostShellTool = {
328
329
  exitCode: null,
329
330
  output,
330
331
  completedAt,
331
- });
332
+ };
333
+ recordCompletedBackgroundTool(completion);
332
334
  broadcastMessage(
333
335
  {
334
336
  type: "background_tool_completed",
@@ -351,6 +353,7 @@ export const hostShellTool = {
351
353
  hint: framing,
352
354
  source: "background-tool",
353
355
  persistTriggerAsEvent: true,
356
+ backgroundToolCompletion: completion,
354
357
  untrustedOutput: {
355
358
  content: output || "(no output)",
356
359
  source: "tool_result",
@@ -370,7 +373,7 @@ export const hostShellTool = {
370
373
  ? err.message
371
374
  : String(err);
372
375
  const completedAt = Date.now();
373
- recordCompletedBackgroundTool({
376
+ const completion: CompletedBackgroundTool = {
374
377
  id: bgId,
375
378
  toolName: "host_bash",
376
379
  conversationId: context.conversationId,
@@ -380,7 +383,8 @@ export const hostShellTool = {
380
383
  exitCode: null,
381
384
  output,
382
385
  completedAt,
383
- });
386
+ };
387
+ recordCompletedBackgroundTool(completion);
384
388
  broadcastMessage(
385
389
  {
386
390
  type: "background_tool_completed",
@@ -400,6 +404,7 @@ export const hostShellTool = {
400
404
  : `Background host command failed (id=${bgId}): ${err instanceof Error ? err.message : String(err)}`,
401
405
  source: "background-tool",
402
406
  persistTriggerAsEvent: true,
407
+ backgroundToolCompletion: completion,
403
408
  });
404
409
  })
405
410
  .finally(() => removeBackgroundTool(bgId));
@@ -559,7 +564,7 @@ export const hostShellTool = {
559
564
  ? `Background host command cancelled (id=${bgId}).`
560
565
  : result.content;
561
566
  const completedAt = Date.now();
562
- recordCompletedBackgroundTool({
567
+ const completion: CompletedBackgroundTool = {
563
568
  id: bgId,
564
569
  toolName: "host_bash",
565
570
  conversationId: context.conversationId,
@@ -569,7 +574,8 @@ export const hostShellTool = {
569
574
  exitCode: code ?? null,
570
575
  output,
571
576
  completedAt,
572
- });
577
+ };
578
+ recordCompletedBackgroundTool(completion);
573
579
  broadcastMessage(
574
580
  {
575
581
  type: "background_tool_completed",
@@ -593,6 +599,7 @@ export const hostShellTool = {
593
599
  hint: framing,
594
600
  source: "background-tool",
595
601
  persistTriggerAsEvent: true,
602
+ backgroundToolCompletion: completion,
596
603
  untrustedOutput: {
597
604
  content: output || "(no output)",
598
605
  source: "tool_result",
@@ -613,7 +620,7 @@ export const hostShellTool = {
613
620
  ? `Background host command cancelled (id=${bgId}).`
614
621
  : err.message;
615
622
  const completedAt = Date.now();
616
- recordCompletedBackgroundTool({
623
+ const completion: CompletedBackgroundTool = {
617
624
  id: bgId,
618
625
  toolName: "host_bash",
619
626
  conversationId: context.conversationId,
@@ -623,7 +630,8 @@ export const hostShellTool = {
623
630
  exitCode: null,
624
631
  output,
625
632
  completedAt,
626
- });
633
+ };
634
+ recordCompletedBackgroundTool(completion);
627
635
  broadcastMessage(
628
636
  {
629
637
  type: "background_tool_completed",
@@ -643,6 +651,7 @@ export const hostShellTool = {
643
651
  : `Background host command failed (id=${bgId}): ${err.message}`,
644
652
  source: "background-tool",
645
653
  persistTriggerAsEvent: true,
654
+ backgroundToolCompletion: completion,
646
655
  });
647
656
  removeBackgroundTool(bgId);
648
657
  });
@@ -11,6 +11,7 @@ import { isUntrustedShellLockdownActive } from "../../runtime/effective-capabili
11
11
  import { redactSecrets } from "../../security/secret-scanner.js";
12
12
  import { getLogger } from "../../util/logger.js";
13
13
  import { getDataDir } from "../../util/platform.js";
14
+ import type { CompletedBackgroundTool } from "../background-tool-registry.js";
14
15
  import {
15
16
  generateBackgroundToolId,
16
17
  isBackgroundToolLimitReached,
@@ -427,6 +428,17 @@ export const shellTool = {
427
428
  ? `Background command cancelled (id=${bgId}).`
428
429
  : fmtResult.content;
429
430
  const completedAt = Date.now();
431
+ const completion: CompletedBackgroundTool = {
432
+ id: bgId,
433
+ toolName: "bash",
434
+ conversationId: context.conversationId,
435
+ command,
436
+ startedAt,
437
+ status,
438
+ exitCode: code ?? null,
439
+ output,
440
+ completedAt,
441
+ };
430
442
 
431
443
  // Wake AFTER the terminal status is known so a user-cancelled run wakes
432
444
  // the assistant with the cancellation — not the SIGKILL-framed "completed"
@@ -440,6 +452,7 @@ export const shellTool = {
440
452
  hint: framing,
441
453
  source: "background-tool",
442
454
  persistTriggerAsEvent: true,
455
+ backgroundToolCompletion: completion,
443
456
  untrustedOutput: {
444
457
  content: output,
445
458
  source: "tool_result",
@@ -448,17 +461,7 @@ export const shellTool = {
448
461
  maxChars: MAX_OUTPUT_LENGTH * 2,
449
462
  },
450
463
  });
451
- recordCompletedBackgroundTool({
452
- id: bgId,
453
- toolName: "bash",
454
- conversationId: context.conversationId,
455
- command,
456
- startedAt,
457
- status,
458
- exitCode: code ?? null,
459
- output,
460
- completedAt,
461
- });
464
+ recordCompletedBackgroundTool(completion);
462
465
  broadcastMessage(
463
466
  {
464
467
  type: "background_tool_completed",
@@ -493,15 +496,8 @@ export const shellTool = {
493
496
  });
494
497
 
495
498
  const framing = `Background command failed (id=${bgId}): ${err.message}`;
496
- void wakeAgentForOpportunity({
497
- conversationId: context.conversationId,
498
- hint: framing,
499
- source: "background-tool",
500
- persistTriggerAsEvent: true,
501
- });
502
-
503
499
  const completedAt = Date.now();
504
- recordCompletedBackgroundTool({
500
+ const completion: CompletedBackgroundTool = {
505
501
  id: bgId,
506
502
  toolName: "bash",
507
503
  conversationId: context.conversationId,
@@ -511,7 +507,16 @@ export const shellTool = {
511
507
  exitCode: null,
512
508
  output: framing,
513
509
  completedAt,
510
+ };
511
+ void wakeAgentForOpportunity({
512
+ conversationId: context.conversationId,
513
+ hint: framing,
514
+ source: "background-tool",
515
+ persistTriggerAsEvent: true,
516
+ backgroundToolCompletion: completion,
514
517
  });
518
+
519
+ recordCompletedBackgroundTool(completion);
515
520
  broadcastMessage(
516
521
  {
517
522
  type: "background_tool_completed",