@vellumai/assistant 0.10.3-dev.202606301940.da6000c → 0.10.3-dev.202606302138.0f63073

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.
Files changed (44) hide show
  1. package/openapi.yaml +43 -3
  2. package/package.json +1 -1
  3. package/src/__tests__/anthropic-provider.test.ts +5 -2
  4. package/src/__tests__/conversation-initial-prompt.test.ts +110 -0
  5. package/src/__tests__/conversation-loop-db-lock-resilience.test.ts +191 -0
  6. package/src/__tests__/openai-provider.test.ts +33 -0
  7. package/src/__tests__/openai-responses-provider.test.ts +40 -0
  8. package/src/__tests__/schedule-routes.test.ts +4 -2
  9. package/src/cli/commands/__tests__/schedules.test.ts +52 -0
  10. package/src/cli/commands/schedules.ts +52 -8
  11. package/src/cli/lib/__tests__/plugin-catalog-cache.test.ts +1 -0
  12. package/src/cli/lib/__tests__/search-plugins.test.ts +29 -1
  13. package/src/cli/lib/search-plugins.ts +6 -0
  14. package/src/config/call-site-defaults.ts +0 -2
  15. package/src/config/feature-flag-registry.json +0 -8
  16. package/src/config/schemas/call-site-catalog.ts +0 -12
  17. package/src/config/schemas/llm.ts +0 -2
  18. package/src/daemon/__tests__/daemon-skill-host.test.ts +13 -7
  19. package/src/daemon/conversation-agent-loop-handlers.ts +91 -20
  20. package/src/daemon/conversation-initial-prompt.ts +77 -0
  21. package/src/daemon/conversation-store.ts +2 -3
  22. package/src/daemon/daemon-skill-host.ts +13 -6
  23. package/src/daemon/message-protocol.ts +0 -3
  24. package/src/daemon/shutdown-handlers.ts +4 -14
  25. package/src/providers/anthropic/client.ts +10 -7
  26. package/src/providers/model-catalog.ts +38 -0
  27. package/src/providers/openai/chat-completions-provider.ts +14 -1
  28. package/src/providers/openai/responses-provider.ts +18 -0
  29. package/src/providers/tool-progress-events.test.ts +50 -0
  30. package/src/providers/tool-progress-events.ts +99 -0
  31. package/src/runtime/routes/__tests__/plugins-routes.test.ts +395 -26
  32. package/src/runtime/routes/__tests__/schedule-routes-create.test.ts +72 -0
  33. package/src/runtime/routes/plugins-routes.ts +180 -7
  34. package/src/runtime/routes/schedule-routes.ts +46 -5
  35. package/src/schedule/__tests__/run-script.test.ts +21 -0
  36. package/src/schedule/run-script.ts +13 -6
  37. package/src/schedule/scheduler.ts +1 -0
  38. package/src/skills/__tests__/categories-cache.test.ts +38 -0
  39. package/src/skills/categories-cache.ts +40 -5
  40. package/drizzle/0000_dizzy_maggott.sql +0 -301
  41. package/drizzle/meta/0000_snapshot.json +0 -1933
  42. package/drizzle/meta/_journal.json +0 -13
  43. package/src/daemon/message-types/meet.ts +0 -143
  44. package/src/daemon/shutdown-registry.ts +0 -40
package/openapi.yaml CHANGED
@@ -20818,7 +20818,10 @@ paths:
20818
20818
  description:
20819
20819
  Return one entry per directory under `<workspaceDir>/plugins/`, sorted alphabetically. Matches the CLI's
20820
20820
  `assistant plugins list`. Supports `?q=<text>` for case-insensitive substring matching across plugin id, name,
