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
@@ -3,10 +3,18 @@ import type { ProfileTestReport } from "@/lib/agents/profiles/test-types";
3
3
  import type { RuntimeCapabilities, RuntimeCatalogEntry } from "./catalog";
4
4
  import type { TaskAssistResponse } from "./task-assist-types";
5
5
  import type { ProfileAssistRequest, ProfileAssistResponse } from "./profile-assist-types";
6
+ import type {
7
+ OpenAIAccountInfo,
8
+ OpenAIAuthMode,
9
+ OpenAIRateLimitInfo,
10
+ } from "@/lib/settings/openai-auth";
6
11
 
7
12
  export interface RuntimeConnectionResult {
8
13
  connected: boolean;
9
14
  apiKeySource?: ApiKeySource;
15
+ account?: OpenAIAccountInfo | null;
16
+ rateLimits?: OpenAIRateLimitInfo | null;
17
+ authMode?: OpenAIAuthMode;
10
18
  error?: string;
11
19
  }
12
20
 
@@ -19,6 +19,7 @@ export const CHAPTER_SLUGS: Record<string, string> = {
19
19
  "ch-11": "ch-11-the-machine-that-builds-machines",
20
20
  "ch-12": "ch-12-the-road-ahead",
21
21
  "ch-13": "ch-13-the-wealth-manager",
22
+ "ch-14": "ch-14-the-meta-program",
22
23
  };
23
24
 
24
25
  interface ChapterMapping {
@@ -105,6 +106,16 @@ export const CHAPTER_MAPPING: Record<string, ChapterMapping> = {
105
106
  sourceFiles: ["src/lib/workflows/engine.ts", "src/lib/schedules/scheduler.ts", "src/lib/agents/profiles/registry.ts"],
106
107
  caseStudies: ["making-machine-that-builds-machines"],
107
108
  },
109
+ "ch-14": {
110
+ docs: ["workflows", "profiles", "schedules", "blueprints"],
111
+ sourceFiles: [
112
+ "src/lib/agents/profiles/registry.ts",
113
+ "src/lib/workflows/blueprints/registry.ts",
114
+ "src/lib/workflows/engine.ts",
115
+ "features/instance-bootstrap.md",
116
+ ],
117
+ caseStudies: ["making-machine-that-builds-machines"],
118
+ },
108
119
  };
109
120
 
110
121
  /** Get related Playbook doc slugs for a chapter */
@@ -166,6 +166,16 @@ export const CHAPTERS: BookChapter[] = [
166
166
  relatedDocs: ["workflows", "profiles", "schedules"],
167
167
  sections: [],
168
168
  },
169
+ {
170
+ id: "ch-14",
171
+ number: 14,
172
+ title: "The Meta-Program",
173
+ subtitle: "When the System You Are Using Is Also the System You Are Building",
174
+ part: PARTS[3],
175
+ readingTime: 16,
176
+ relatedDocs: ["workflows", "profiles", "schedules", "blueprints"],
177
+ sections: [],
178
+ },
169
179
  ];
170
180
 
