@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 +38 -0
- package/package.json +1 -1
- package/src/__tests__/background-shell-bash.test.ts +9 -0
- package/src/__tests__/background-shell-host-bash.test.ts +11 -0
- package/src/__tests__/list-messages-background-tool-completion.test.ts +141 -0
- package/src/api/index.ts +1 -0
- package/src/api/responses/conversation-message.ts +29 -0
- package/src/daemon/__tests__/abort-null-controller.test.ts +167 -0
- package/src/daemon/__tests__/wake-conversation-ops-completion.test.ts +107 -0
- package/src/daemon/conversation-lifecycle.ts +25 -1
- package/src/daemon/wake-conversation-ops.ts +3 -0
- package/src/persistence/conversation-crud.ts +18 -0
- package/src/runtime/agent-wake.ts +13 -1
- package/src/runtime/routes/conversation-routes.ts +16 -0
- package/src/tools/host-terminal/host-shell.ts +17 -8
- package/src/tools/terminal/shell.ts +24 -19
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
|
@@ -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
|
@@ -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
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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",
|