@vellumai/assistant 0.10.3-dev.202606301846.839df2b → 0.10.3-dev.202606302045.155be34
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 +48 -2
- package/package.json +1 -1
- package/src/__tests__/anthropic-provider.test.ts +5 -2
- package/src/__tests__/background-shell-bash.test.ts +9 -0
- package/src/__tests__/background-shell-host-bash.test.ts +11 -0
- package/src/__tests__/conversation-initial-prompt.test.ts +110 -0
- package/src/__tests__/conversation-loop-db-lock-resilience.test.ts +191 -0
- package/src/__tests__/list-messages-background-tool-completion.test.ts +141 -0
- package/src/__tests__/schedule-routes.test.ts +4 -2
- package/src/api/index.ts +1 -0
- package/src/api/responses/conversation-message.ts +29 -0
- package/src/cli/commands/__tests__/schedules.test.ts +52 -0
- package/src/cli/commands/schedules.ts +52 -8
- package/src/config/call-site-defaults.ts +0 -2
- package/src/config/feature-flag-registry.json +0 -8
- package/src/config/schemas/call-site-catalog.ts +0 -12
- package/src/config/schemas/llm.ts +0 -2
- package/src/daemon/__tests__/abort-null-controller.test.ts +167 -0
- package/src/daemon/__tests__/daemon-skill-host.test.ts +13 -7
- package/src/daemon/__tests__/wake-conversation-ops-completion.test.ts +107 -0
- package/src/daemon/conversation-agent-loop-handlers.ts +91 -20
- package/src/daemon/conversation-initial-prompt.ts +77 -0
- package/src/daemon/conversation-lifecycle.ts +25 -1
- package/src/daemon/conversation-store.ts +2 -3
- package/src/daemon/daemon-skill-host.ts +13 -6
- package/src/daemon/message-protocol.ts +0 -3
- package/src/daemon/shutdown-handlers.ts +4 -14
- package/src/daemon/wake-conversation-ops.ts +3 -0
- package/src/persistence/conversation-crud.ts +18 -0
- package/src/providers/anthropic/client.ts +10 -7
- package/src/providers/model-catalog.ts +38 -0
- package/src/runtime/agent-wake.ts +13 -1
- package/src/runtime/routes/__tests__/schedule-routes-create.test.ts +72 -0
- package/src/runtime/routes/conversation-routes.ts +16 -0
- package/src/runtime/routes/schedule-routes.ts +46 -5
- package/src/schedule/__tests__/run-script.test.ts +21 -0
- package/src/schedule/run-script.ts +13 -6
- package/src/schedule/scheduler.ts +1 -0
- package/src/tools/host-terminal/host-shell.ts +17 -8
- package/src/tools/terminal/shell.ts +24 -19
- package/drizzle/0000_dizzy_maggott.sql +0 -301
- package/drizzle/meta/0000_snapshot.json +0 -1933
- package/drizzle/meta/_journal.json +0 -13
- package/src/daemon/message-types/meet.ts +0 -143
- package/src/daemon/shutdown-registry.ts +0 -40
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:
|
|
@@ -22630,7 +22668,7 @@ paths:
|
|
|
22630
22668
|
post:
|
|
22631
22669
|
operationId: schedules_post
|
|
22632
22670
|
summary: Create schedule
|
|
22633
|
-
description: Create a new recurring schedule
|
|
22671
|
+
description: Create a new recurring schedule (execute, script, or workflow mode).
|
|
22634
22672
|
tags:
|
|
22635
22673
|
- schedules
|
|
22636
22674
|
requestBody:
|
|
@@ -22664,12 +22702,20 @@ paths:
|
|
|
22664
22702
|
description: Whether the schedule starts active (default true)
|
|
22665
22703
|
mode:
|
|
22666
22704
|
type: string
|
|
22667
|
-
description: "'execute' (default) or 'workflow' (flag-gated)"
|
|
22705
|
+
description: "'execute' (default), 'script', or 'workflow' (flag-gated)"
|
|
22668
22706
|
workflowName:
|
|
22669
22707
|
type: string
|
|
22670
22708
|
description: Saved workflow to trigger (required for workflow mode)
|
|
22671
22709
|
workflowArgs:
|
|
22672
22710
|
description: Args passed to the workflow run (workflow mode)
|
|
22711
|
+
script:
|
|
22712
|
+
type: string
|
|
22713
|
+
description: Shell command run on each fire (required for script mode)
|
|
22714
|
+
timeoutMs:
|
|
22715
|
+
anyOf:
|
|
22716
|
+
- type: number
|
|
22717
|
+
- type: "null"
|
|
22718
|
+
description: Script execution timeout override in ms (script mode)
|
|
22673
22719
|
inferenceProfile:
|
|
22674
22720
|
anyOf:
|
|
22675
22721
|
- type: string
|
package/package.json
CHANGED
|
@@ -3245,11 +3245,14 @@ describe("AnthropicProvider — deprecated sampling params (temperature / top_p
|
|
|
3245
3245
|
lastStreamParams = null;
|
|
3246
3246
|
});
|
|
3247
3247
|
|
|
3248
|
-
// opus-4-7 / opus-4-8 (and, conservatively, fable) reject
|
|
3249
|
-
// `top_p`, and `top_k` with a 400; the provider must strip
|
|
3248
|
+
// opus-4-7 / opus-4-8 / sonnet-5 (and, conservatively, fable) reject
|
|
3249
|
+
// `temperature`, `top_p`, and `top_k` with a 400; the provider must strip
|
|
3250
|
+
// all three. The OpenRouter `anthropic/...` form delegates here too.
|
|
3250
3251
|
for (const model of [
|
|
3251
3252
|
"claude-opus-4-8",
|
|
3252
3253
|
"claude-opus-4-7",
|
|
3254
|
+
"claude-sonnet-5",
|
|
3255
|
+
"anthropic/claude-sonnet-5",
|
|
3253
3256
|
"claude-fable-5",
|
|
3254
3257
|
]) {
|
|
3255
3258
|
test(`strips temperature, top_p, and top_k for ${model}`, async () => {
|
|
@@ -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,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for construction-time system-prompt resolution.
|
|
3
|
+
*
|
|
4
|
+
* A conversation's system prompt is built once at construction and frozen for
|
|
5
|
+
* every turn (the agent loop never re-resolves it), so the persona slot must
|
|
6
|
+
* resolve correctly here. With no construction-time identity the default-build
|
|
7
|
+
* path must warm the gateway guardian binding BEFORE building (else a cold cache
|
|
8
|
+
* pins `users/default.md`); a channel-routed conversation that already carries
|
|
9
|
+
* the requester's trust context must build with it so the requester's profile
|
|
10
|
+
* resolves instead of the guardian/default one.
|
|
11
|
+
*
|
|
12
|
+
* Dependency seams are injected so this exercises the sequencing without mocking
|
|
13
|
+
* the widely-imported guardian-delivery / system-prompt modules (those global
|
|
14
|
+
* module mocks leak across files in the shared-process test runner).
|
|
15
|
+
*/
|
|
16
|
+
import { describe, expect, test } from "bun:test";
|
|
17
|
+
|
|
18
|
+
import {
|
|
19
|
+
resolveInitialSystemPrompt,
|
|
20
|
+
warmGuardianBindings,
|
|
21
|
+
} from "../daemon/conversation-initial-prompt.js";
|
|
22
|
+
import type { ConversationCreateOptions } from "../daemon/handlers/shared.js";
|
|
23
|
+
import type { TrustContext } from "../daemon/trust-context.js";
|
|
24
|
+
|
|
25
|
+
function spyDeps() {
|
|
26
|
+
const calls: string[] = [];
|
|
27
|
+
const builtWith: Array<TrustContext | undefined> = [];
|
|
28
|
+
return {
|
|
29
|
+
calls,
|
|
30
|
+
builtWith,
|
|
31
|
+
deps: {
|
|
32
|
+
warm: async () => {
|
|
33
|
+
calls.push("warm");
|
|
34
|
+
},
|
|
35
|
+
build: (trustContext: TrustContext | undefined) => {
|
|
36
|
+
calls.push("build");
|
|
37
|
+
builtWith.push(trustContext);
|
|
38
|
+
return "BUILD";
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const REQUESTER_TRUST = {
|
|
45
|
+
sourceChannel: "slack",
|
|
46
|
+
trustClass: "trusted_contact",
|
|
47
|
+
requesterExternalUserId: "user-123",
|
|
48
|
+
} as unknown as TrustContext;
|
|
49
|
+
|
|
50
|
+
describe("warmGuardianBindings", () => {
|
|
51
|
+
test("warms both the vellum-channel key and the unfiltered fallback key", async () => {
|
|
52
|
+
const keys: string[] = [];
|
|
53
|
+
await warmGuardianBindings(async (input) => {
|
|
54
|
+
keys.push(input?.channelTypes?.join(",") ?? "ALL");
|
|
55
|
+
return null;
|
|
56
|
+
});
|
|
57
|
+
// Both keys the persona resolver reads — peekGuardianForChannel("vellum")
|
|
58
|
+
// and its peekAnyGuardian() fallback — must be warmed.
|
|
59
|
+
expect(keys.sort()).toEqual(["ALL", "vellum"]);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe("resolveInitialSystemPrompt", () => {
|
|
64
|
+
test("no construction-time identity: warms the guardian binding, then builds with no trust context", async () => {
|
|
65
|
+
const { calls, builtWith, deps } = spyDeps();
|
|
66
|
+
const result = await resolveInitialSystemPrompt(undefined, deps);
|
|
67
|
+
|
|
68
|
+
expect(result).toBe("BUILD");
|
|
69
|
+
// The warm MUST precede the build so the persona slot resolves the
|
|
70
|
+
// guardian's users/<slug>.md instead of users/default.md on a cold cache.
|
|
71
|
+
expect(calls).toEqual(["warm", "build"]);
|
|
72
|
+
expect(builtWith).toEqual([undefined]);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("channel-routed: threads the requester trust context and skips the guardian warm", async () => {
|
|
76
|
+
const { calls, builtWith, deps } = spyDeps();
|
|
77
|
+
const result = await resolveInitialSystemPrompt(
|
|
78
|
+
{ trustContext: REQUESTER_TRUST } as ConversationCreateOptions,
|
|
79
|
+
deps,
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
expect(result).toBe("BUILD");
|
|
83
|
+
// The requester's identity resolves the persona via a DB lookup, so no
|
|
84
|
+
// guardian-cache warm is needed and the trust context must reach the build.
|
|
85
|
+
expect(calls).toEqual(["build"]);
|
|
86
|
+
expect(builtWith).toEqual([REQUESTER_TRUST]);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("an explicit override is used verbatim and skips the warm and build", async () => {
|
|
90
|
+
const { calls, deps } = spyDeps();
|
|
91
|
+
const result = await resolveInitialSystemPrompt(
|
|
92
|
+
{ systemPromptOverride: "CUSTOM PROMPT" } as ConversationCreateOptions,
|
|
93
|
+
deps,
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
expect(result).toBe("CUSTOM PROMPT");
|
|
97
|
+
expect(calls).toEqual([]);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("an explicit empty-string override is honored verbatim (not treated as absent)", async () => {
|
|
101
|
+
const { calls, deps } = spyDeps();
|
|
102
|
+
const result = await resolveInitialSystemPrompt(
|
|
103
|
+
{ systemPromptOverride: "" } as ConversationCreateOptions,
|
|
104
|
+
deps,
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
expect(result).toBe("");
|
|
108
|
+
expect(calls).toEqual([]);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression tests for SQLite write-contention resilience in the agent loop.
|
|
3
|
+
*
|
|
4
|
+
* When another writer holds the SQLite write lock long enough that
|
|
5
|
+
* `busy_timeout` elapses, an in-loop content write throws `SQLITE_BUSY`
|
|
6
|
+
* ("database is locked"). The turn-finalizing write at `message_complete` is on
|
|
7
|
+
* `dispatchAgentEvent`'s re-throw allowlist, so before this resilience layer a
|
|
8
|
+
* single transient lock aborted the whole turn and surfaced to the user as
|
|
9
|
+
* "Processing failed: database is locked".
|
|
10
|
+
*
|
|
11
|
+
* These tests pin the two guarantees:
|
|
12
|
+
* 1. A transient `SQLITE_BUSY` is retried and the row is finalized normally.
|
|
13
|
+
* 2. A persistent `SQLITE_BUSY` is swallowed (logged) so the turn continues
|
|
14
|
+
* instead of throwing — a later write (this turn or the next) overwrites
|
|
15
|
+
* the dropped content.
|
|
16
|
+
*/
|
|
17
|
+
import { beforeEach, describe, expect, mock, test } from "bun:test";
|
|
18
|
+
|
|
19
|
+
// ── Mock platform (must precede imports that read it) ─────────────────────────
|
|
20
|
+
mock.module("../util/logger.js", () => ({
|
|
21
|
+
getLogger: () =>
|
|
22
|
+
new Proxy({} as Record<string, unknown>, {
|
|
23
|
+
get: () => () => {},
|
|
24
|
+
}),
|
|
25
|
+
}));
|
|
26
|
+
|
|
27
|
+
mock.module("../config/loader.js", () => ({
|
|
28
|
+
getConfig: () => ({ memory: {} }),
|
|
29
|
+
loadConfig: () => ({}),
|
|
30
|
+
}));
|
|
31
|
+
|
|
32
|
+
// `updateMessageContent` throws `SQLITE_BUSY` for the first
|
|
33
|
+
// `updateContentFailuresRemaining` calls, then records the content written.
|
|
34
|
+
let updateContentFailuresRemaining = 0;
|
|
35
|
+
const writtenContentById = new Map<string, string>();
|
|
36
|
+
const updateMessageContentMock = mock((id: string, content: string) => {
|
|
37
|
+
if (updateContentFailuresRemaining > 0) {
|
|
38
|
+
updateContentFailuresRemaining -= 1;
|
|
39
|
+
throw Object.assign(new Error("database is locked"), {
|
|
40
|
+
code: "SQLITE_BUSY",
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
writtenContentById.set(id, content);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// Stand-in for the `conversations.seq` column, mirroring the monotonic,
|
|
47
|
+
// ignore-non-positive semantics of the real `recordConversationPersistedSeq`.
|
|
48
|
+
const persistedSeqByConversation = new Map<string, number>();
|
|
49
|
+
|
|
50
|
+
mock.module("../persistence/conversation-crud.js", () => ({
|
|
51
|
+
setConversationProcessingStartedAt: () => {},
|
|
52
|
+
isConversationProcessing: () => false,
|
|
53
|
+
getConversation: () => null,
|
|
54
|
+
getMessageById: () => null,
|
|
55
|
+
updateMessageContent: updateMessageContentMock,
|
|
56
|
+
provenanceFromTrustContext: () => ({}),
|
|
57
|
+
reserveMessage: async (_conversationId: string, role: string) => ({
|
|
58
|
+
id: role === "user" ? "tool-result-row" : "assistant-row",
|
|
59
|
+
}),
|
|
60
|
+
recordConversationPersistedSeq: (id: string, seq: number) => {
|
|
61
|
+
if (!Number.isFinite(seq) || seq <= 0) return;
|
|
62
|
+
const prev = persistedSeqByConversation.get(id);
|
|
63
|
+
if (prev == null || prev < seq) persistedSeqByConversation.set(id, seq);
|
|
64
|
+
},
|
|
65
|
+
getConversationPersistedSeq: (id: string) =>
|
|
66
|
+
persistedSeqByConversation.get(id) ?? null,
|
|
67
|
+
}));
|
|
68
|
+
|
|
69
|
+
mock.module("../persistence/conversation-disk-view.js", () => ({
|
|
70
|
+
syncMessageToDisk: () => {},
|
|
71
|
+
}));
|
|
72
|
+
|
|
73
|
+
mock.module("../persistence/llm-request-log-store.js", () => ({
|
|
74
|
+
recordRequestLog: () => {},
|
|
75
|
+
backfillMessageIdOnLogs: () => {},
|
|
76
|
+
}));
|
|
77
|
+
|
|
78
|
+
mock.module("../plugins/defaults/memory/memory-recall-log-store.js", () => ({
|
|
79
|
+
backfillMemoryRecallLogMessageId: () => {},
|
|
80
|
+
}));
|
|
81
|
+
|
|
82
|
+
mock.module(
|
|
83
|
+
"../plugins/defaults/memory/memory-v2-activation-log-store.js",
|
|
84
|
+
() => ({
|
|
85
|
+
backfillMemoryV2ActivationMessageId: () => {},
|
|
86
|
+
}),
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
// ── Imports (after mocks) ─────────────────────────────────────────────────────
|
|
90
|
+
import type { AgentEvent } from "../agent/loop.js";
|
|
91
|
+
import type {
|
|
92
|
+
EventHandlerDeps,
|
|
93
|
+
EventHandlerState,
|
|
94
|
+
} from "../daemon/conversation-agent-loop-handlers.js";
|
|
95
|
+
import {
|
|
96
|
+
createEventHandlerState,
|
|
97
|
+
handleMessageComplete,
|
|
98
|
+
} from "../daemon/conversation-agent-loop-handlers.js";
|
|
99
|
+
import type { ServerMessage } from "../daemon/message-protocol.js";
|
|
100
|
+
import { getConversationPersistedSeq } from "../persistence/conversation-crud.js";
|
|
101
|
+
|
|
102
|
+
const CONVERSATION_ID = "test-session-id";
|
|
103
|
+
|
|
104
|
+
function createMockDeps(): EventHandlerDeps {
|
|
105
|
+
return {
|
|
106
|
+
ctx: {
|
|
107
|
+
conversationId: CONVERSATION_ID,
|
|
108
|
+
provider: { name: "anthropic" },
|
|
109
|
+
traceEmitter: { emit: () => {} },
|
|
110
|
+
streamThinking: false,
|
|
111
|
+
emitActivityState: () => {},
|
|
112
|
+
markWorkspaceTopLevelDirty: () => {},
|
|
113
|
+
currentTurnSurfaces: [],
|
|
114
|
+
} as unknown as EventHandlerDeps["ctx"],
|
|
115
|
+
onEvent: (_msg: ServerMessage) => {},
|
|
116
|
+
reqId: "test-req-id",
|
|
117
|
+
isFirstMessage: false,
|
|
118
|
+
shouldGenerateTitle: false,
|
|
119
|
+
rlog: new Proxy({} as Record<string, unknown>, {
|
|
120
|
+
get: () => () => {},
|
|
121
|
+
}) as unknown as EventHandlerDeps["rlog"],
|
|
122
|
+
turnChannelContext: {
|
|
123
|
+
userMessageChannel: "vellum",
|
|
124
|
+
assistantMessageChannel: "vellum",
|
|
125
|
+
} as EventHandlerDeps["turnChannelContext"],
|
|
126
|
+
turnInterfaceContext: {
|
|
127
|
+
userMessageInterface: "macos",
|
|
128
|
+
assistantMessageInterface: "macos",
|
|
129
|
+
} as EventHandlerDeps["turnInterfaceContext"],
|
|
130
|
+
} as EventHandlerDeps;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const MESSAGE_COMPLETE_EVENT = {
|
|
134
|
+
type: "message_complete",
|
|
135
|
+
message: { role: "assistant", content: [{ type: "text", text: "done" }] },
|
|
136
|
+
} as Extract<AgentEvent, { type: "message_complete" }>;
|
|
137
|
+
|
|
138
|
+
describe("agent loop SQLite write-contention resilience", () => {
|
|
139
|
+
let state: EventHandlerState;
|
|
140
|
+
|
|
141
|
+
beforeEach(() => {
|
|
142
|
+
state = createEventHandlerState();
|
|
143
|
+
// A row was reserved at llm_call_started; message_complete finalizes it.
|
|
144
|
+
state.lastAssistantMessageId = "assistant-row";
|
|
145
|
+
state.assistantRowAwaitingFinalization = true;
|
|
146
|
+
state.lastPersistedContentSeq = 5;
|
|
147
|
+
updateContentFailuresRemaining = 0;
|
|
148
|
+
writtenContentById.clear();
|
|
149
|
+
persistedSeqByConversation.clear();
|
|
150
|
+
updateMessageContentMock.mockClear();
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("retries a transient SQLITE_BUSY and finalizes the assistant row", async () => {
|
|
154
|
+
// GIVEN the finalize write loses the write-lock race twice before winning
|
|
155
|
+
updateContentFailuresRemaining = 2;
|
|
156
|
+
|
|
157
|
+
// WHEN message_complete drains
|
|
158
|
+
await handleMessageComplete(
|
|
159
|
+
state,
|
|
160
|
+
createMockDeps(),
|
|
161
|
+
MESSAGE_COMPLETE_EVENT,
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
// THEN it retried until the write committed (2 failures + 1 success) ...
|
|
165
|
+
expect(updateMessageContentMock).toHaveBeenCalledTimes(3);
|
|
166
|
+
expect(writtenContentById.get("assistant-row")).toContain("done");
|
|
167
|
+
// ... the finalization completed ...
|
|
168
|
+
expect(state.assistantRowAwaitingFinalization).toBe(false);
|
|
169
|
+
// ... and the persisted seq advanced because the content is durable.
|
|
170
|
+
expect(getConversationPersistedSeq(CONVERSATION_ID)).toBe(5);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("swallows a persistent SQLITE_BUSY without aborting the turn", async () => {
|
|
174
|
+
// GIVEN every finalize attempt loses the race (lock held past every retry)
|
|
175
|
+
updateContentFailuresRemaining = Number.POSITIVE_INFINITY;
|
|
176
|
+
|
|
177
|
+
// WHEN message_complete drains, it must resolve rather than throw — a thrown
|
|
178
|
+
// SQLITE_BUSY here is on dispatchAgentEvent's re-throw allowlist and would
|
|
179
|
+
// kill the turn.
|
|
180
|
+
await expect(
|
|
181
|
+
handleMessageComplete(state, createMockDeps(), MESSAGE_COMPLETE_EVENT),
|
|
182
|
+
).resolves.toBeUndefined();
|
|
183
|
+
|
|
184
|
+
// THEN the write was attempted (initial try + the retries) ...
|
|
185
|
+
expect(updateMessageContentMock.mock.calls.length).toBeGreaterThan(1);
|
|
186
|
+
// ... no content landed ...
|
|
187
|
+
expect(writtenContentById.has("assistant-row")).toBe(false);
|
|
188
|
+
// ... and the seq was NOT advanced past content that never became durable.
|
|
189
|
+
expect(getConversationPersistedSeq(CONVERSATION_ID)).toBeNull();
|
|
190
|
+
});
|
|
191
|
+
});
|
|
@@ -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
|
+
});
|
|
@@ -1241,7 +1241,7 @@ describe("POST /schedules — create", () => {
|
|
|
1241
1241
|
expect(job.description).toBe("Description fallback");
|
|
1242
1242
|
});
|
|
1243
1243
|
|
|
1244
|
-
test("rejects modes other than execute/workflow", async () => {
|
|
1244
|
+
test("rejects modes other than execute/workflow/script", async () => {
|
|
1245
1245
|
await expect(
|
|
1246
1246
|
postCreate({
|
|
1247
1247
|
name: "x",
|
|
@@ -1250,7 +1250,9 @@ describe("POST /schedules — create", () => {
|
|
|
1250
1250
|
message: "hi",
|
|
1251
1251
|
mode: "notify",
|
|
1252
1252
|
}),
|
|
1253
|
-
).rejects.toThrow(
|
|
1253
|
+
).rejects.toThrow(
|
|
1254
|
+
"Only 'execute', 'script', and 'workflow' modes are supported",
|
|
1255
|
+
);
|
|
1254
1256
|
});
|
|
1255
1257
|
|
|
1256
1258
|
test("rejects an unparseable expression", async () => {
|
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
|