171
181
  /**
@@ -0,0 +1,49 @@
1
+ import { describe, it, expect, beforeEach } from "vitest";
2
+ import {
3
+ registerChatStream,
4
+ unregisterChatStream,
5
+ getActiveChatStreamCount,
6
+ isAnyChatStreaming,
7
+ } from "../active-streams";
8
+
9
+ describe("active chat streams", () => {
10
+ beforeEach(() => {
11
+ for (const id of ["a", "b", "c"]) unregisterChatStream(id);
12
+ });
13
+
14
+ it("starts empty", () => {
15
+ expect(getActiveChatStreamCount()).toBe(0);
16
+ expect(isAnyChatStreaming()).toBe(false);
17
+ });
18
+
19
+ it("tracks a single registered stream", () => {
20
+ registerChatStream("a");
21
+ expect(getActiveChatStreamCount()).toBe(1);
22
+ expect(isAnyChatStreaming()).toBe(true);
23
+ });
24
+
25
+ it("tracks multiple streams independently", () => {
26
+ registerChatStream("a");
27
+ registerChatStream("b");
28
+ expect(getActiveChatStreamCount()).toBe(2);
29
+ });
30
+
31
+ it("is idempotent — registering the same id twice still counts as one", () => {
32
+ registerChatStream("a");
33
+ registerChatStream("a");
34
+ expect(getActiveChatStreamCount()).toBe(1);
35
+ });
36
+
37
+ it("unregisters by id", () => {
38
+ registerChatStream("a");
39
+ registerChatStream("b");
40
+ unregisterChatStream("a");
41
+ expect(getActiveChatStreamCount()).toBe(1);
42
+ expect(isAnyChatStreaming()).toBe(true);
43
+ });
44
+
45
+ it("unregistering a non-existent id is a no-op", () => {
46
+ expect(() => unregisterChatStream("never-registered")).not.toThrow();
47
+ expect(getActiveChatStreamCount()).toBe(0);
48
+ });
49
+ });
@@ -0,0 +1,139 @@
1
+ import { describe, it, expect, beforeEach } from "vitest";
2
+ import { db } from "@/lib/db";
3
+ import { conversations, chatMessages } from "@/lib/db/schema";
4
+ import { eq } from "drizzle-orm";
5
+ import { randomUUID } from "crypto";
6
+ import { finalizeStreamingMessage } from "../reconcile";
7
+
8
+ function seedConversation(): string {
9
+ const id = randomUUID();
10
+ const now = new Date();
11
+ db.insert(conversations)
12
+ .values({
13
+ id,
14
+ runtimeId: "test-runtime",
15
+ status: "active",
16
+ createdAt: now,
17
+ updatedAt: now,
18
+ })
19
+ .run();
20
+ return id;
21
+ }
22
+
23
+ function seedStreaming(convId: string, content: string): string {
24
+ const id = randomUUID();
25
+ db.insert(chatMessages)
26
+ .values({
27
+ id,
28
+ conversationId: convId,
29
+ role: "assistant",
30
+ content,
31
+ status: "streaming",
32
+ createdAt: new Date(),
33
+ })
34
+ .run();
35
+ return id;
36
+ }
37
+
38
+ describe("finalizeStreamingMessage", () => {
39
+ beforeEach(() => {
40
+ db.delete(chatMessages).run();
41
+ db.delete(conversations).run();
42
+ });
43
+
44
+ it("is a no-op when the message is already complete", async () => {
45
+ const convId = seedConversation();
46
+ const id = randomUUID();
47
+ db.insert(chatMessages)
48
+ .values({
49
+ id,
50
+ conversationId: convId,
51
+ role: "assistant",
52
+ content: "Already finished",
53
+ status: "complete",
54
+ createdAt: new Date(),
55
+ })
56
+ .run();
57
+
58
+ await finalizeStreamingMessage(id, "ignored salvage text");
59
+
60
+ const row = db
61
+ .select()
62
+ .from(chatMessages)
63
+ .where(eq(chatMessages.id, id))
64
+ .get();
65
+ expect(row?.status).toBe("complete");
66
+ expect(row?.content).toBe("Already finished");
67
+ });
68
+
69
+ it("salvages streaming row with substantial content as complete", async () => {
70
+ const convId = seedConversation();
71
+ const id = seedStreaming(convId, "");
72
+ const partialText =
73
+ "I searched the web and found three relevant articles about the topic. Here are the highlights of what I learned...";
74
+
75
+ await finalizeStreamingMessage(id, partialText);
76
+
77
+ const row = db
78
+ .select()
79
+ .from(chatMessages)
80
+ .where(eq(chatMessages.id, id))
81
+ .get();
82
+ expect(row?.status).toBe("complete");
83
+ expect(row?.content).toBe(partialText);
84
+ });
85
+
86
+ it("marks streaming row with no content as error with fallback string", async () => {
87
+ const convId = seedConversation();
88
+ const id = seedStreaming(convId, "");
89
+
90
+ await finalizeStreamingMessage(id, "");
91
+
92
+ const row = db
93
+ .select()
94
+ .from(chatMessages)
95
+ .where(eq(chatMessages.id, id))
96
+ .get();
97
+ expect(row?.status).toBe("error");
98
+ expect(row?.content).toMatch(/interrupted/i);
99
+ expect(row?.content.length).toBeGreaterThan(0);
100
+ });
101
+
102
+ it("marks streaming row with very short content as error, not complete", async () => {
103
+ const convId = seedConversation();
104
+ const id = seedStreaming(convId, "");
105
+
106
+ // 20 chars — not substantial enough to call "complete"
107
+ await finalizeStreamingMessage(id, "Just a short reply.");
108
+
109
+ const row = db
110
+ .select()
111
+ .from(chatMessages)
112
+ .where(eq(chatMessages.id, id))
113
+ .get();
114
+ expect(row?.status).toBe("error");
115
+ expect(row?.content).toBe("Just a short reply.");
116
+ });
117
+
118
+ it("marks streaming row with whitespace-only fullText as error with fallback", async () => {
119
+ const convId = seedConversation();
120
+ const id = seedStreaming(convId, "");
121
+
122
+ await finalizeStreamingMessage(id, " \n\n \t ");
123
+
124
+ const row = db
125
+ .select()
126
+ .from(chatMessages)
127
+ .where(eq(chatMessages.id, id))
128
+ .get();
129
+ expect(row?.status).toBe("error");
130
+ expect(row?.content).toMatch(/interrupted/i);
131
+ });
132
+
133
+ it("is a no-op when the message does not exist", async () => {
134
+ // Should not throw — defensive null check
135
+ await expect(
136
+ finalizeStreamingMessage("nonexistent-id", "some text"),
137
+ ).resolves.not.toThrow();
138
+ });
139
+ });
@@ -0,0 +1,137 @@
1
+ import { describe, it, expect, beforeEach } from "vitest";
2
+ import { db } from "@/lib/db";
3
+ import { conversations, chatMessages } from "@/lib/db/schema";
4
+ import { eq } from "drizzle-orm";
5
+ import { randomUUID } from "crypto";
6
+ import { reconcileStreamingMessages } from "../reconcile";
7
+
8
+ function seedConversation(): string {
9
+ const id = randomUUID();
10
+ const now = new Date();
11
+ db.insert(conversations)
12
+ .values({
13
+ id,
14
+ runtimeId: "test-runtime",
15
+ status: "active",
16
+ createdAt: now,
17
+ updatedAt: now,
18
+ })
19
+ .run();
20
+ return id;
21
+ }
22
+
23
+ function seedMessage(opts: {
24
+ conversationId: string;
25
+ status: "streaming" | "complete" | "error";
26
+ content: string;
27
+ createdAt: Date;
28
+ }): string {
29
+ const id = randomUUID();
30
+ db.insert(chatMessages)
31
+ .values({
32
+ id,
33
+ conversationId: opts.conversationId,
34
+ role: "assistant",
35
+ content: opts.content,
36
+ status: opts.status,
37
+ createdAt: opts.createdAt,
38
+ })
39
+ .run();
40
+ return id;
41
+ }
42
+
43
+ describe("reconcileStreamingMessages", () => {
44
+ beforeEach(() => {
45
+ // Isolate each test
46
+ db.delete(chatMessages).run();
47
+ db.delete(conversations).run();
48
+ });
49
+
50
+ it("sweeps a 20-min-old streaming row with empty content to error state with fallback", async () => {
51
+ const convId = seedConversation();
52
+ const twentyMinAgo = new Date(Date.now() - 20 * 60 * 1000);
53
+ const msgId = seedMessage({
54
+ conversationId: convId,
55
+ status: "streaming",
56
+ content: "",
57
+ createdAt: twentyMinAgo,
58
+ });
59
+
60
+ const swept = await reconcileStreamingMessages();
61
+
62
+ expect(swept).toBe(1);
63
+ const row = db
64
+ .select()
65
+ .from(chatMessages)
66
+ .where(eq(chatMessages.id, msgId))
67
+ .get();
68
+ expect(row?.status).toBe("error");
69
+ expect(row?.content).toMatch(/Interrupted/i);
70
+ expect(row?.content.length).toBeGreaterThan(0);
71
+ });
72
+
73
+ it("leaves a 30-second-old streaming row untouched", async () => {
74
+ const convId = seedConversation();
75
+ const thirtySecAgo = new Date(Date.now() - 30 * 1000);
76
+ const msgId = seedMessage({
77
+ conversationId: convId,
78
+ status: "streaming",
79
+ content: "",
80
+ createdAt: thirtySecAgo,
81
+ });
82
+
83
+ const swept = await reconcileStreamingMessages();
84
+
85
+ expect(swept).toBe(0);
86
+ const row = db
87
+ .select()
88
+ .from(chatMessages)
89
+ .where(eq(chatMessages.id, msgId))
90
+ .get();
91
+ expect(row?.status).toBe("streaming");
92
+ expect(row?.content).toBe("");
93
+ });
94
+
95
+ it("preserves partial content when sweeping old streaming row", async () => {
96
+ const convId = seedConversation();
97
+ const twentyMinAgo = new Date(Date.now() - 20 * 60 * 1000);
98
+ const msgId = seedMessage({
99
+ conversationId: convId,
100
+ status: "streaming",
101
+ content: "Here is what I found so",
102
+ createdAt: twentyMinAgo,
103
+ });
104
+
105
+ await reconcileStreamingMessages();
106
+
107
+ const row = db
108
+ .select()
109
+ .from(chatMessages)
110
+ .where(eq(chatMessages.id, msgId))
111
+ .get();
112
+ expect(row?.status).toBe("error");
113
+ expect(row?.content).toBe("Here is what I found so");
114
+ });
115
+
116
+ it("leaves complete messages untouched regardless of age", async () => {
117
+ const convId = seedConversation();
118
+ const twentyMinAgo = new Date(Date.now() - 20 * 60 * 1000);
119
+ const msgId = seedMessage({
120
+ conversationId: convId,
121
+ status: "complete",
122
+ content: "Finished response",
123
+ createdAt: twentyMinAgo,
124
+ });
125
+
126
+ const swept = await reconcileStreamingMessages();
127
+
128
+ expect(swept).toBe(0);
129
+ const row = db
130
+ .select()
131
+ .from(chatMessages)
132
+ .where(eq(chatMessages.id, msgId))
133
+ .get();
134
+ expect(row?.status).toBe("complete");
135
+ expect(row?.content).toBe("Finished response");
136
+ });
137
+ });
@@ -0,0 +1,151 @@
1
+ import { describe, it, expect, beforeEach } from "vitest";
2
+ import {
3
+ recordTermination,
4
+ readTerminations,
5
+ countTerminations,
6
+ __resetForTesting,
7
+ } from "../stream-telemetry";
8
+
9
+ describe("stream-telemetry ring buffer", () => {
10
+ beforeEach(() => {
11
+ __resetForTesting();
12
+ });
13
+
14
+ it("returns [] before any events are recorded", () => {
15
+ expect(readTerminations()).toEqual([]);
16
+ });
17
+
18
+ it("records events in chronological order", () => {
19
+ recordTermination({
20
+ reason: "stream.completed",
21
+ conversationId: "c1",
22
+ messageId: "m1",
23
+ durationMs: 100,
24
+ });
25
+ recordTermination({
26
+ reason: "stream.aborted.client",
27
+ conversationId: "c2",
28
+ messageId: "m2",
29
+ durationMs: 50,
30
+ });
31
+
32
+ const events = readTerminations();
33
+ expect(events).toHaveLength(2);
34
+ expect(events[0].reason).toBe("stream.completed");
35
+ expect(events[1].reason).toBe("stream.aborted.client");
36
+ expect(events[0].timestamp).toBeLessThanOrEqual(events[1].timestamp);
37
+ });
38
+
39
+ it("stamps each event with a timestamp", () => {
40
+ const before = Date.now();
41
+ recordTermination({
42
+ reason: "stream.completed",
43
+ conversationId: "c1",
44
+ messageId: "m1",
45
+ durationMs: 0,
46
+ });
47
+ const after = Date.now();
48
+ const events = readTerminations();
49
+ expect(events[0].timestamp).toBeGreaterThanOrEqual(before);
50
+ expect(events[0].timestamp).toBeLessThanOrEqual(after);
51
+ });
52
+
53
+ it("wraps around after 500 events, preserving newest-500 in order", () => {
54
+ // Write 520 events — first 20 should be evicted.
55
+ for (let i = 0; i < 520; i++) {
56
+ recordTermination({
57
+ reason: "stream.completed",
58
+ conversationId: `c${i}`,
59
+ messageId: `m${i}`,
60
+ durationMs: i,
61
+ });
62
+ }
63
+
64
+ const events = readTerminations();
65
+ expect(events).toHaveLength(500);
66
+ // Oldest surviving event should be #20; newest should be #519.
67
+ expect(events[0].conversationId).toBe("c20");
68
+ expect(events[0].durationMs).toBe(20);
69
+ expect(events[499].conversationId).toBe("c519");
70
+ expect(events[499].durationMs).toBe(519);
71
+ });
72
+
73
+ it("countTerminations groups by reason code across the full buffer", () => {
74
+ recordTermination({ reason: "stream.completed", conversationId: "c", messageId: "m", durationMs: 1 });
75
+ recordTermination({ reason: "stream.completed", conversationId: "c", messageId: "m", durationMs: 1 });
76
+ recordTermination({ reason: "stream.aborted.client", conversationId: "c", messageId: "m", durationMs: 1 });
77
+ recordTermination({ reason: "stream.finalized.error", conversationId: "c", messageId: "m", durationMs: 1, error: "boom" });
78
+ recordTermination({ reason: "stream.abandoned", conversationId: "c", messageId: "m", durationMs: 42 });
79
+
80
+ const counts = countTerminations();
81
+ expect(counts["stream.completed"]).toBe(2);
82
+ expect(counts["stream.aborted.client"]).toBe(1);
83
+ expect(counts["stream.finalized.error"]).toBe(1);
84
+ expect(counts["stream.abandoned"]).toBe(1);
85
+ expect(counts["stream.aborted.signal"]).toBe(0);
86
+ expect(counts["stream.reconciled.stale"]).toBe(0);
87
+ });
88
+
89
+ it("stream.abandoned is a valid reason code for iterator abandonment", () => {
90
+ // finalizeStreamingMessage records this when the engine's happy and
91
+ // catch paths both missed the termination — the canonical "gap"
92
+ // indicator. Make sure it round-trips through the buffer.
93
+ recordTermination({
94
+ reason: "stream.abandoned",
95
+ conversationId: "c1",
96
+ messageId: "m1",
97
+ durationMs: 100,
98
+ error: "no content streamed before abandonment",
99
+ });
100
+ const events = readTerminations();
101
+ expect(events[0].reason).toBe("stream.abandoned");
102
+ expect(events[0].error).toBe("no content streamed before abandonment");
103
+ });
104
+
105
+ it("countTerminations honors the windowMs filter", async () => {
106
+ recordTermination({ reason: "stream.completed", conversationId: "c", messageId: "m", durationMs: 1 });
107
+ // Wait a few ms so the second event has a strictly later timestamp.
108
+ await new Promise((r) => setTimeout(r, 10));
109
+ const midpoint = Date.now();
110
+ await new Promise((r) => setTimeout(r, 10));
111
+ recordTermination({ reason: "stream.completed", conversationId: "c", messageId: "m", durationMs: 1 });
112
+
113
+ // Use a window that only includes the second event.
114
+ const windowMs = Date.now() - midpoint + 5;
115
+ const counts = countTerminations(windowMs);
116
+ expect(counts["stream.completed"]).toBe(1);
117
+ });
118
+
119
+ it("readTerminations returns a copy, not a live reference", () => {
120
+ recordTermination({ reason: "stream.completed", conversationId: "c", messageId: "m", durationMs: 1 });
121
+ const first = readTerminations();
122
+ recordTermination({ reason: "stream.completed", conversationId: "c2", messageId: "m2", durationMs: 1 });
123
+ // first snapshot should still have only the initial event.
124
+ expect(first).toHaveLength(1);
125
+ expect(readTerminations()).toHaveLength(2);
126
+ });
127
+
128
+ it("records optional error strings on error events", () => {
129
+ recordTermination({
130
+ reason: "stream.finalized.error",
131
+ conversationId: "c",
132
+ messageId: "m",
133
+ durationMs: 42,
134
+ error: "boom",
135
+ });
136
+ expect(readTerminations()[0].error).toBe("boom");
137
+ });
138
+
139
+ it("allows null conversationId / messageId / durationMs for edge cases", () => {
140
+ recordTermination({
141
+ reason: "stream.reconciled.stale",
142
+ conversationId: null,
143
+ messageId: null,
144
+ durationMs: null,
145
+ });
146
+ const events = readTerminations();
147
+ expect(events[0].conversationId).toBeNull();
148
+ expect(events[0].messageId).toBeNull();
149
+ expect(events[0].durationMs).toBeNull();
150
+ });
151
+ });
@@ -0,0 +1,27 @@
1
+ /**
2
+ * In-memory tracker for chat conversations that currently have an SSE stream
3
+ * in flight. Used by the scheduler tick loop to apply a soft pressure signal
4
+ * — when chat is active, new schedule firings are deferred by N seconds to
5
+ * keep the Node event loop responsive for the user's conversation.
6
+ *
7
+ * Module-level state; single-process (same Node instance as the scheduler).
8
+ * Must NOT be persisted — crash recovery relies on the set starting empty.
9
+ */
10
+
11
+ const activeStreams = new Set<string>();
12
+
13
+ export function registerChatStream(conversationId: string): void {
14
+ activeStreams.add(conversationId);
15
+ }
16
+
17
+ export function unregisterChatStream(conversationId: string): void {
18
+ activeStreams.delete(conversationId);
19
+ }
20
+
21
+ export function getActiveChatStreamCount(): number {
22
+ return activeStreams.size;
23
+ }
24
+
25
+ export function isAnyChatStreaming(): boolean {
26
+ return activeStreams.size > 0;
27
+ }
@@ -2,7 +2,10 @@ import { db } from "@/lib/db";
2
2
  import { projects } from "@/lib/db/schema";
