stagent 0.9.5 → 0.10.0

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 (277) hide show
  1. package/README.md +5 -42
  2. package/dist/cli.js +42 -18
  3. package/docs/.coverage-gaps.json +13 -55
  4. package/docs/.last-generated +1 -1
  5. package/docs/features/provider-runtimes.md +4 -0
  6. package/docs/features/schedules.md +32 -4
  7. package/docs/features/settings.md +28 -5
  8. package/docs/features/tables.md +9 -2
  9. package/docs/features/workflows.md +10 -4
  10. package/docs/journeys/developer.md +15 -1
  11. package/docs/journeys/personal-use.md +21 -4
  12. package/docs/superpowers/plans/2026-04-07-instance-bootstrap.md +1691 -0
  13. package/docs/superpowers/plans/2026-04-08-schedule-orchestration.md +2983 -0
  14. package/docs/superpowers/plans/2026-04-11-schedule-maxturns-api-control.md +551 -0
  15. package/docs/superpowers/plans/2026-04-11-task-create-profile-validation.md +864 -0
  16. package/docs/superpowers/plans/2026-04-11-task-runtime-stagent-mcp-injection.md +739 -0
  17. package/docs/superpowers/specs/2026-04-08-chat-sse-resilience-hotfix-design.md +201 -0
  18. package/docs/superpowers/specs/2026-04-08-schedule-orchestration-design.md +371 -0
  19. package/docs/superpowers/specs/2026-04-08-swarm-visibility-design.md +213 -0
  20. package/package.json +3 -2
  21. package/src/__tests__/instrumentation-smoke.test.ts +15 -0
  22. package/src/app/analytics/page.tsx +1 -21
  23. package/src/app/api/chat/conversations/[id]/messages/route.ts +22 -1
  24. package/src/app/api/diagnostics/chat-streams/route.ts +65 -0
  25. package/src/app/api/instance/config/route.ts +41 -0
  26. package/src/app/api/instance/init/route.ts +34 -0
  27. package/src/app/api/instance/upgrade/check/route.ts +26 -0
  28. package/src/app/api/instance/upgrade/route.ts +96 -0
  29. package/src/app/api/instance/upgrade/status/route.ts +35 -0
  30. package/src/app/api/memory/route.ts +0 -11
  31. package/src/app/api/notifications/route.ts +4 -2
  32. package/src/app/api/projects/[id]/route.ts +5 -155
  33. package/src/app/api/projects/__tests__/delete-project.test.ts +10 -19
  34. package/src/app/api/schedules/[id]/execute/route.ts +111 -0
  35. package/src/app/api/schedules/[id]/route.ts +9 -1
  36. package/src/app/api/schedules/__tests__/execute-route.test.ts +118 -0
  37. package/src/app/api/schedules/route.ts +3 -12
  38. package/src/app/api/settings/openai/login/route.ts +22 -0
  39. package/src/app/api/settings/openai/logout/route.ts +7 -0
  40. package/src/app/api/settings/openai/route.ts +21 -1
  41. package/src/app/api/settings/providers/route.ts +35 -8
  42. package/src/app/api/tables/[id]/enrich/__tests__/route.test.ts +153 -0
  43. package/src/app/api/tables/[id]/enrich/plan/route.ts +98 -0
  44. package/src/app/api/tables/[id]/enrich/route.ts +147 -0
  45. package/src/app/api/tables/[id]/enrich/runs/route.ts +25 -0
  46. package/src/app/api/tasks/[id]/execute/route.ts +0 -21
  47. package/src/app/api/workflows/[id]/resume/route.ts +59 -0
  48. package/src/app/api/workflows/[id]/status/route.ts +22 -8
  49. package/src/app/api/workspace/context/route.ts +2 -0
  50. package/src/app/api/workspace/fix-data-dir/route.ts +81 -0
  51. package/src/app/chat/page.tsx +11 -0
  52. package/src/app/inbox/page.tsx +12 -5
  53. package/src/app/layout.tsx +42 -21
  54. package/src/app/page.tsx +0 -2
  55. package/src/app/settings/page.tsx +6 -9
  56. package/src/components/chat/__tests__/chat-session-provider.test.tsx +408 -0
  57. package/src/components/chat/chat-command-popover.tsx +2 -2
  58. package/src/components/chat/chat-input.tsx +2 -3
  59. package/src/components/chat/chat-session-provider.tsx +720 -0
  60. package/src/components/chat/chat-shell.tsx +92 -401
  61. package/src/components/instance/__tests__/instance-section.test.tsx +125 -0
  62. package/src/components/instance/instance-section.tsx +382 -0
  63. package/src/components/instance/upgrade-badge.tsx +219 -0
  64. package/src/components/notifications/__tests__/batch-proposal-review.test.tsx +95 -0
  65. package/src/components/notifications/__tests__/notification-item.test.tsx +106 -0
  66. package/src/components/notifications/batch-proposal-review.tsx +20 -5
  67. package/src/components/notifications/inbox-list.tsx +11 -2
  68. package/src/components/notifications/notification-item.tsx +56 -2
  69. package/src/components/notifications/pending-approval-host.tsx +56 -37
  70. package/src/components/schedules/schedule-create-sheet.tsx +19 -1
  71. package/src/components/schedules/schedule-edit-sheet.tsx +20 -1
  72. package/src/components/schedules/schedule-form.tsx +31 -0
  73. package/src/components/settings/__tests__/providers-runtimes-section.test.tsx +149 -0
  74. package/src/components/settings/auth-method-selector.tsx +19 -4
  75. package/src/components/settings/auth-status-badge.tsx +28 -3
  76. package/src/components/settings/openai-chatgpt-auth-control.tsx +278 -0
  77. package/src/components/settings/openai-runtime-section.tsx +7 -1
  78. package/src/components/settings/providers-runtimes-section.tsx +138 -19
  79. package/src/components/shared/app-sidebar.tsx +4 -3
  80. package/src/components/shared/command-palette.tsx +4 -5
  81. package/src/components/shared/theme-toggle.tsx +5 -24
  82. package/src/components/shared/workspace-indicator.tsx +61 -2
  83. package/src/components/tables/__tests__/table-enrichment-sheet.test.tsx +130 -0
  84. package/src/components/tables/table-create-sheet.tsx +4 -0
  85. package/src/components/tables/table-enrichment-runs.tsx +103 -0
  86. package/src/components/tables/table-enrichment-sheet.tsx +538 -0
  87. package/src/components/tables/table-spreadsheet.tsx +29 -5
  88. package/src/components/tables/table-toolbar.tsx +10 -1
  89. package/src/components/tasks/kanban-board.tsx +1 -0
  90. package/src/components/tasks/kanban-column.tsx +53 -14
  91. package/src/components/tasks/task-bento-grid.tsx +19 -0
  92. package/src/components/tasks/task-card.tsx +26 -3
  93. package/src/components/tasks/task-chip-bar.tsx +24 -0
  94. package/src/components/tasks/task-result-renderer.tsx +1 -1
  95. package/src/components/workflows/delay-step-body.tsx +109 -0
  96. package/src/components/workflows/hooks/use-workflow-status.ts +50 -0
  97. package/src/components/workflows/loop-status-view.tsx +1 -1
  98. package/src/components/workflows/shared/step-result.tsx +78 -0
  99. package/src/components/workflows/shared/workflow-header.tsx +141 -0
  100. package/src/components/workflows/shared/workflow-loading-skeleton.tsx +36 -0
  101. package/src/components/workflows/swarm-dashboard.tsx +2 -15
  102. package/src/components/workflows/views/loop-pattern-view.tsx +137 -0
  103. package/src/components/workflows/views/sequence-pattern-view.tsx +511 -0
  104. package/src/components/workflows/workflow-form-view.tsx +133 -16
  105. package/src/components/workflows/workflow-status-view.tsx +30 -740
  106. package/src/instrumentation-node.ts +94 -0
  107. package/src/instrumentation.ts +4 -48
  108. package/src/lib/agents/__tests__/claude-agent.test.ts +199 -0
  109. package/src/lib/agents/__tests__/execution-manager.test.ts +1 -27
  110. package/src/lib/agents/__tests__/failure-reason.test.ts +68 -0
  111. package/src/lib/agents/__tests__/learned-context.test.ts +0 -11
  112. package/src/lib/agents/__tests__/learning-session.test.ts +158 -0
  113. package/src/lib/agents/__tests__/pattern-extractor.test.ts +48 -0
  114. package/src/lib/agents/claude-agent.ts +155 -18
  115. package/src/lib/agents/execution-manager.ts +0 -35
  116. package/src/lib/agents/learned-context.ts +0 -12
  117. package/src/lib/agents/learning-session.ts +18 -5
  118. package/src/lib/agents/profiles/__tests__/registry.test.ts +6 -4
  119. package/src/lib/agents/profiles/builtins/upgrade-assistant/SKILL.md +70 -0
  120. package/src/lib/agents/profiles/builtins/upgrade-assistant/profile.yaml +32 -0
  121. package/src/lib/agents/runtime/__tests__/openai-codex-auth.test.ts +118 -0
  122. package/src/lib/agents/runtime/codex-app-server-client.ts +11 -5
  123. package/src/lib/agents/runtime/openai-codex-auth.ts +389 -0
  124. package/src/lib/agents/runtime/openai-codex.ts +29 -60
  125. package/src/lib/agents/runtime/types.ts +8 -0
  126. package/src/lib/book/chapter-mapping.ts +11 -0
  127. package/src/lib/book/content.ts +10 -0
  128. package/src/lib/chat/__tests__/active-streams.test.ts +49 -0
  129. package/src/lib/chat/__tests__/finalize-safety-net.test.ts +139 -0
  130. package/src/lib/chat/__tests__/reconcile.test.ts +137 -0
  131. package/src/lib/chat/__tests__/stream-telemetry.test.ts +151 -0
  132. package/src/lib/chat/active-streams.ts +27 -0
  133. package/src/lib/chat/codex-engine.ts +16 -17
  134. package/src/lib/chat/context-builder.ts +5 -3
  135. package/src/lib/chat/engine.ts +50 -3
  136. package/src/lib/chat/reconcile.ts +117 -0
  137. package/src/lib/chat/stagent-tools.ts +1 -0
  138. package/src/lib/chat/stream-telemetry.ts +132 -0
  139. package/src/lib/chat/suggested-prompts.ts +28 -1
  140. package/src/lib/chat/system-prompt.ts +26 -1
  141. package/src/lib/chat/tool-catalog.ts +2 -1
  142. package/src/lib/chat/tools/__tests__/enrich-table-tool.test.ts +127 -0
  143. package/src/lib/chat/tools/__tests__/schedule-tools.test.ts +261 -0
  144. package/src/lib/chat/tools/__tests__/task-tools.test.ts +352 -0
  145. package/src/lib/chat/tools/__tests__/workflow-tools-dedup.test.ts +217 -0
  146. package/src/lib/chat/tools/document-tools.ts +29 -13
  147. package/src/lib/chat/tools/helpers.ts +39 -0
  148. package/src/lib/chat/tools/notification-tools.ts +9 -5
  149. package/src/lib/chat/tools/project-tools.ts +33 -0
  150. package/src/lib/chat/tools/schedule-tools.ts +44 -11
  151. package/src/lib/chat/tools/table-tools.ts +71 -0
  152. package/src/lib/chat/tools/task-tools.ts +84 -20
  153. package/src/lib/chat/tools/workflow-tools.ts +234 -32
  154. package/src/lib/constants/settings.ts +8 -18
  155. package/src/lib/data/__tests__/clear.test.ts +56 -2
  156. package/src/lib/data/clear.ts +20 -15
  157. package/src/lib/data/delete-project.ts +171 -0
  158. package/src/lib/db/__tests__/bootstrap.test.ts +1 -1
  159. package/src/lib/db/bootstrap.ts +45 -16
  160. package/src/lib/db/index.ts +5 -0
  161. package/src/lib/db/migrations/0009_add_app_instances.sql +25 -0
  162. package/src/lib/db/migrations/0024_add_workflow_resume_at.sql +10 -0
  163. package/src/lib/db/migrations/0025_drop_app_instances.sql +3 -0
  164. package/src/lib/db/migrations/0026_drop_license.sql +3 -0
  165. package/src/lib/db/migrations/meta/_journal.json +21 -0
  166. package/src/lib/db/schema.ts +68 -23
  167. package/src/lib/environment/workspace-context.ts +13 -1
  168. package/src/lib/import/dedup.ts +4 -54
  169. package/src/lib/instance/__tests__/bootstrap.test.ts +362 -0
  170. package/src/lib/instance/__tests__/detect.test.ts +115 -0
  171. package/src/lib/instance/__tests__/fingerprint.test.ts +48 -0
  172. package/src/lib/instance/__tests__/git-ops.test.ts +95 -0
  173. package/src/lib/instance/__tests__/settings.test.ts +83 -0
  174. package/src/lib/instance/__tests__/upgrade-poller.test.ts +131 -0
  175. package/src/lib/instance/bootstrap.ts +270 -0
  176. package/src/lib/instance/detect.ts +49 -0
  177. package/src/lib/instance/fingerprint.ts +78 -0
  178. package/src/lib/instance/git-ops.ts +95 -0
  179. package/src/lib/instance/settings.ts +61 -0
  180. package/src/lib/instance/types.ts +77 -0
  181. package/src/lib/instance/upgrade-poller.ts +153 -0
  182. package/src/lib/notifications/__tests__/visibility.test.ts +51 -0
  183. package/src/lib/notifications/visibility.ts +33 -0
  184. package/src/lib/schedules/__tests__/collision-check.test.ts +93 -0
  185. package/src/lib/schedules/__tests__/config.test.ts +62 -0
  186. package/src/lib/schedules/__tests__/firing-metrics.test.ts +99 -0
  187. package/src/lib/schedules/__tests__/integration.test.ts +82 -0
  188. package/src/lib/schedules/__tests__/slot-claim.test.ts +242 -0
  189. package/src/lib/schedules/__tests__/tick-scheduler.test.ts +102 -0
  190. package/src/lib/schedules/__tests__/turn-budget.test.ts +228 -0
  191. package/src/lib/schedules/collision-check.ts +105 -0
  192. package/src/lib/schedules/config.ts +53 -0
  193. package/src/lib/schedules/scheduler.ts +232 -13
  194. package/src/lib/schedules/slot-claim.ts +105 -0
  195. package/src/lib/settings/__tests__/openai-auth.test.ts +101 -0
  196. package/src/lib/settings/__tests__/openai-login-manager.test.ts +64 -0
  197. package/src/lib/settings/__tests__/runtime-setup.test.ts +33 -0
  198. package/src/lib/settings/openai-auth.ts +105 -10
  199. package/src/lib/settings/openai-login-manager.ts +260 -0
  200. package/src/lib/settings/runtime-setup.ts +14 -4
  201. package/src/lib/tables/__tests__/enrichment-planner.test.ts +124 -0
  202. package/src/lib/tables/__tests__/enrichment.test.ts +147 -0
  203. package/src/lib/tables/enrichment-planner.ts +454 -0
  204. package/src/lib/tables/enrichment.ts +328 -0
  205. package/src/lib/tables/query-builder.ts +5 -2
  206. package/src/lib/tables/trigger-evaluator.ts +3 -2
  207. package/src/lib/theme.ts +71 -0
  208. package/src/lib/usage/ledger.ts +2 -18
  209. package/src/lib/util/__tests__/similarity.test.ts +106 -0
  210. package/src/lib/util/similarity.ts +77 -0
  211. package/src/lib/utils/format-timestamp.ts +24 -0
  212. package/src/lib/utils/stagent-paths.ts +12 -0
  213. package/src/lib/validators/__tests__/blueprint.test.ts +172 -0
  214. package/src/lib/validators/__tests__/settings.test.ts +10 -0
  215. package/src/lib/validators/blueprint.ts +70 -9
  216. package/src/lib/validators/profile.ts +2 -2
  217. package/src/lib/validators/settings.ts +3 -1
  218. package/src/lib/workflows/__tests__/delay.test.ts +196 -0
  219. package/src/lib/workflows/__tests__/engine.test.ts +8 -0
  220. package/src/lib/workflows/__tests__/loop-executor.test.ts +54 -0
  221. package/src/lib/workflows/__tests__/post-action.test.ts +108 -0
  222. package/src/lib/workflows/blueprints/instantiator.ts +22 -1
  223. package/src/lib/workflows/blueprints/types.ts +10 -2
  224. package/src/lib/workflows/delay.ts +106 -0
  225. package/src/lib/workflows/engine.ts +207 -4
  226. package/src/lib/workflows/loop-executor.ts +349 -24
  227. package/src/lib/workflows/post-action.ts +91 -0
  228. package/src/lib/workflows/types.ts +166 -1
  229. package/src/app/api/license/checkout/route.ts +0 -28
  230. package/src/app/api/license/portal/route.ts +0 -26
  231. package/src/app/api/license/route.ts +0 -89
  232. package/src/app/api/license/usage/route.ts +0 -63
  233. package/src/app/api/marketplace/browse/route.ts +0 -15
  234. package/src/app/api/marketplace/import/route.ts +0 -28
  235. package/src/app/api/marketplace/publish/route.ts +0 -40
  236. package/src/app/api/onboarding/email/route.ts +0 -53
  237. package/src/app/api/settings/telemetry/route.ts +0 -14
  238. package/src/app/api/sync/export/route.ts +0 -54
  239. package/src/app/api/sync/restore/route.ts +0 -37
  240. package/src/app/api/sync/sessions/route.ts +0 -24
  241. package/src/app/auth/callback/route.ts +0 -73
  242. package/src/app/marketplace/page.tsx +0 -19
  243. package/src/components/analytics/analytics-gate-card.tsx +0 -101
  244. package/src/components/marketplace/blueprint-card.tsx +0 -61
  245. package/src/components/marketplace/marketplace-browser.tsx +0 -131
  246. package/src/components/onboarding/email-capture-card.tsx +0 -104
  247. package/src/components/settings/activation-form.tsx +0 -95
  248. package/src/components/settings/cloud-account-section.tsx +0 -147
  249. package/src/components/settings/cloud-sync-section.tsx +0 -155
  250. package/src/components/settings/subscription-section.tsx +0 -410
  251. package/src/components/settings/telemetry-section.tsx +0 -80
  252. package/src/components/shared/premium-gate-overlay.tsx +0 -50
  253. package/src/components/shared/schedule-gate-dialog.tsx +0 -64
  254. package/src/components/shared/upgrade-banner.tsx +0 -112
  255. package/src/hooks/use-supabase-auth.ts +0 -79
  256. package/src/lib/billing/email.ts +0 -54
  257. package/src/lib/billing/products.ts +0 -80
  258. package/src/lib/billing/stripe.ts +0 -101
  259. package/src/lib/cloud/supabase-browser.ts +0 -32
  260. package/src/lib/cloud/supabase-client.ts +0 -56
  261. package/src/lib/license/__tests__/features.test.ts +0 -56
  262. package/src/lib/license/__tests__/key-format.test.ts +0 -88
  263. package/src/lib/license/__tests__/manager.test.ts +0 -64
  264. package/src/lib/license/__tests__/tier-limits.test.ts +0 -79
  265. package/src/lib/license/cloud-validation.ts +0 -60
  266. package/src/lib/license/features.ts +0 -44
  267. package/src/lib/license/key-format.ts +0 -101
  268. package/src/lib/license/limit-check.ts +0 -111
  269. package/src/lib/license/limit-queries.ts +0 -51
  270. package/src/lib/license/manager.ts +0 -345
  271. package/src/lib/license/notifications.ts +0 -59
  272. package/src/lib/license/tier-limits.ts +0 -71
  273. package/src/lib/marketplace/marketplace-client.ts +0 -107
  274. package/src/lib/sync/cloud-sync.ts +0 -235
  275. package/src/lib/telemetry/conversion-events.ts +0 -71
  276. package/src/lib/telemetry/queue.ts +0 -122
  277. package/src/lib/validators/license.ts +0 -33