20821
- and description.
20821
+ and description. Each entry carries a `category` (marketplace slug from the Skills taxonomy, or `null` for
20822
+ non-marketplace installs); the response also reports `categoryCounts` (per-category totals, computed before the
20823
+ category filter) and `totalCount`. `?category=<slug>` filters the returned plugins by category server-side while
20824
+ leaving the counts unfiltered. A marketplace outage degrades `category` to `null` without failing the list.
20822
20825
  tags:
20823
20826
  - plugins
20824
20827
  parameters:
@@ -20828,6 +20831,12 @@ paths:
20828
20831
  schema:
20829
20832
  type: string
20830
20833
  description: Optional substring filter applied to plugin id, name, and description.
20834
+ - name: category
20835
+ in: query
20836
+ required: false
20837
+ schema:
20838
+ type: string
20839
+ description: Filter installed plugins by category slug (Skills taxonomy).
20831
20840
  responses:
20832
20841
  "200":
20833
20842
  description: Successful response
@@ -20865,12 +20874,29 @@ paths:
20865
20874
  type: array
20866
20875
  items:
20867
20876
  type: string
20877
+ category:
20878
+ description:
20879
+ Marketplace category slug (Skills taxonomy); null when origin/category is unknown, e.g. non-marketplace
20880
+ installs.
20881
+ anyOf:
20882
+ - type: string
20883
+ - type: "null"
20868
20884
  required:
20869
20885
  - id
20870
20886
  - name
20871
20887
  - description
20872
20888
  - version
20873
20889
  additionalProperties: false
20890
+ categoryCounts:
20891
+ description: Installed plugins per category (before the category filter is applied).
20892
+ type: object
20893
+ propertyNames:
20894
+ type: string
20895
+ additionalProperties:
20896
+ type: number
20897
+ totalCount:
20898
+ description: Total installed plugins matching non-category filters.
20899
+ type: number
20874
20900
  required:
20875
20901
  - plugins
20876
20902
  additionalProperties: false
@@ -21731,6 +21757,11 @@ paths:
21731
21757
  description:
21732
21758
  description: Short description, when known (external entries only today).
21733
21759
  type: string
21760
+ category:
21761
+ anyOf:
21762
+ - type: string
21763
+ - type: "null"
21764
+ description: Marketplace category slug (Skills taxonomy); null when the entry declares none.
21734
21765
  source:
21735
21766
  type: object
21736
21767
  properties:
@@ -21755,6 +21786,7 @@ paths:
21755
21786
  required:
21756
21787
  - name
21757
21788
  - path
21789
+ - category
21758
21790
  - source
21759
21791
  additionalProperties: false
21760
21792
  description: Directory matches, sorted alphabetically by name.
@@ -22668,7 +22700,7 @@ paths:
22668
22700
  post:
22669
22701
  operationId: schedules_post
22670
22702
  summary: Create schedule
22671
- description: Create a new recurring schedule. Currently restricted to mode='execute'.
22703
+ description: Create a new recurring schedule (execute, script, or workflow mode).
22672
22704
  tags:
22673
22705
  - schedules
22674
22706
  requestBody:
@@ -22702,12 +22734,20 @@ paths:
22702
22734
  description: Whether the schedule starts active (default true)
22703
22735
  mode:
22704
22736
  type: string
22705
- description: "'execute' (default) or 'workflow' (flag-gated)"
22737
+ description: "'execute' (default), 'script', or 'workflow' (flag-gated)"
22706
22738
  workflowName:
22707
22739
  type: string
22708
22740
  description: Saved workflow to trigger (required for workflow mode)
22709
22741
  workflowArgs:
22710
22742
  description: Args passed to the workflow run (workflow mode)
22743
+ script:
22744
+ type: string
22745
+ description: Shell command run on each fire (required for script mode)
22746
+ timeoutMs:
22747
+ anyOf:
22748
+ - type: number
22749
+ - type: "null"
22750
+ description: Script execution timeout override in ms (script mode)
22711
22751
  inferenceProfile:
22712
22752
  anyOf:
