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
@@ -21,6 +21,9 @@ import {
21
21
  updateConversation,
22
22
  } from "@/lib/data/chat";
23
23
  import { buildChatContext, type MentionReference } from "./context-builder";
24
+ import { finalizeStreamingMessage } from "./reconcile";
25
+ import { recordTermination } from "./stream-telemetry";
26
+ import { registerChatStream, unregisterChatStream } from "./active-streams";
24
27
  import {
25
28
  detectEntities,
26
29
  extractToolResultEntities,
@@ -250,6 +253,8 @@ export async function* sendMessage(
250
253
  status: "streaming",
251
254
  });
252
255
 
256
+ registerChatStream(conversationId);
257
+
253
258
  // Create side channel for canUseTool → SSE bridge communication
254
259
  const sideChannel = createSideChannel(conversationId);
255
260
 
@@ -636,6 +641,13 @@ export async function* sendMessage(
636
641
  finishedAt: new Date(),
637
642
  });
638
643
 
644
+ recordTermination({
645
+ reason: "stream.completed",
646
+ conversationId,
647
+ messageId: assistantMsg.id,
648
+ durationMs: Date.now() - startedAt.getTime(),
649
+ });
650
+
639
651
  yield {
640
652
  type: "done",
641
653
  messageId: assistantMsg.id,
@@ -647,7 +659,27 @@ export async function* sendMessage(
647
659
 
648
660
  // Enrich the error with stderr diagnostics when available
649
661
  const stderrTail = stderrChunks.join("").trim();
650
- const errorMessage = diagnoseProcessError(rawMessage, stderrTail);
662
+ const rawErrorMessage = diagnoseProcessError(rawMessage, stderrTail);
663
+ // Truncate at 4KB to prevent multi-MB stderr dumps bloating chat_messages
664
+ const errorMessage =
665
+ rawErrorMessage.length > 4096
666
+ ? rawErrorMessage.slice(0, 4096) + "... (truncated)"
667
+ : rawErrorMessage;
668
+
669
+ // Telemetry: record BEFORE the yield below. If this code is reached
670
+ // via iterator abandonment (consumer broke the for-await and the
671
+ // generator's own yield throws GeneratorReturn), control would skip
672
+ // past any post-yield statement. Recording up front guarantees the
673
+ // event lands in the ring buffer regardless of whether the yield
674
+ // completes or aborts. Matches the same invariant we rely on for
675
+ // the success-path recordTermination before the done yield.
676
+ recordTermination({
677
+ reason: signal?.aborted ? "stream.aborted.signal" : "stream.finalized.error",
678
+ conversationId,
679
+ messageId: assistantMsg.id,
680
+ durationMs: Date.now() - startedAt.getTime(),
681
+ error: errorMessage.slice(0, 500),
682
+ });
651
683
 
652
684
  if (fullText && fullText.length > 50) {
653
685
  // Substantial content was already streamed — complete gracefully with warning
@@ -674,10 +706,14 @@ export async function* sendMessage(
674
706
 
675
707
  yield { type: "done", messageId: assistantMsg.id, quickAccess: [] };
676
708
  } else {
677
- // No meaningful content — show as error
709
+ // No meaningful content — show as error. Fallback chain ensures we
710
+ // never write an empty string even if both fullText and errorMessage
711
+ // happen to be blank.
678
712
  await updateMessageContent(
679
713
  assistantMsg.id,
680
- fullText || errorMessage
714
+ fullText ||
715
+ errorMessage ||
716
+ "(Response failed — no error detail available.)"
681
717
  );
682
718
  await updateMessageStatus(assistantMsg.id, "error");
683
719
 
@@ -698,6 +734,17 @@ export async function* sendMessage(
698
734
  yield { type: "error", message: errorMessage };
699
735
  }
700
736
  } finally {
737
+ // Safety net: guarantee the placeholder row never remains in
738
+ // status='streaming' after the generator exits. Catches code paths that
739
+ // bypass the catch block — most notably async iterator abandonment, where
740
+ // a consumer `break`ing out of a `for await` loop triggers the generator's
741
+ // return() method and jumps straight here, skipping catch entirely.
742
+ try {
743
+ await finalizeStreamingMessage(assistantMsg.id, fullText);
744
+ } catch (finalizeErr) {
745
+ console.error("[chat] finalize safety net failed:", finalizeErr);
746
+ }
747
+ unregisterChatStream(conversationId);
701
748
  cleanupConversation(conversationId);
702
749
  }
703
750
  }
@@ -0,0 +1,117 @@
1
+ import { db } from "@/lib/db";
2
+ import { chatMessages } from "@/lib/db/schema";
3
+ import { and, eq, lt } from "drizzle-orm";
4
+ import { recordTermination } from "./stream-telemetry";
5
+
6
+ const INTERRUPTED_FALLBACK =
7
+ "(Response interrupted. Please try again.)";
8
+
9
+ const ORPHAN_FALLBACK =
10
+ "(Interrupted — this response was not completed. Please retry.)";
11
+
12
+ /**
13
+ * Safety-net finalizer called from the chat engine's top-level `finally` block.
14
+ *
15
+ * Guarantees the invariant: no `chat_messages` row remains in
16
+ * `status='streaming'` after `sendMessage()` returns or throws. Catches every
17
+ * code path the engine's own `catch` block misses — most notably async
18
+ * iterator abandonment, where a consumer `break`ing out of a `for await` loop
19
+ * triggers the generator's `return()` method and jumps straight to `finally`,
20
+ * skipping `catch` entirely.
21
+ *
22
+ * No-op if the message is already in a terminal state. Idempotent.
23
+ *
24
+ * When the salvage path actually fires (row was still in streaming → now
25
+ * updated to complete/error), records a `stream.abandoned` telemetry event
26
+ * so maintainers can see that the engine's own happy/catch paths both
27
+ * missed the termination. A non-zero count here signals a real gap that
28
+ * may warrant investigation — e.g., the dev HMR interrupting a stream,
29
+ * or a consumer break pattern that bypasses the telemetry in both primary
30
+ * code paths.
31
+ */
32
+ export async function finalizeStreamingMessage(
33
+ messageId: string,
34
+ fullText: string,
35
+ ): Promise<void> {
36
+ const current = db
37
+ .select()
38
+ .from(chatMessages)
39
+ .where(eq(chatMessages.id, messageId))
40
+ .get();
41
+
42
+ if (!current || current.status !== "streaming") {
43
+ return;
44
+ }
45
+
46
+ const hasContent = fullText && fullText.trim().length > 0;
47
+ const salvage = hasContent ? fullText : INTERRUPTED_FALLBACK;
48
+ const nextStatus = hasContent && fullText.length > 50 ? "complete" : "error";
49
+
50
+ db.update(chatMessages)
51
+ .set({ status: nextStatus, content: salvage })
52
+ .where(eq(chatMessages.id, messageId))
53
+ .run();
54
+
55
+ // Telemetry: this code path means neither stream.completed nor the
56
+ // engine's catch-block recordTermination fired. Capture the gap so
57
+ // the diagnostics endpoint can surface it.
58
+ recordTermination({
59
+ reason: "stream.abandoned",
60
+ conversationId: current.conversationId ?? null,
61
+ messageId,
62
+ durationMs: current.createdAt
63
+ ? Date.now() - new Date(current.createdAt).getTime()
64
+ : null,
65
+ error: hasContent ? undefined : "no content streamed before abandonment",
66
+ });
67
+ }
68
+
69
+ /**
70
+ * Sweep orphaned chat assistant messages left in `status='streaming'` past a
71
+ * reasonable cutoff. These rows are produced when the chat engine's finally
72
+ * block is bypassed (process crash, iterator abandonment under heavy load,
73
+ * HTTP disconnect before update commits, etc.).
74
+ *
75
+ * Safe to call idempotently at chat page load. Uses a 10-minute cutoff — far
76
+ * longer than any legitimate in-flight streaming response — so in-flight rows
77
+ * are never clobbered.
78
+ *
79
+ * Returns the number of rows swept. Never throws.
80
+ */
81
+ export async function reconcileStreamingMessages(): Promise<number> {
82
+ const cutoff = new Date(Date.now() - 10 * 60 * 1000);
83
+ const orphans = db
84
+ .select()
85
+ .from(chatMessages)
86
+ .where(
87
+ and(
88
+ eq(chatMessages.status, "streaming"),
89
+ lt(chatMessages.createdAt, cutoff),
90
+ ),
91
+ )
92
+ .all();
93
+
94
+ for (const row of orphans) {
95
+ const salvage =
96
+ row.content && row.content.length > 0 ? row.content : ORPHAN_FALLBACK;
97
+ db.update(chatMessages)
98
+ .set({ status: "error", content: salvage })
99
+ .where(eq(chatMessages.id, row.id))
100
+ .run();
101
+
102
+ // Telemetry: record the orphan sweep so diagnostics can tell how often
103
+ // the safety net actually fires vs. how often the normal finalize path
104
+ // catches everything first. If this code ever logs a row, the engine's
105
+ // `finally` block missed it.
106
+ recordTermination({
107
+ reason: "stream.reconciled.stale",
108
+ conversationId: row.conversationId ?? null,
109
+ messageId: row.id,
110
+ durationMs: row.createdAt
111
+ ? Date.now() - new Date(row.createdAt).getTime()
112
+ : null,
113
+ });
114
+ }
115
+
116
+ return orphans.length;
117
+ }
@@ -22,6 +22,7 @@ import { handoffTools } from "./tools/handoff-tools";
22
22
  import { tableTools } from "./tools/table-tools";
23
23
  import { runtimeTools } from "./tools/runtime-tools";
24
24
 
25
+
25
26
  // ── Tool server types ────────────────────────────────────────────────
26
27
 
27
28
  export interface ProviderToolKit {
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Chat stream termination telemetry.
3
+ *
4
+ * Lightweight, in-memory ring buffer that records how SSE chat streams
5
+ * terminate. Added in response to a sibling-repo bug report claiming
6
+ * conversations refresh mid-stream — the proposed root cause (Next.js dev
7
+ * HMR remounting ChatShell) is already mitigated in this repo, so rather
8
+ * than port a speculative fix, we instrument the termination boundaries
9
+ * and let real data decide whether a resume protocol is worth building.
10
+ *
11
+ * Six server-side reason codes:
12
+ * - stream.completed — normal end-of-generator (success path)
13
+ * - stream.aborted.signal — req.signal fired, engine catch block entered
14
+ * - stream.aborted.client — ReadableStream cancel callback fired
15
+ * - stream.finalized.error — non-abort exception in engine catch block
16
+ * - stream.abandoned — generator return() called by consumer
17
+ * (finally ran but catch was skipped). Covers
18
+ * iterator abandonment — the case where the
19
+ * route's for-await breaks out gracefully and
20
+ * the engine's own happy/catch paths are both
21
+ * bypassed. Recorded from finalizeStreamingMessage
22
+ * when it actually performs a salvage update.
23
+ * - stream.reconciled.stale — reconcileStreamingMessages swept an orphan
24
+ * at chat page load (10-min cutoff)
25
+ *
26
+ * Three client-side reason codes (logged via console.info with a stable
27
+ * prefix so tests and grep can find them):
28
+ * - client.stream.done — reader.read() returned done: true
29
+ * - client.stream.user-abort — user clicked Stop / AbortController fired
30
+ * - client.stream.reader-error — reader.read() or decode threw
31
+ *
32
+ * As of the `chat-session-persistence-provider` feature, the SSE reader
33
+ * loop runs inside `ChatSessionProvider` (rendered from the root layout),
34
+ * not inside the route-scoped `ChatShell`. Sidebar navigation no longer
35
+ * tears down the reader loop, so "client.stream.user-abort" should only
36
+ * fire when the user explicitly clicks Stop. If it starts firing on plain
37
+ * view switches again, something has regressed the provider hoisting.
38
+ * HMR in dev can still reset the provider module — that is expected.
39
+ *
40
+ * Read via the dev-only `GET /api/diagnostics/chat-streams` endpoint.
41
+ * The buffer is process-local — a server restart clears it, which is fine
42
+ * for dev diagnostics and avoids adding a persistence layer that would
43
+ * itself need testing.
44
+ */
45
+
46
+ export type TerminationReason =
47
+ | "stream.completed"
48
+ | "stream.aborted.signal"
49
+ | "stream.aborted.client"
50
+ | "stream.finalized.error"
51
+ | "stream.abandoned"
52
+ | "stream.reconciled.stale";
53
+
54
+ export interface TerminationEvent {
55
+ reason: TerminationReason;
56
+ conversationId: string | null;
57
+ messageId: string | null;
58
+ durationMs: number | null;
59
+ error?: string;
60
+ timestamp: number;
61
+ }
62
+
63
+ /** Ring buffer capacity — ~500 events is ~50KB, negligible for a dev tool. */
64
+ const CAPACITY = 500;
65
+
66
+ /**
67
+ * Module-level circular buffer. Newer events overwrite older ones once
68
+ * capacity is reached. Writes are O(1), reads copy-out in order.
69
+ *
70
+ * Next.js dev HMR may re-import this module and reset the buffer — that
71
+ * is expected behavior and not a bug. The buffer is intentionally not
72
+ * persisted; its purpose is "what happened in the last N minutes of this
73
+ * process", not forensic logging.
74
+ */
75
+ const buffer: TerminationEvent[] = new Array(CAPACITY);
76
+ let writeIndex = 0;
77
+ let writeCount = 0;
78
+
79
+ export function recordTermination(event: Omit<TerminationEvent, "timestamp">): void {
80
+ const full: TerminationEvent = { ...event, timestamp: Date.now() };
81
+ buffer[writeIndex] = full;
82
+ writeIndex = (writeIndex + 1) % CAPACITY;
83
+ writeCount++;
84
+ }
85
+
86
+ /**
87
+ * Return all recorded events in chronological order (oldest → newest).
88
+ * Copies out of the ring buffer so callers can't mutate internal state.
89
+ */
90
+ export function readTerminations(): TerminationEvent[] {
91
+ const count = Math.min(writeCount, CAPACITY);
92
+ if (count === 0) return [];
93
+ const result: TerminationEvent[] = new Array(count);
94
+ // Start at the oldest slot. When the buffer is full, the oldest is at
95
+ // writeIndex (the next slot to be overwritten). When not full, it's at 0.
96
+ const start = writeCount > CAPACITY ? writeIndex : 0;
97
+ for (let i = 0; i < count; i++) {
98
+ result[i] = buffer[(start + i) % CAPACITY]!;
99
+ }
100
+ return result;
101
+ }
102
+
103
+ /**
104
+ * Aggregate event counts by reason code for the last `windowMs` milliseconds.
105
+ * Pass 0 or omit to get counts across the entire buffer.
106
+ */
107
+ export function countTerminations(windowMs = 0): Record<TerminationReason, number> {
108
+ const counts: Record<TerminationReason, number> = {
109
+ "stream.completed": 0,
110
+ "stream.aborted.signal": 0,
111
+ "stream.aborted.client": 0,
112
+ "stream.finalized.error": 0,
113
+ "stream.abandoned": 0,
114
+ "stream.reconciled.stale": 0,
115
+ };
116
+ const cutoff = windowMs > 0 ? Date.now() - windowMs : 0;
117
+ for (const event of readTerminations()) {
118
+ if (event.timestamp >= cutoff) {
119
+ counts[event.reason]++;
120
+ }
121
+ }
122
+ return counts;
123
+ }
124
+
125
+ /**
126
+ * Reset the buffer. Intended for tests — do not call in production code.
127
+ */
128
+ export function __resetForTesting(): void {
129
+ for (let i = 0; i < CAPACITY; i++) buffer[i] = undefined as never;
130
+ writeIndex = 0;
131
+ writeCount = 0;
132
+ }
@@ -1,5 +1,5 @@
1
1
  import { db } from "@/lib/db";
2
- import { projects, tasks, workflows, schedules } from "@/lib/db/schema";
2
+ import { projects, tasks, schedules, userTables } from "@/lib/db/schema";
3
3
  import { eq, desc } from "drizzle-orm";
4
4
  import type { PromptCategory, SuggestedPrompt } from "./types";
5
5
 
@@ -40,6 +40,25 @@ async function buildExplorePrompts(): Promise<SuggestedPrompt[]> {
40
40
  });
41
41
  }
42
42
 
43
+ // Context-sensitive suggestion: if any user tables exist, surface an
44
+ // enrichment prompt for the most recently updated one. Users commonly have
45
+ // tables with empty cells waiting to be enriched; even without a column-level
46
+ // scan, pointing the chat LLM at enrich_table makes the bulk-row fan-out
47
+ // capability discoverable via suggested prompts rather than only via direct
48
+ // intent.
49
+ const recentTable = await db
50
+ .select({ id: userTables.id, name: userTables.name })
51
+ .from(userTables)
52
+ .orderBy(desc(userTables.updatedAt))
53
+ .limit(1);
54
+
55
+ if (recentTable.length > 0) {
56
+ prompts.push({
57
+ label: `Enrich "${truncate(recentTable[0].name, 28)}" rows`,
58
+ prompt: `I'd like to enrich rows in the "${recentTable[0].name}" table (id: ${recentTable[0].id}) using an agent. Ask me which column is missing data, what prompt template to use (reference row fields naturally), and which agent profile is best. Then use enrich_table to kick off the loop workflow.`,
59
+ });
60
+ }
61
+
43
62
  // Fill with static fallbacks
44
63
  const fallbacks: SuggestedPrompt[] = [
45
64
  {
@@ -76,6 +95,14 @@ function buildCreatePrompts(): SuggestedPrompt[] {
76
95
  label: "Set up a multi-step workflow",
77
96
  prompt: "Help me design a multi-step workflow. I want to define a sequence of tasks with dependencies. Ask me what the workflow should accomplish and suggest a structure.",
78
97
  },
98
+ {
99
+ label: "Design a drip sequence",
100
+ prompt: "Help me build a drip workflow with delay steps between sends. Ask me about the cadence (e.g. 3 days between touches), the number of touches, and the content goal for each step. Then use create_workflow with a sequence pattern, interleaving task steps and delay steps (delayDuration format: Nm|Nh|Nd|Nw, bounds 1m..30d). Do not create separate workflows or schedules — a single workflow with inline delay steps is the idiomatic pattern.",
101
+ },
102
+ {
103
+ label: "Enrich a table with an agent",
104
+ prompt: "I have a table with missing data that I want an agent to fill in. Help me use enrich_table to fan out rows to the agent. Ask me which table, which column is missing, what prompt template to use (the row is available as JSON context — tell the agent to read the relevant fields and return just the value, or NOT_FOUND if none can be determined), and which agent profile is best (sales-researcher, content-creator, data-analyst, etc.). Do not hand-roll a loop workflow — enrich_table already handles the loop, row binding, postAction writeback, and idempotent skip.",
105
+ },
79
106
  {
80
107
  label: "Draft a document outline",
81
108
  prompt: "Help me create a structured document outline. Ask me about the topic, audience, and purpose, then suggest sections and key points to cover.",
@@ -21,11 +21,12 @@ export const STAGENT_SYSTEM_PROMPT = `You are Stagent, an AI workspace assistant
21
21
 
22
22
  ### Workflows
23
23
  - list_workflows: List all workflows
24
- - create_workflow: Create a multi-step workflow with a definition
24
+ - create_workflow: Create a multi-step workflow with a definition. Steps can be task steps (profile + prompt) or **delay steps** (delayDuration like '3d', '2h', '30m', '1w') that pause the workflow before the next step. Delay steps enable time-distributed sequences.
25
25
  - get_workflow: Get workflow details and definition
26
26
  - update_workflow: Update a draft workflow
27
27
  - delete_workflow: Delete a workflow and its children [requires approval]
28
28
  - execute_workflow: Start workflow execution [requires approval]
29
+ - resume_workflow: Resume a paused (delayed) workflow immediately instead of waiting for its scheduled resume time [requires approval]
29
30
  - get_workflow_status: Get current execution status with step progress
30
31
  - find_related_documents: Search the project document pool for documents to attach as workflow context
31
32
 
@@ -58,9 +59,30 @@ export const STAGENT_SYSTEM_PROMPT = `You are Stagent, an AI workspace assistant
58
59
  - get_usage_summary: Get token and cost statistics over a time period
59
60
  - get_settings: Read current configuration (auth method, budgets, runtime)
60
61
 
62
+ ### Tables
63
+ Structured user data lives in Stagent tables (separate from Stagent's own internal records). Every table tool takes a tableId; use list_tables or search_table to find them first.
64
+ - list_tables: List all user tables in a project
65
+ - get_table_schema: Get a table's columns, types, and metadata
66
+ - query_table: Filter, sort, and paginate rows with operators (eq, neq, gt, gte, lt, lte, contains, starts_with, in, is_empty, is_not_empty)
67
+ - search_table: Full-text search across row cell values
68
+ - aggregate_table: Compute count/sum/avg/min/max over a column with optional group-by
69
+ - add_rows: Insert one or more rows
70
+ - update_row: Update a single row's cell values
71
+ - delete_rows: Delete rows matching a filter [requires approval]
72
+ - create_table: Create a new empty table with specified columns
73
+ - import_document_as_table: Parse an uploaded document into a new table
74
+ - export_table: Export rows as CSV/JSON
75
+ - add_column / update_column / delete_column / reorder_columns: Schema edits
76
+ - list_triggers / create_trigger / update_trigger / delete_trigger: Per-row trigger evaluation
77
+ - get_table_history: Read change history for a table
78
+ - save_as_template: Save a table's shape as a reusable template
79
+ - **enrich_table**: Run an agent task for every row in a table matching a filter, writing results to a target column. Use for bulk research, classification, content generation, or any table-row fan-out pattern. Generates the optimal loop workflow, binds each row as context, skips already-populated rows for idempotency [requires approval]
80
+
61
81
  ## When to Use Which Tools
62
82
  - CRUD operations ("create a task", "list workflows", "update the schedule") → Use the appropriate Stagent tool
63
83
  - Execution ("run this task", "execute the workflow") → Use execute_task / execute_workflow
84
+ - Time-distributed multi-step sequences ("send email, wait 3 days, follow up", "drip campaign", "onboarding flow") → Use create_workflow with delay steps in a sequence pattern. Do NOT create separate workflows and schedules for each touch — a single workflow with inline delay steps is the idiomatic pattern.
85
+ - Bulk per-row operations ("research every contact", "classify all tickets", "enrich rows missing X", "for each row do Y") → Use enrich_table. Do NOT hand-roll a loop workflow for this — enrich_table already generates the optimal loop, handles row-data binding, wires up the postAction writeback, and skips already-populated rows for idempotency.
64
86
  - Approvals ("approve that", "allow it", "deny the request") → Use respond_notification
65
87
  - Monitoring ("what's pending?", "any approval requests?") → Use list_notifications
66
88
  - Usage ("how much have I spent?", "token usage this week") → Use get_usage_summary
@@ -82,6 +104,9 @@ Be proactive with tools. If the user asks about project status, use list_tasks t
82
104
  - If a project context is active, scope operations to it unless the user specifies otherwise.
83
105
  - Tools marked [requires approval] will prompt the user before executing.
84
106
  - For workflows, valid patterns are: sequence, parallel, checkpoint, planner-executor, swarm, loop.
107
+ - **Delay steps** (sequence pattern only): a step with \`delayDuration\` (format: Nm|Nh|Nd|Nw, bounds 1m..30d) pauses the workflow between task steps. Format examples: "30m", "2h", "3d", "1w". Delay steps must have NO profile or prompt — they are pure waits. Use them for outreach sequences, drip campaigns, cooling periods, staged rollouts. A paused workflow resumes automatically when its scheduled time arrives, or immediately when the user clicks "Resume Now".
108
+ - **enrich_table idempotency:** \`enrich_table\` skips rows where the target column already has a non-empty value. If the user wants to overwrite existing values, explain that force re-enrichment is not supported in v1 — they must manually clear the target column first (e.g. via update_row) before re-running.
109
+ - **create_workflow dedup:** Before calling \`create_workflow\`, call \`list_workflows\` (filtered by the current project) to check whether a similar workflow already exists. If the user asks to "redesign", "redo", or "update" an existing workflow, call \`update_workflow\` on the matching row instead of creating a new one. \`create_workflow\` performs its own near-duplicate check and will return \`{status: "similar-found", matches: [...]}\` instead of inserting when it finds one — when that happens, surface the matches to the user and confirm intent. Only pass \`force: true\` to \`create_workflow\` when the user has explicitly confirmed they want a second workflow alongside a similar one (e.g., "v2", "alternate approach").
85
110
  - When a working directory is specified, always create files relative to it. Never assume the git root is the working directory — they may differ in worktree environments.
86
111
 
87
112
  ## Document Pool Awareness
@@ -11,7 +11,6 @@ import {
11
11
  Clock,
12
12
  Globe,
13
13
  Sun,
14
- CheckCheck,
15
14
  Sparkles,
16
15
  Table2,
17
16
  } from "lucide-react";
@@ -171,11 +170,13 @@ const STAGENT_TOOLS: ToolCatalogEntry[] = [
171
170
  { name: "delete_trigger", description: "Delete a trigger", group: "Tables", paramHint: "tableId, triggerId" },
172
171
  { name: "get_table_history", description: "Get row change history for a table", group: "Tables", paramHint: "tableId, limit" },
173
172
  { name: "save_as_template", description: "Save a table as a reusable template", group: "Tables", paramHint: "tableId, name, category" },
173
+ { name: "enrich_table", description: "Bulk-enrich rows by running an agent task per row, writing results to a target column", group: "Tables", paramHint: "tableId, prompt, targetColumn, filter" },
174
174
 
175
175
  // ── Chat History ──
176
176
  { name: "list_conversations", description: "List recent chat conversations", group: "Chat", paramHint: "search, limit" },
177
177
  { name: "get_conversation_messages", description: "Get messages from a past conversation", group: "Chat", paramHint: "conversationId, limit" },
178
178
  { name: "search_messages", description: "Search across all conversations", group: "Chat", paramHint: "query" },
179
+
179
180
  ];
180
181
 
181
182
  const BROWSER_TOOLS: ToolCatalogEntry[] = [
@@ -0,0 +1,127 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+
3
+ const { mockCreateEnrichmentWorkflow } = vi.hoisted(() => ({
4
+ mockCreateEnrichmentWorkflow: vi.fn(),
5
+ }));
6
+
7
+ vi.mock("@/lib/tables/enrichment", () => ({
8
+ createEnrichmentWorkflow: mockCreateEnrichmentWorkflow,
9
+ }));
10
+
11
+ // Stub the rest of @/lib/data/tables so importing table-tools doesn't drag in DB.
12
+ vi.mock("@/lib/data/tables", () => ({
13
+ listTables: vi.fn(),
14
+ getTable: vi.fn(),
15
+ createTable: vi.fn(),
16
+ updateTable: vi.fn(),
17
+ deleteTable: vi.fn(),
18
+ listRows: vi.fn(),
19
+ addRows: vi.fn(),
20
+ updateRow: vi.fn(),
21
+ deleteRows: vi.fn(),
22
+ listTemplates: vi.fn(),
23
+ cloneFromTemplate: vi.fn(),
24
+ addColumn: vi.fn(),
25
+ updateColumn: vi.fn(),
26
+ deleteColumn: vi.fn(),
27
+ reorderColumns: vi.fn(),
28
+ }));
29
+
30
+ vi.mock("@/lib/tables/history", () => ({ getTableHistory: vi.fn() }));
31
+ vi.mock("@/lib/tables/import", () => ({
32
+ extractStructuredData: vi.fn(),
33
+ inferColumnTypes: vi.fn(),
34
+ importRows: vi.fn(),
35
+ createImportRecord: vi.fn(),
36
+ }));
37
+
38
+ import { tableTools } from "../table-tools";
39
+
40
+ function findEnrichTool() {
41
+ const tools = tableTools({ projectId: "proj_test" });
42
+ const tool = tools.find((t) => t.name === "enrich_table");
43
+ if (!tool) throw new Error("enrich_table tool not registered");
44
+ return tool;
45
+ }
46
+
47
+ describe("enrich_table tool", () => {
48
+ beforeEach(() => {
49
+ mockCreateEnrichmentWorkflow.mockReset();
50
+ });
51
+
52
+ it("is registered in tableTools", () => {
53
+ const tools = tableTools({ projectId: "proj_test" });
54
+ const names = tools.map((t) => t.name);
55
+ expect(names).toContain("enrich_table");
56
+ });
57
+
58
+ it("delegates to createEnrichmentWorkflow with the supplied params", async () => {
59
+ mockCreateEnrichmentWorkflow.mockResolvedValueOnce({
60
+ workflowId: "wf_xyz",
61
+ rowCount: 4,
62
+ });
63
+
64
+ const tool = findEnrichTool();
65
+ const result = await tool.handler({
66
+ tableId: "tbl_contacts",
67
+ prompt: "Find LinkedIn for {{row.name}}",
68
+ targetColumn: "linkedin",
69
+ filter: { column: "linkedin", operator: "is_empty" },
70
+ agentProfile: "sales-researcher",
71
+ });
72
+
73
+ expect(mockCreateEnrichmentWorkflow).toHaveBeenCalledWith(
74
+ "tbl_contacts",
75
+ expect.objectContaining({
76
+ prompt: "Find LinkedIn for {{row.name}}",
77
+ targetColumn: "linkedin",
78
+ filter: { column: "linkedin", operator: "is_empty" },
79
+ agentProfile: "sales-researcher",
80
+ })
81
+ );
82
+
83
+ expect(result.isError).toBeFalsy();
84
+ const payload = JSON.parse(result.content[0].text) as {
85
+ workflowId: string;
86
+ rowCount: number;
87
+ };
88
+ expect(payload.workflowId).toBe("wf_xyz");
89
+ expect(payload.rowCount).toBe(4);
90
+ });
91
+
92
+ it("falls back to ctx.projectId when projectId is not supplied", async () => {
93
+ mockCreateEnrichmentWorkflow.mockResolvedValueOnce({
94
+ workflowId: "wf_a",
95
+ rowCount: 1,
96
+ });
97
+
98
+ const tool = findEnrichTool();
99
+ await tool.handler({
100
+ tableId: "tbl_x",
101
+ prompt: "x",
102
+ targetColumn: "linkedin",
103
+ });
104
+
105
+ const callArg = mockCreateEnrichmentWorkflow.mock.calls[0][1] as {
106
+ projectId?: string;
107
+ };
108
+ expect(callArg.projectId).toBe("proj_test");
109
+ });
110
+
111
+ it("returns an error result when createEnrichmentWorkflow throws", async () => {
112
+ mockCreateEnrichmentWorkflow.mockRejectedValueOnce(
113
+ new Error("Table tbl_missing not found")
114
+ );
115
+
116
+ const tool = findEnrichTool();
117
+ const result = await tool.handler({
118
+ tableId: "tbl_missing",
119
+ prompt: "x",
120
+ targetColumn: "linkedin",
121
+ });
122
+
123
+ expect(result.isError).toBe(true);
124
+ const payload = JSON.parse(result.content[0].text) as { error: string };
125
+ expect(payload.error).toContain("not found");
126
+ });
127
+ });