@@ -0,0 +1,261 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import { z } from "zod";
3
+
4
+ interface ScheduleRow {
5
+ id: string;
6
+ maxTurns: number | null;
7
+ [key: string]: unknown;
8
+ }
9
+
10
+ const { mockState } = vi.hoisted(() => ({
11
+ mockState: {
12
+ rows: [] as ScheduleRow[],
13
+ lastInsertValues: null as Record<string, unknown> | null,
14
+ lastUpdateValues: null as Record<string, unknown> | null,
15
+ },
16
+ }));
17
+
18
+ vi.mock("@/lib/db", () => {
19
+ const selectBuilder = {
20
+ from() { return this; },
21
+ where() { return this; },
22
+ orderBy() { return this; },
23
+ limit() { return this; },
24
+ get() { return Promise.resolve(mockState.rows[0]); },
25
+ then<TResolve>(resolve: (rows: ScheduleRow[]) => TResolve) {
26
+ return Promise.resolve(mockState.rows).then(resolve);
27
+ },
28
+ };
29
+ return {
30
+ db: {
31
+ select: () => selectBuilder,
32
+ insert: () => ({
33
+ values: (v: Record<string, unknown>) => {
34
+ mockState.lastInsertValues = v;
35
+ mockState.rows = [{ id: "sched-1", maxTurns: null, ...v } as ScheduleRow];
36
+ return Promise.resolve();
37
+ },
38
+ }),
39
+ update: () => ({
40
+ set: (v: Record<string, unknown>) => {
41
+ mockState.lastUpdateValues = v;
42
+ mockState.rows[0] = { ...mockState.rows[0], ...v } as ScheduleRow;
43
+ return { where: () => Promise.resolve() };
44
+ },
45
+ }),
46
+ delete: () => ({ where: () => Promise.resolve() }),
47
+ },
48
+ };
49
+ });
50
+
51
+ vi.mock("@/lib/db/schema", () => ({
52
+ schedules: {
53
+ id: "id",
54
+ status: "status",
55
+ projectId: "projectId",
56
+ updatedAt: "updatedAt",
57
+ cronExpression: "cronExpression",
58
+ },
59
+ }));
60
+
61
+ vi.mock("drizzle-orm", () => ({
62
+ eq: () => ({}),
63
+ and: () => ({}),
64
+ desc: () => ({}),
65
+ like: () => ({}),
66
+ }));
67
+
68
+ vi.mock("@/lib/schedules/interval-parser", () => ({
69
+ parseInterval: () => "*/30 * * * *",
70
+ computeNextFireTime: () => new Date("2026-04-11T10:00:00Z"),
71
+ computeStaggeredCron: (cron: string) => ({
72
+ cronExpression: cron,
73
+ offsetApplied: 0,
74
+ collided: false,
75
+ }),
76
+ }));
77
+
78
+ vi.mock("@/lib/schedules/nlp-parser", () => ({
79
+ parseNaturalLanguage: () => null,
80
+ }));
81
+
82
+ vi.mock("@/lib/schedules/prompt-analyzer", () => ({
83
+ analyzePromptEfficiency: () => [],
84
+ }));
85
+
86
+ import { scheduleTools } from "../schedule-tools";
87
+
88
+ function getTool(name: string) {
89
+ const tools = scheduleTools({ projectId: "proj-1" } as never);
90
+ const tool = tools.find((t) => t.name === name);
91
+ if (!tool) throw new Error(`Tool not found: ${name}`);
92
+ return tool;
93
+ }
94
+
95
+ function parseArgs(toolName: string, args: unknown) {
96
+ const tool = getTool(toolName);
97
+ return z.object(tool.zodShape).safeParse(args);
98
+ }
99
+
100
+ beforeEach(() => {
101
+ mockState.rows = [];
102
+ mockState.lastInsertValues = null;
103
+ mockState.lastUpdateValues = null;
104
+ });
105
+
106
+ describe("create_schedule maxTurns Zod validation", () => {
107
+ const base = {
108
+ name: "test",
109
+ prompt: "hello",
110
+ interval: "every 30 minutes",
111
+ };
112
+
113
+ it("accepts a valid maxTurns value", () => {
114
+ const result = parseArgs("create_schedule", { ...base, maxTurns: 50 });
115
+ expect(result.success).toBe(true);
116
+ });
117
+
118
+ it("accepts omitted maxTurns (inherit default)", () => {
119
+ const result = parseArgs("create_schedule", base);
120
+ expect(result.success).toBe(true);
121
+ });
122
+
123
+ it("rejects maxTurns below 10", () => {
124
+ const result = parseArgs("create_schedule", { ...base, maxTurns: 9 });
125
+ expect(result.success).toBe(false);
126
+ });
127
+
128
+ it("rejects maxTurns above 500", () => {
129
+ const result = parseArgs("create_schedule", { ...base, maxTurns: 501 });
130
+ expect(result.success).toBe(false);
131
+ });
132
+
133
+ it("rejects non-integer maxTurns", () => {
134
+ const result = parseArgs("create_schedule", { ...base, maxTurns: 50.5 });
135
+ expect(result.success).toBe(false);
136
+ });
137
+
138
+ it("rejects explicit null on create (only update supports clear-to-null)", () => {
139
+ const result = parseArgs("create_schedule", { ...base, maxTurns: null });
140
+ expect(result.success).toBe(false);
141
+ });
142
+ });
143
+
144
+ describe("update_schedule maxTurns Zod validation", () => {
145
+ const base = { scheduleId: "sched-1" };
146
+
147
+ it("accepts a valid maxTurns value", () => {
148
+ const result = parseArgs("update_schedule", { ...base, maxTurns: 100 });
149
+ expect(result.success).toBe(true);
150
+ });
151
+
152
+ it("accepts explicit null to clear an override", () => {
153
+ const result = parseArgs("update_schedule", { ...base, maxTurns: null });
154
+ expect(result.success).toBe(true);
155
+ });
156
+
157
+ it("accepts omitted maxTurns (unchanged)", () => {
158
+ const result = parseArgs("update_schedule", base);
159
+ expect(result.success).toBe(true);
160
+ });
161
+
162
+ it("rejects out-of-range maxTurns on update", () => {
163
+ const result = parseArgs("update_schedule", { ...base, maxTurns: 9 });
164
+ expect(result.success).toBe(false);
165
+ });
166
+ });
167
+
168
+ describe("create_schedule maxTurns persistence", () => {
169
+ it("writes maxTurns to the insert payload when provided", async () => {
170
+ const tool = getTool("create_schedule");
171
+ await tool.handler({
172
+ name: "test",
173
+ prompt: "hello",
174
+ interval: "every 30 minutes",
175
+ maxTurns: 75,
176
+ });
177
+ expect(mockState.lastInsertValues).not.toBeNull();
178
+ expect(mockState.lastInsertValues?.maxTurns).toBe(75);
179
+ });
180
+
181
+ it("writes null to maxTurns when omitted (inherit default)", async () => {
182
+ const tool = getTool("create_schedule");
183
+ await tool.handler({
184
+ name: "test",
185
+ prompt: "hello",
186
+ interval: "every 30 minutes",
187
+ });
188
+ expect(mockState.lastInsertValues?.maxTurns).toBe(null);
189
+ });
190
+
191
+ it("sets maxTurnsSetAt to a Date when maxTurns is provided", async () => {
192
+ const tool = getTool("create_schedule");
193
+ await tool.handler({
194
+ name: "test",
195
+ prompt: "hello",
196
+ interval: "every 30 minutes",
197
+ maxTurns: 75,
198
+ });
199
+ expect(mockState.lastInsertValues?.maxTurnsSetAt).toBeInstanceOf(Date);
200
+ });
201
+
202
+ it("sets maxTurnsSetAt to null when maxTurns is omitted", async () => {
203
+ const tool = getTool("create_schedule");
204
+ await tool.handler({
205
+ name: "test",
206
+ prompt: "hello",
207
+ interval: "every 30 minutes",
208
+ });
209
+ expect(mockState.lastInsertValues?.maxTurnsSetAt).toBe(null);
210
+ });
211
+ });
212
+
213
+ describe("update_schedule maxTurns persistence", () => {
214
+ beforeEach(() => {
215
+ mockState.rows = [{
216
+ id: "sched-1",
217
+ name: "existing",
218
+ status: "active",
219
+ maxTurns: 50,
220
+ } as ScheduleRow];
221
+ });
222
+
223
+ it("writes the new maxTurns value when provided", async () => {
224
+ const tool = getTool("update_schedule");
225
+ await tool.handler({ scheduleId: "sched-1", maxTurns: 120 });
226
+ expect(mockState.lastUpdateValues?.maxTurns).toBe(120);
227
+ });
228
+
229
+ it("writes null when explicitly clearing the override", async () => {
230
+ const tool = getTool("update_schedule");
231
+ await tool.handler({ scheduleId: "sched-1", maxTurns: null });
232
+ expect(mockState.lastUpdateValues).not.toBeNull();
233
+ expect("maxTurns" in (mockState.lastUpdateValues ?? {})).toBe(true);
234
+ expect(mockState.lastUpdateValues?.maxTurns).toBe(null);
235
+ });
236
+
237
+ it("does not touch maxTurns when the field is omitted", async () => {
238
+ const tool = getTool("update_schedule");
239
+ await tool.handler({ scheduleId: "sched-1", name: "renamed" });
240
+ expect("maxTurns" in (mockState.lastUpdateValues ?? {})).toBe(false);
241
+ });
242
+
243
+ it("sets maxTurnsSetAt to a Date when maxTurns is set to a number", async () => {
244
+ const tool = getTool("update_schedule");
245
+ await tool.handler({ scheduleId: "sched-1", maxTurns: 120 });
246
+ expect(mockState.lastUpdateValues?.maxTurnsSetAt).toBeInstanceOf(Date);
247
+ });
248
+
249
+ it("sets maxTurnsSetAt to null when maxTurns is cleared", async () => {
250
+ const tool = getTool("update_schedule");
251
+ await tool.handler({ scheduleId: "sched-1", maxTurns: null });
252
+ expect("maxTurnsSetAt" in (mockState.lastUpdateValues ?? {})).toBe(true);
253
+ expect(mockState.lastUpdateValues?.maxTurnsSetAt).toBe(null);
254
+ });
255
+
256
+ it("does not touch maxTurnsSetAt when maxTurns is omitted", async () => {
257
+ const tool = getTool("update_schedule");
258
+ await tool.handler({ scheduleId: "sched-1", name: "renamed" });
259
+ expect("maxTurnsSetAt" in (mockState.lastUpdateValues ?? {})).toBe(false);
260
+ });
261
+ });
@@ -0,0 +1,352 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import { z } from "zod";
3
+
4
+ interface TaskRow {
5
+ id: string;
6
+ title: string;
7
+ status: string;
8
+ projectId: string | null;
9
+ agentProfile: string | null;
10
+ assignedAgent: string | null;
11
+ [key: string]: unknown;
12
+ }
13
+
14
+ const { mockState } = vi.hoisted(() => ({
15
+ mockState: {
16
+ rows: [] as TaskRow[],
17
+ lastInsertValues: null as Record<string, unknown> | null,
18
+ lastUpdateValues: null as Record<string, unknown> | null,
19
+ },
20
+ }));
21
+
22
+ vi.mock("@/lib/db", () => {
23
+ const selectBuilder = {
24
+ from() { return this; },
25
+ where() { return this; },
26
+ orderBy() { return this; },
27
+ limit() {
28
+ return Promise.resolve(mockState.rows);
29
+ },
30
+ get() { return Promise.resolve(mockState.rows[0]); },
31
+ then<TResolve>(resolve: (rows: TaskRow[]) => TResolve) {
32
+ return Promise.resolve(mockState.rows).then(resolve);
33
+ },
34
+ };
35
+ return {
36
+ db: {
37
+ select: () => selectBuilder,
38
+ insert: () => ({
39
+ values: (v: Record<string, unknown>) => {
40
+ mockState.lastInsertValues = v;
41
+ mockState.rows = [{
42
+ id: "task-1",
43
+ title: "",
44
+ status: "planned",
45
+ projectId: null,
46
+ agentProfile: null,
47
+ assignedAgent: null,
48
+ ...v,
49
+ } as TaskRow];
50
+ return Promise.resolve();
51
+ },
52
+ }),
53
+ update: () => ({
54
+ set: (v: Record<string, unknown>) => {
55
+ mockState.lastUpdateValues = v;
56
+ if (mockState.rows[0]) {
57
+ mockState.rows[0] = { ...mockState.rows[0], ...v } as TaskRow;
58
+ }
59
+ return { where: () => Promise.resolve() };
60
+ },
61
+ }),
62
+ },
63
+ };
64
+ });
65
+
66
+ vi.mock("@/lib/db/schema", () => ({
67
+ tasks: {
68
+ id: "id",
69
+ projectId: "projectId",
70
+ status: "status",
71
+ priority: "priority",
72
+ createdAt: "createdAt",
73
+ },
74
+ }));
75
+
76
+ vi.mock("drizzle-orm", () => ({
77
+ eq: () => ({}),
78
+ and: () => ({}),
79
+ desc: () => ({}),
80
+ like: () => ({}),
81
+ }));
82
+
83
+ // Mock the profile registry: accept "general" and "code-reviewer"
84
+ // and "researcher", reject everything else.
85
+ vi.mock("@/lib/agents/profiles/registry", () => {
86
+ const validIds = new Set(["general", "code-reviewer", "researcher"]);
87
+ return {
88
+ getProfile: (id: string) =>
89
+ validIds.has(id)
90
+ ? { id, name: id, description: "test", tags: [], skillMd: "", allowedTools: [], mcpServers: {}, systemPrompt: "" }
91
+ : undefined,
92
+ listProfiles: () => Array.from(validIds).map((id) => ({ id, name: id })),
93
+ };
94
+ });
95
+
96
+ // Mock the runtime catalog so isAgentRuntimeId is deterministic.
97
+ vi.mock("@/lib/agents/runtime/catalog", () => ({
98
+ DEFAULT_AGENT_RUNTIME: "claude",
99
+ SUPPORTED_AGENT_RUNTIMES: ["claude", "anthropic-direct", "openai-direct"],
100
+ isAgentRuntimeId: (id: string) => ["claude", "anthropic-direct", "openai-direct"].includes(id),
101
+ }));
102
+
103
+ // Mock the router so execute_task's dynamic import doesn't explode.
104
+ vi.mock("@/lib/agents/router", () => ({
105
+ executeTaskWithAgent: () => Promise.resolve(),
106
+ }));
107
+
108
+ import { taskTools } from "../task-tools";
109
+
110
+ function getTool(name: string, ctx: { projectId?: string | null } = { projectId: undefined }) {
111
+ const tools = taskTools(ctx as never);
112
+ const tool = tools.find((t) => t.name === name);
113
+ if (!tool) throw new Error(`Tool not found: ${name}`);
114
+ return tool;
115
+ }
116
+
117
+ function parseArgs(toolName: string, args: unknown) {
118
+ const tool = getTool(toolName);
119
+ return z.object(tool.zodShape).safeParse(args);
120
+ }
121
+
122
+ function callHandler(toolName: string, args: unknown, ctx: { projectId?: string | null } = { projectId: undefined }) {
123
+ const tool = getTool(toolName, ctx);
124
+ return tool.handler(args);
125
+ }
126
+
127
+ function getToolResultText(result: { content: Array<{ type: string; text: string }>; isError?: boolean }) {
128
+ return result.content[0]?.text ?? "";
129
+ }
130
+
131
+ beforeEach(() => {
132
+ mockState.rows = [];
133
+ mockState.lastInsertValues = null;
134
+ mockState.lastUpdateValues = null;
135
+ });
136
+
137
+ describe("create_task agentProfile Zod validation", () => {
138
+ const base = { title: "test task" };
139
+
140
+ it("accepts a valid profile id", () => {
141
+ const result = parseArgs("create_task", { ...base, agentProfile: "general" });
142
+ expect(result.success).toBe(true);
143
+ });
144
+
145
+ it("accepts another valid profile id", () => {
146
+ const result = parseArgs("create_task", { ...base, agentProfile: "code-reviewer" });
147
+ expect(result.success).toBe(true);
148
+ });
149
+
150
+ it("accepts omitted agentProfile", () => {
151
+ const result = parseArgs("create_task", base);
152
+ expect(result.success).toBe(true);
153
+ });
154
+
155
+ it("rejects a runtime id passed as agentProfile", () => {
156
+ const result = parseArgs("create_task", { ...base, agentProfile: "anthropic-direct" });
157
+ expect(result.success).toBe(false);
158
+ });
159
+
160
+ it("rejects an arbitrary invalid string", () => {
161
+ const result = parseArgs("create_task", { ...base, agentProfile: "not-a-profile" });
162
+ expect(result.success).toBe(false);
163
+ });
164
+ });
165
+
166
+ describe("create_task handler-level error messages", () => {
167
+ it("returns a descriptive error naming the invalid value and listing valid profile ids", async () => {
168
+ const result = await callHandler("create_task", {
169
+ title: "test task",
170
+ agentProfile: "anthropic-direct",
171
+ });
172
+ expect(result.isError).toBe(true);
173
+ const text = getToolResultText(result);
174
+ expect(text).toContain("anthropic-direct");
175
+ expect(text).toMatch(/code-reviewer|general|researcher/);
176
+ });
177
+
178
+ it("inserts a task when agentProfile is valid", async () => {
179
+ const result = await callHandler("create_task", {
180
+ title: "test task",
181
+ agentProfile: "general",
182
+ });
183
+ expect(result.isError).toBeFalsy();
184
+ expect(mockState.lastInsertValues?.agentProfile).toBe("general");
185
+ });
186
+
187
+ it("inserts with null agentProfile when omitted", async () => {
188
+ await callHandler("create_task", { title: "test task" });
189
+ expect(mockState.lastInsertValues?.agentProfile).toBe(null);
190
+ });
191
+ });
192
+
193
+ describe("update_task agentProfile Zod validation", () => {
194
+ const base = { taskId: "task-1" };
195
+
196
+ it("accepts a valid profile id", () => {
197
+ const result = parseArgs("update_task", { ...base, agentProfile: "researcher" });
198
+ expect(result.success).toBe(true);
199
+ });
200
+
201
+ it("rejects a runtime id", () => {
202
+ const result = parseArgs("update_task", { ...base, agentProfile: "anthropic-direct" });
203
+ expect(result.success).toBe(false);
204
+ });
205
+ });
206
+
207
+ describe("update_task handler-level agentProfile validation", () => {
208
+ beforeEach(() => {
209
+ mockState.rows = [{
210
+ id: "task-1",
211
+ title: "existing",
212
+ status: "planned",
213
+ projectId: null,
214
+ agentProfile: null,
215
+ assignedAgent: null,
216
+ } as TaskRow];
217
+ });
218
+
219
+ it("returns a descriptive error when the new agentProfile is invalid", async () => {
220
+ const result = await callHandler("update_task", {
221
+ taskId: "task-1",
222
+ agentProfile: "anthropic-direct",
223
+ });
224
+ expect(result.isError).toBe(true);
225
+ expect(getToolResultText(result)).toContain("anthropic-direct");
226
+ });
227
+
228
+ it("updates when the new agentProfile is valid", async () => {
229
+ const result = await callHandler("update_task", {
230
+ taskId: "task-1",
231
+ agentProfile: "code-reviewer",
232
+ });
233
+ expect(result.isError).toBeFalsy();
234
+ expect(mockState.lastUpdateValues?.agentProfile).toBe("code-reviewer");
235
+ });
236
+ });
237
+
238
+ describe("execute_task stale agentProfile surfacing", () => {
239
+ it("returns synchronous error when the stored task.agentProfile is invalid", async () => {
240
+ mockState.rows = [{
241
+ id: "task-1",
242
+ title: "stale task",
243
+ status: "planned",
244
+ projectId: null,
245
+ agentProfile: "anthropic-direct",
246
+ assignedAgent: null,
247
+ } as TaskRow];
248
+
249
+ const result = await callHandler("execute_task", { taskId: "task-1" });
250
+ expect(result.isError).toBe(true);
251
+ const text = getToolResultText(result);
252
+ expect(text).toContain("anthropic-direct");
253
+ expect(text).toContain("update_task");
254
+ });
255
+
256
+ it("queues execution when task.agentProfile is valid", async () => {
257
+ mockState.rows = [{
258
+ id: "task-1",
259
+ title: "ok task",
260
+ status: "planned",
261
+ projectId: null,
262
+ agentProfile: "general",
263
+ assignedAgent: null,
264
+ } as TaskRow];
265
+
266
+ const result = await callHandler("execute_task", { taskId: "task-1" });
267
+ expect(result.isError).toBeFalsy();
268
+ });
269
+
270
+ it("queues execution when task.agentProfile is null (runtime falls back to general)", async () => {
271
+ mockState.rows = [{
272
+ id: "task-1",
273
+ title: "ok task",
274
+ status: "planned",
275
+ projectId: null,
276
+ agentProfile: null,
277
+ assignedAgent: null,
278
+ } as TaskRow];
279
+
280
+ const result = await callHandler("execute_task", { taskId: "task-1" });
281
+ expect(result.isError).toBeFalsy();
282
+ });
283
+ });
284
+
285
+ describe("list_tasks empty-result note", () => {
286
+ it("returns an envelope with note when a project filter is active and zero rows result", async () => {
287
+ mockState.rows = [];
288
+ const result = await callHandler("list_tasks", {}, { projectId: "proj-active" });
289
+ expect(result.isError).toBeFalsy();
290
+ const parsed = JSON.parse(getToolResultText(result));
291
+ expect(parsed).toMatchObject({ tasks: [], note: expect.stringContaining("proj-active") });
292
+ expect(parsed.note).toContain("projectId: null");
293
+ expect(parsed.note).toContain("get_task");
294
+ });
295
+
296
+ it("returns the plain array (no note) when a project filter is active and rows are returned", async () => {
297
+ mockState.rows = [{
298
+ id: "task-1",
299
+ title: "existing",
300
+ status: "planned",
301
+ projectId: "proj-active",
302
+ agentProfile: null,
303
+ assignedAgent: null,
304
+ } as TaskRow];
305
+
306
+ const result = await callHandler("list_tasks", {}, { projectId: "proj-active" });
307
+ const parsed = JSON.parse(getToolResultText(result));
308
+ expect(Array.isArray(parsed)).toBe(true);
309
+ expect(parsed).toHaveLength(1);
310
+ });
311
+
312
+ it("returns the plain array (no note) when no filter is active and zero rows result", async () => {
313
+ mockState.rows = [];
314
+ const result = await callHandler("list_tasks", {}, { projectId: undefined });
315
+ const parsed = JSON.parse(getToolResultText(result));
316
+ expect(Array.isArray(parsed)).toBe(true);
317
+ expect(parsed).toHaveLength(0);
318
+ });
319
+ });
320
+
321
+ describe("get_task AC #4: failed tasks remain findable", () => {
322
+ it("finds a task regardless of status (including failed)", async () => {
323
+ mockState.rows = [{
324
+ id: "task-1",
325
+ title: "a failed task",
326
+ status: "failed",
327
+ projectId: "proj-other",
328
+ agentProfile: null,
329
+ assignedAgent: null,
330
+ } as TaskRow];
331
+
332
+ const result = await callHandler("get_task", { taskId: "task-1" });
333
+ expect(result.isError).toBeFalsy();
334
+ const text = getToolResultText(result);
335
+ expect(text).toContain("task-1");
336
+ expect(text).toContain("failed");
337
+ });
338
+
339
+ it("does not apply a project filter (returns the task even when stored under a different project)", async () => {
340
+ mockState.rows = [{
341
+ id: "task-1",
342
+ title: "cross-project task",
343
+ status: "completed",
344
+ projectId: "proj-A",
345
+ agentProfile: null,
346
+ assignedAgent: null,
347
+ } as TaskRow];
348
+
349
+ const result = await callHandler("get_task", { taskId: "task-1" }, { projectId: "proj-B" });
350
+ expect(result.isError).toBeFalsy();
351
+ });
352
+ });