22713
22753
  - type: string
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.10.3-dev.202606301940.da6000c",
3
+ "version": "0.10.3-dev.202606302138.0f63073",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -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 `temperature`,
3249
- // `top_p`, and `top_k` with a 400; the provider must strip all three.
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
+ });
@@ -400,6 +400,39 @@ describe("OpenAIProvider", () => {
400
400
  expect(events[1]).toEqual({ type: "text_delta", text: ", world!" });
401
401
  });
402
402
 
403
+ test("fires tool preview and argument progress events while tool calls stream", async () => {
404
+ fakeChunks = [
405
+ ...toolCallChunks([
406
+ {
407
+ id: "call_write",
408
+ name: "app_create",
409
+ args: '{"source_files":[{"path":"src/App.tsx","content":"hello"}]}',
410
+ },
411
+ ]),
412
+ usageChunk(10, 15),
413
+ ];
414
+
415
+ const events: ProviderEvent[] = [];
416
+ await provider.sendMessage([userMsg("Create an app")], {
417
+ onEvent: (e) => events.push(e),
418
+ });
419
+
420
+ expect(events.slice(0, 2)).toEqual([
421
+ {
422
+ type: "tool_use_preview_start",
423
+ toolUseId: "call_write",
424
+ toolName: "app_create",
425
+ },
426
+ {
427
+ type: "input_json_delta",
428
+ toolUseId: "call_write",
429
+ toolName: "app_create",
430
+ accumulatedJson:
431
+ '{"source_files":[{"path":"src/App.tsx","content":"hello"}]}',
432
+ },
433
+ ]);
434
+ });
435
+
403
436
  // -----------------------------------------------------------------------
404
437
  // Reasoning content (MiniMax / DeepSeek extension)
405
438
  // -----------------------------------------------------------------------
@@ -89,6 +89,10 @@ function textDeltaEvent(delta: string): FakeStreamEvent {
89
89
  return { type: "response.output_text.delta", delta };
90
90
  }
91
91
 
92
+ function userMsg(text: string): Message {
93
+ return { role: "user", content: [{ type: "text", text }] };
94
+ }
95
+
92
96
  function functionCallAddedEvent(
93
97
  callId: string,
94
98
  name: string,
@@ -395,6 +399,42 @@ describe("OpenAIResponsesProvider", () => {
395
399
  });
396
400
  });
397
401
 
402
+ test("fires tool preview and argument progress events while tool calls stream", async () => {
403
+ fakeStreamEvents = [
404
+ functionCallAddedEvent("call_write", "app_create"),
405
+ functionCallArgsDeltaEvent(
406
+ '{"source_files":[{"path":"src/App.tsx","content":"hello"}]}',
407
+ "call_write",
408
+ ),
409
+ functionCallArgsDoneEvent(
410
+ "call_write",
411
+ "app_create",
412
+ '{"source_files":[{"path":"src/App.tsx","content":"hello"}]}',
413
+ ),
414
+ completedEvent(10, 15),
415
+ ];
416
+
417
+ const events: ProviderEvent[] = [];
418
+ await provider.sendMessage([userMsg("Create an app")], {
419
+ onEvent: (e) => events.push(e),
420
+ });
421
+
422
+ expect(events.slice(0, 2)).toEqual([
423
+ {
424
+ type: "tool_use_preview_start",
425
+ toolUseId: "call_write",
426
+ toolName: "app_create",
427
+ },
428
+ {
429
+ type: "input_json_delta",
430
+ toolUseId: "call_write",
431
+ toolName: "app_create",
432
+ accumulatedJson:
433
+ '{"source_files":[{"path":"src/App.tsx","content":"hello"}]}',
434
+ },
435
+ ]);
436
+ });
437
+
398
438
  // -----------------------------------------------------------------------
399
439
  // Mixed text + tool calls
400
440
  // -----------------------------------------------------------------------
@@ -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("Only 'execute' and 'workflow' modes are supported");
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", () => {