@vellumai/assistant 0.10.3-dev.202606301940.da6000c → 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 +10 -2
- package/package.json +1 -1
- package/src/__tests__/anthropic-provider.test.ts +5 -2
- 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__/schedule-routes.test.ts +4 -2
- 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__/daemon-skill-host.test.ts +13 -7
- package/src/daemon/conversation-agent-loop-handlers.ts +91 -20
- package/src/daemon/conversation-initial-prompt.ts +77 -0
- 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/providers/anthropic/client.ts +10 -7
- package/src/providers/model-catalog.ts +38 -0
- package/src/runtime/routes/__tests__/schedule-routes-create.test.ts +72 -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/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
|
@@ -22668,7 +22668,7 @@ paths:
|
|
|
22668
22668
|
post:
|
|
22669
22669
|
operationId: schedules_post
|
|
22670
22670
|
summary: Create schedule
|
|
22671
|
-
description: Create a new recurring schedule
|
|
22671
|
+
description: Create a new recurring schedule (execute, script, or workflow mode).
|
|
22672
22672
|
tags:
|
|
22673
22673
|
- schedules
|
|
22674
22674
|
requestBody:
|
|
@@ -22702,12 +22702,20 @@ paths:
|
|
|
22702
22702
|
description: Whether the schedule starts active (default true)
|
|
22703
22703
|
mode:
|
|
22704
22704
|
type: string
|
|
22705
|
-
description: "'execute' (default) or 'workflow' (flag-gated)"
|
|
22705
|
+
description: "'execute' (default), 'script', or 'workflow' (flag-gated)"
|
|
22706
22706
|
workflowName:
|
|
22707
22707
|
type: string
|
|
22708
22708
|
description: Saved workflow to trigger (required for workflow mode)
|
|
22709
22709
|
workflowArgs:
|
|
22710
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)
|
|
22711
22719
|
inferenceProfile:
|
|
22712
22720
|
anyOf:
|
|
22713
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 () => {
|
|
@@ -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
|
+
});
|
|
@@ -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 () => {
|
|
@@ -748,6 +748,58 @@ describe("schedules create", () => {
|
|
|
748
748
|
expect(exitFromIpcResultCalls).toEqual([mockIpcResult]);
|
|
749
749
|
expect(errorLines).toEqual([]);
|
|
750
750
|
});
|
|
751
|
+
|
|
752
|
+
test("creates a script-mode schedule with --mode script --script", async () => {
|
|
753
|
+
mockIpcResult = { ok: true, result: { schedules: [] } };
|
|
754
|
+
|
|
755
|
+
const { exitCode } = await runCommand([
|
|
756
|
+
"schedules",
|
|
757
|
+
"create",
|
|
758
|
+
"GitHub watcher",
|
|
759
|
+
"--mode",
|
|
760
|
+
"script",
|
|
761
|
+
"--script",
|
|
762
|
+
'cd "$VELLUM_WORKSPACE_DIR/schedules/$__SCHEDULE_ID" && bun poll.ts',
|
|
763
|
+
"--expression",
|
|
764
|
+
"*/15 * * * *",
|
|
765
|
+
"--description",
|
|
766
|
+
"Polls GitHub notifications",
|
|
767
|
+
]);
|
|
768
|
+
|
|
769
|
+
expect(exitCode).toBe(0);
|
|
770
|
+
expect(ipcCalls).toEqual([
|
|
771
|
+
{
|
|
772
|
+
method: "createSchedule",
|
|
773
|
+
params: {
|
|
774
|
+
body: {
|
|
775
|
+
name: "GitHub watcher",
|
|
776
|
+
expression: "*/15 * * * *",
|
|
777
|
+
description: "Polls GitHub notifications",
|
|
778
|
+
enabled: true,
|
|
779
|
+
mode: "script",
|
|
780
|
+
script:
|
|
781
|
+
'cd "$VELLUM_WORKSPACE_DIR/schedules/$__SCHEDULE_ID" && bun poll.ts',
|
|
782
|
+
},
|
|
783
|
+
},
|
|
784
|
+
},
|
|
785
|
+
]);
|
|
786
|
+
});
|
|
787
|
+
|
|
788
|
+
test("exits 1 when --mode script is missing --script", async () => {
|
|
789
|
+
const { exitCode } = await runCommand([
|
|
790
|
+
"schedules",
|
|
791
|
+
"create",
|
|
792
|
+
"Broken",
|
|
793
|
+
"--mode",
|
|
794
|
+
"script",
|
|
795
|
+
"--expression",
|
|
796
|
+
"*/15 * * * *",
|
|
797
|
+
"--description",
|
|
798
|
+
"no script",
|
|
799
|
+
]);
|
|
800
|
+
|
|
801
|
+
expect(exitCode).toBe(1);
|
|
802
|
+
});
|
|
751
803
|
});
|
|
752
804
|
|
|
753
805
|
describe("schedules update", () => {
|
|
@@ -416,9 +416,21 @@ Examples:
|
|
|
416
416
|
"-d, --description <text>",
|
|
417
417
|
"Authored description explaining what the schedule is for",
|
|
418
418
|
)
|
|
419
|
-
.
|
|
419
|
+
.option(
|
|
420
420
|
"-m, --message <text>",
|
|
421
|
-
"Message body sent to the assistant on each fire",
|
|
421
|
+
"Message body sent to the assistant on each fire (required for execute mode)",
|
|
422
|
+
)
|
|
423
|
+
.option(
|
|
424
|
+
"--mode <mode>",
|
|
425
|
+
"Schedule mode: 'execute' (default), 'script', or 'workflow'",
|
|
426
|
+
)
|
|
427
|
+
.option(
|
|
428
|
+
"--script <command>",
|
|
429
|
+
"Shell command to run on each fire (required for --mode script)",
|
|
430
|
+
)
|
|
431
|
+
.option(
|
|
432
|
+
"--timeout-ms <ms>",
|
|
433
|
+
"Script execution timeout override in ms (--mode script)",
|
|
422
434
|
)
|
|
423
435
|
.option(
|
|
424
436
|
"-t, --timezone <tz>",
|
|
@@ -436,7 +448,10 @@ Examples:
|
|
|
436
448
|
Options:
|
|
437
449
|
-e, --expression <expr> Cron (e.g. '*/30 * * * *') or RRULE expression.
|
|
438
450
|
-d, --description <text> Authored description explaining what the schedule is for.
|
|
439
|
-
-m, --message <text> Message body sent on each fire.
|
|
451
|
+
-m, --message <text> Message body sent on each fire (execute mode).
|
|
452
|
+
--mode <mode> execute (default), script, or workflow.
|
|
453
|
+
--script <command> Shell command for --mode script.
|
|
454
|
+
--timeout-ms <ms> Script timeout override in ms (--mode script).
|
|
440
455
|
-t, --timezone <tz> IANA timezone applied to the expression.
|
|
441
456
|
--profile <name> Inference profile (llm.profiles key) the schedule's
|
|
442
457
|
runs use. When omitted, runs use the default —
|
|
@@ -448,11 +463,17 @@ Arguments:
|
|
|
448
463
|
<name> Display name for the schedule.
|
|
449
464
|
|
|
450
465
|
Behavior:
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
466
|
+
Defaults to 'execute' mode (requires --message). Pass --mode script with
|
|
467
|
+
--script to run a shell command on each fire with no LLM call; the script's
|
|
468
|
+
env includes $VELLUM_WORKSPACE_DIR and $__SCHEDULE_ID (this schedule's id).
|
|
469
|
+
notify/wake schedules remain reachable only through the schedule_create tool.
|
|
454
470
|
|
|
455
471
|
Examples:
|
|
472
|
+
$ assistant schedules create "GitHub watcher" \\
|
|
473
|
+
--mode script \\
|
|
474
|
+
--script 'cd "$VELLUM_WORKSPACE_DIR/schedules/$__SCHEDULE_ID" && bun poll.ts' \\
|
|
475
|
+
--expression '*/15 * * * *' \\
|
|
476
|
+
--description 'Polls GitHub notifications' --json
|
|
456
477
|
$ assistant schedules create "Heartbeat" \\
|
|
457
478
|
--expression '*/30 * * * *' \\
|
|
458
479
|
--description 'Checks service heartbeat every 30 minutes' \\
|
|
@@ -474,7 +495,10 @@ Examples:
|
|
|
474
495
|
opts: {
|
|
475
496
|
expression: string;
|
|
476
497
|
description: string;
|
|
477
|
-
message
|
|
498
|
+
message?: string;
|
|
499
|
+
mode?: string;
|
|
500
|
+
script?: string;
|
|
501
|
+
timeoutMs?: string;
|
|
478
502
|
timezone?: string;
|
|
479
503
|
profile?: string;
|
|
480
504
|
enabled: boolean;
|
|
@@ -506,13 +530,33 @@ Examples:
|
|
|
506
530
|
return;
|
|
507
531
|
}
|
|
508
532
|
|
|
533
|
+
const mode = opts.mode ?? "execute";
|
|
534
|
+
if (mode === "script" && !opts.script) {
|
|
535
|
+
const error = "--script is required for --mode script";
|
|
536
|
+
if (opts.json) writeOutput(cmd, { ok: false, error });
|
|
537
|
+
else log.error(error);
|
|
538
|
+
process.exitCode = 1;
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
if (mode === "execute" && !opts.message) {
|
|
542
|
+
const error = "--message is required for execute mode";
|
|
543
|
+
if (opts.json) writeOutput(cmd, { ok: false, error });
|
|
544
|
+
else log.error(error);
|
|
545
|
+
process.exitCode = 1;
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
|
|
509
549
|
const body: Record<string, unknown> = {
|
|
510
550
|
name: scheduleName,
|
|
511
551
|
expression: opts.expression,
|
|
512
552
|
description,
|
|
513
|
-
message: opts.message,
|
|
514
553
|
enabled: opts.enabled,
|
|
515
554
|
};
|
|
555
|
+
// Omit mode for the default; the route treats absent as 'execute'.
|
|
556
|
+
if (mode !== "execute") body.mode = mode;
|
|
557
|
+
if (opts.message != null) body.message = opts.message;
|
|
558
|
+
if (opts.script != null) body.script = opts.script;
|
|
559
|
+
if (opts.timeoutMs != null) body.timeoutMs = Number(opts.timeoutMs);
|
|
516
560
|
if (opts.timezone != null) body.timezone = opts.timezone;
|
|
517
561
|
if (opts.profile != null) body.inferenceProfile = opts.profile;
|
|
518
562
|
|
|
@@ -66,8 +66,6 @@ export const CALL_SITE_DEFAULTS: Record<LLMCallSite, CallSiteDefaultConfig> = {
|
|
|
66
66
|
approvalConversation: { profile: "cost-optimized" },
|
|
67
67
|
trustRuleSuggestion: { profile: "cost-optimized" },
|
|
68
68
|
styleAnalyzer: { profile: "cost-optimized" },
|
|
69
|
-
meetConsentMonitor: { profile: "cost-optimized" },
|
|
70
|
-
meetChatOpportunity: { profile: "cost-optimized" },
|
|
71
69
|
inference: { profile: "cost-optimized" },
|
|
72
70
|
// Vision captioning for the image-fallback plugin. No pinned profile — the
|
|
73
71
|
// plugin resolves a vision-capable profile itself via `doesSupportVision` and
|
|
@@ -218,14 +218,6 @@
|
|
|
218
218
|
"description": "Replace the knowledge graph top-level tab with a new Home page showing relationship progression, facts, and capability tiers",
|
|
219
219
|
"defaultEnabled": false
|
|
220
220
|
},
|
|
221
|
-
{
|
|
222
|
-
"id": "meet",
|
|
223
|
-
"scope": "assistant",
|
|
224
|
-
"key": "meet",
|
|
225
|
-
"label": "Google Meet",
|
|
226
|
-
"description": "Enables the Google Meet joining bot and the meet-join skill.",
|
|
227
|
-
"defaultEnabled": false
|
|
228
|
-
},
|
|
229
221
|
{
|
|
230
222
|
"id": "scroll-debug-overlay",
|
|
231
223
|
"scope": "client",
|
|
@@ -282,18 +282,6 @@ const CATALOG_RECORD: CatalogRecord = {
|
|
|
282
282
|
description: "Infers the category of a skill from its description.",
|
|
283
283
|
domain: "skills",
|
|
284
284
|
},
|
|
285
|
-
meetConsentMonitor: {
|
|
286
|
-
id: "meetConsentMonitor",
|
|
287
|
-
displayName: "Meet Consent Monitor",
|
|
288
|
-
description: "Monitors meeting consent signals during live calls.",
|
|
289
|
-
domain: "skills",
|
|
290
|
-
},
|
|
291
|
-
meetChatOpportunity: {
|
|
292
|
-
id: "meetChatOpportunity",
|
|
293
|
-
displayName: "Meet Chat Opportunity",
|
|
294
|
-
description: "Identifies opportunities to engage in meeting chat.",
|
|
295
|
-
domain: "skills",
|
|
296
|
-
},
|
|
297
285
|
inference: {
|
|
298
286
|
id: "inference",
|
|
299
287
|
displayName: "Inference",
|
|
@@ -135,9 +135,9 @@ mock.module("../../runtime/skill-route-registry.js", () => ({
|
|
|
135
135
|
registerSkillRoute: registerSkillRouteSpy,
|
|
136
136
|
}));
|
|
137
137
|
|
|
138
|
-
const
|
|
139
|
-
mock.module("
|
|
140
|
-
|
|
138
|
+
const registerPluginHooksSpy = mock(() => {});
|
|
139
|
+
mock.module("../../hooks/registry.js", () => ({
|
|
140
|
+
registerPluginHooks: registerPluginHooksSpy,
|
|
141
141
|
}));
|
|
142
142
|
|
|
143
143
|
class StubSpeakerTracker {}
|
|
@@ -261,10 +261,16 @@ describe("createDaemonSkillHost", () => {
|
|
|
261
261
|
expect(registerSkillRouteSpy).toHaveBeenCalled();
|
|
262
262
|
const hook = async () => {};
|
|
263
263
|
host.registries.registerShutdownHook("test-hook", hook);
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
);
|
|
264
|
+
// The skill's shutdown hook is registered into the unified hook registry
|
|
265
|
+
// under the skillId-namespaced owner, as a `shutdown` hook that fires via
|
|
266
|
+
// `runHook(HOOKS.SHUTDOWN)` at daemon shutdown.
|
|
267
|
+
expect(registerPluginHooksSpy).toHaveBeenCalledTimes(1);
|
|
268
|
+
const [owner, hooks] = registerPluginHooksSpy.mock.calls[0]! as unknown as [
|
|
269
|
+
string,
|
|
270
|
+
Record<string, unknown>,
|
|
271
|
+
];
|
|
272
|
+
expect(owner).toBe("meet-join:test-hook");
|
|
273
|
+
expect(typeof hooks.shutdown).toBe("function");
|
|
268
274
|
});
|
|
269
275
|
|
|
270
276
|
test("speakers.createTracker yields a concrete tracker instance", () => {
|