3
3
  import { eq } from "drizzle-orm";
4
4
  import { CodexAppServerClient } from "@/lib/agents/runtime/codex-app-server-client";
5
- import { getOpenAIApiKey } from "@/lib/settings/openai-auth";
5
+ import {
6
+ ensureOpenAICodexClientAuthenticated,
7
+ resolveOpenAICodexAuthContext,
8
+ } from "@/lib/agents/runtime/openai-codex-auth";
6
9
  import {
7
10
  extractUsageSnapshot,
8
11
  mergeUsageSnapshot,
@@ -128,11 +131,17 @@ export async function* sendCodexMessage(
128
131
  });
129
132
 
130
133
  // Get OpenAI API key
131
- const { apiKey } = await getOpenAIApiKey();
132
- if (!apiKey) {
133
- await updateMessageContent(assistantMsg.id, "OpenAI API key is not configured. Add it in Settings → Auth.");
134
+ let auth;
135
+ try {
136
+ auth = await resolveOpenAICodexAuthContext();
137
+ } catch (error) {
138
+ const message =
139
+ error instanceof Error
140
+ ? error.message
141
+ : "OpenAI Codex authentication is not configured.";
142
+ await updateMessageContent(assistantMsg.id, message);
134
143
  await updateMessageStatus(assistantMsg.id, "error");
135
- yield { type: "error", message: "OpenAI API key is not configured. Add it in Settings → Auth." };
144
+ yield { type: "error", message };
136
145
  return;
137
146
  }
138
147
 
@@ -164,20 +173,10 @@ export async function* sendCodexMessage(
164
173
  }
165
174
 
166
175
  try {
167
- client = await CodexAppServerClient.connect({
168
- cwd: workspace.cwd,
169
- env: { OPENAI_API_KEY: apiKey },
170
- });
176
+ client = await auth.connect(workspace.cwd);
171
177
 
172
178
  // Initialize and authenticate
173
- await client.request("initialize", {
174
- clientInfo: { name: "Stagent", version: "0.1.1" },
175
- capabilities: null,
176
- });
177
- await client.request("account/login/start", {
178
- type: "apiKey",
179
- apiKey,
180
- });
179
+ await ensureOpenAICodexClientAuthenticated(client, auth);
181
180
 
182
181
  // Validate model availability against what the user's account supports
183
182
  let validatedModel: string | undefined;
@@ -108,7 +108,7 @@ async function buildTier2(projectId?: string | null): Promise<string> {
108
108
  if (recentTasks.length > 0) {
109
109
  parts.push("\n### Recent Tasks");
110
110
  for (const t of recentTasks) {
111
- parts.push(`- [${t.status}] ${t.title} (id: ${t.id.slice(0, 8)})`);
111
+ parts.push(`- [${t.status}] ${t.title} (id: ${t.id})`);
112
112
  }
113
113
  }
114
114
 
@@ -123,7 +123,7 @@ async function buildTier2(projectId?: string | null): Promise<string> {
123
123
  if (activeWorkflows.length > 0) {
124
124
  parts.push("\n### Workflows");
125
125
  for (const w of activeWorkflows) {
126
- parts.push(`- [${w.status}] ${w.name} (id: ${w.id.slice(0, 8)})`);
126
+ parts.push(`- [${w.status}] ${w.name} (id: ${w.id})`);
127
127
  }
128
128
  }
129
129
 
@@ -137,7 +137,7 @@ async function buildTier2(projectId?: string | null): Promise<string> {
137
137
  if (docs.length > 0) {
138
138
  parts.push(`\n### Documents (${docs.length})`);
139
139
  for (const d of docs) {
140
- parts.push(`- ${d.filename} (id: ${d.id.slice(0, 8)})`);
140
+ parts.push(`- ${d.filename} (id: ${d.id})`);
141
141
  }
142
142
  }
143
143
 
@@ -285,6 +285,7 @@ async function buildTier3(mentions: MentionReference[]): Promise<string> {
285
285
  return truncateToTokenBudget(text, TIER_3_BUDGET);
286
286
  }
287
287
 
288
+
288
289
  // ── Public API ─────────────────────────────────────────────────────────
289
290
 
290
291
  export interface ChatContext {
@@ -312,6 +313,7 @@ export async function buildChatContext(opts: {
312
313
  const tier0 = buildTier0(opts.projectName, opts.workspace);
313
314
 
314
315
  const systemParts = [tier0];
316
+
315
317
  if (tier3) systemParts.push(tier3);
316
318
  if (tier2) systemParts.push(tier2);
317
319