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,408 @@
1
+ import { act, render, screen, waitFor } from "@testing-library/react";
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3
+ import { useState } from "react";
4
+
5
+ import type { ChatMessageRow } from "@/lib/db/schema";
6
+ import {
7
+ ChatSessionProvider,
8
+ useChatSession,
9
+ } from "@/components/chat/chat-session-provider";
10
+
11
+ // Satisfy the type import linter — we use ChatMessageRow in the Consumer
12
+ // probes below but through inference from session.messages.
13
+ void ({} as ChatMessageRow | undefined);
14
+
15
+ // ── Next.js router mock ──────────────────────────────────────────────
16
+ vi.mock("next/navigation", () => ({
17
+ useRouter: () => ({ replace: vi.fn(), push: vi.fn() }),
18
+ }));
19
+
20
+ // ── Sonner mock ──────────────────────────────────────────────────────
21
+ const toastErrorSpy = vi.fn();
22
+ vi.mock("sonner", () => ({
23
+ toast: {
24
+ error: (...args: unknown[]) => toastErrorSpy(...args),
25
+ },
26
+ }));
27
+
28
+ // ── Test helpers ─────────────────────────────────────────────────────
29
+
30
+ /**
31
+ * Small consumer component that exposes the session value via test ids.
32
+ * Text probes let us assert state without wiring up the full ChatShell.
33
+ */
34
+ function Consumer({ label }: { label?: string }) {
35
+ const session = useChatSession();
36
+ return (
37
+ <div>
38
+ <div data-testid={`${label ?? "c"}-active`}>{session.activeId ?? ""}</div>
39
+ <div data-testid={`${label ?? "c"}-isStreaming`}>
40
+ {String(session.isStreaming)}
41
+ </div>
42
+ <div data-testid={`${label ?? "c"}-messageCount`}>
43
+ {session.messages.length}
44
+ </div>
45
+ <div data-testid={`${label ?? "c"}-assistantContent`}>
46
+ {session.messages
47
+ .filter((m: ChatMessageRow) => m.role === "assistant")
48
+ .map((m: ChatMessageRow) => m.content)
49
+ .join("|")}
50
+ </div>
51
+ <button
52
+ data-testid={`${label ?? "c"}-send`}
53
+ onClick={() => void session.sendMessage("hello")}
54
+ >
55
+ send
56
+ </button>
57
+ <button
58
+ data-testid={`${label ?? "c"}-stop`}
59
+ onClick={() => session.stopStreaming()}
60
+ >
61
+ stop
62
+ </button>
63
+ <button
64
+ data-testid={`${label ?? "c"}-select`}
65
+ onClick={() => session.setActiveConversation("conv-1")}
66
+ >
67
+ select
68
+ </button>
69
+ <button
70
+ data-testid={`${label ?? "c"}-hydrate`}
71
+ onClick={() =>
72
+ session.hydrate({
73
+ conversations: [
74
+ {
75
+ id: "conv-1",
76
+ projectId: null,
77
+ title: "Test conv",
78
+ status: "active",
79
+ runtimeId: "claude-code",
80
+ modelId: "sonnet",
81
+ createdAt: new Date(),
82
+ updatedAt: new Date(),
83
+ archivedAt: null,
84
+ } as unknown as never,
85
+ ],
86
+ initialActiveId: "conv-1",
87
+ })
88
+ }
89
+ >
90
+ hydrate
91
+ </button>
92
+ </div>
93
+ );
94
+ }
95
+
96
+ /**
97
+ * A wrapper that keeps the provider mounted while letting tests mount and
98
+ * unmount a child consumer on demand. This is how we verify that state
99
+ * survives a consumer unmount/remount cycle — the provider is stable, only
100
+ * the child toggles.
101
+ */
102
+ function ProviderWithToggle() {
103
+ const [show, setShow] = useState(true);
104
+ return (
105
+ <ChatSessionProvider>
106
+ <button data-testid="toggle" onClick={() => setShow((v) => !v)}>
107
+ toggle
108
+ </button>
109
+ <div data-testid="consumer-visible">{String(show)}</div>
110
+ {show && <Consumer />}
111
+ </ChatSessionProvider>
112
+ );
113
+ }
114
+
115
+ /**
116
+ * Build a ReadableStream that emits the given SSE chunks as `data: ...` lines.
117
+ * Each chunk is JSON-serialized and prefixed with `data: ` + newline.
118
+ */
119
+ function makeSSEStream(
120
+ chunks: unknown[],
121
+ opts: { closeAfterMs?: number } = {}
122
+ ): ReadableStream<Uint8Array> {
123
+ const encoder = new TextEncoder();
124
+ return new ReadableStream({
125
+ async start(controller) {
126
+ for (const chunk of chunks) {
127
+ const line = `data: ${JSON.stringify(chunk)}\n`;
128
+ controller.enqueue(encoder.encode(line));
129
+ // Tiny yield so React can flush state between chunks.
130
+ await new Promise((r) => setTimeout(r, 0));
131
+ }
132
+ if (opts.closeAfterMs) {
133
+ await new Promise((r) => setTimeout(r, opts.closeAfterMs));
134
+ }
135
+ controller.close();
136
+ },
137
+ });
138
+ }
139
+
140
+ /**
141
+ * Build a ReadableStream that waits indefinitely (useful for testing
142
+ * abort behavior). Signal-aware: closes early if signal aborts.
143
+ */
144
+ function makeHangingStream(signal: AbortSignal): ReadableStream<Uint8Array> {
145
+ return new ReadableStream({
146
+ start(controller) {
147
+ signal.addEventListener("abort", () => {
148
+ controller.error(
149
+ Object.assign(new Error("aborted"), { name: "AbortError" })
150
+ );
151
+ });
152
+ },
153
+ });
154
+ }
155
+
156
+ // ── Suites ───────────────────────────────────────────────────────────
157
+
158
+ describe("ChatSessionProvider", () => {
159
+ beforeEach(() => {
160
+ toastErrorSpy.mockReset();
161
+ vi.stubGlobal("crypto", {
162
+ randomUUID: () => `uuid-${Math.random().toString(36).slice(2, 10)}`,
163
+ });
164
+ });
165
+
166
+ afterEach(() => {
167
+ vi.unstubAllGlobals();
168
+ vi.restoreAllMocks();
169
+ });
170
+
171
+ it("sendMessage accumulates SSE deltas into the assistant message", async () => {
172
+ const fetchMock = vi.fn(async (url: RequestInfo | URL) => {
173
+ const u = url.toString();
174
+ if (u.startsWith("/api/settings/chat")) return new Response(null, { status: 204 });
175
+ if (u.startsWith("/api/chat/models")) return new Response(null, { status: 204 });
176
+ if (u === "/api/chat/conversations" || u.endsWith("/api/chat/conversations")) {
177
+ return new Response(
178
+ JSON.stringify({
179
+ id: "conv-new",
180
+ projectId: null,
181
+ title: "New Chat",
182
+ status: "active",
183
+ runtimeId: "claude-code",
184
+ modelId: "haiku",
185
+ createdAt: new Date().toISOString(),
186
+ updatedAt: new Date().toISOString(),
187
+ }),
188
+ { status: 200 }
189
+ );
190
+ }
191
+ if (u.match(/\/api\/chat\/conversations\/conv-new\/messages$/)) {
192
+ return new Response(
193
+ makeSSEStream([
194
+ { type: "delta", content: "Hello" },
195
+ { type: "delta", content: " world" },
196
+ { type: "done", messageId: "msg-final", quickAccess: [] },
197
+ ]),
198
+ { status: 200 }
199
+ );
200
+ }
201
+ if (u.startsWith("/api/chat/conversations/conv-new")) {
202
+ // GET metadata refresh after "done" event
203
+ return new Response(
204
+ JSON.stringify({ id: "conv-new", title: "Auto Title" }),
205
+ { status: 200 }
206
+ );
207
+ }
208
+ return new Response(null, { status: 404 });
209
+ });
210
+ vi.stubGlobal("fetch", fetchMock);
211
+
212
+ render(
213
+ <ChatSessionProvider>
214
+ <Consumer />
215
+ </ChatSessionProvider>
216
+ );
217
+
218
+ await act(async () => {
219
+ screen.getByTestId("c-send").click();
220
+ });
221
+
222
+ await waitFor(() => {
223
+ expect(screen.getByTestId("c-assistantContent").textContent).toBe(
224
+ "Hello world"
225
+ );
226
+ expect(screen.getByTestId("c-isStreaming").textContent).toBe("false");
227
+ });
228
+ });
229
+
230
+ it("preserves messages across consumer unmount/remount", async () => {
231
+ // Seed state: hydrate with conv-1 (fetch returns empty message list),
232
+ // then send a message and verify it's visible. Then toggle the consumer
233
+ // off and back on and verify the messages are still there.
234
+ const fetchMock = vi.fn(async (url: RequestInfo | URL) => {
235
+ const u = url.toString();
236
+ if (u.startsWith("/api/settings/chat")) return new Response(null, { status: 204 });
237
+ if (u.startsWith("/api/chat/models")) return new Response(null, { status: 204 });
238
+ if (u.match(/\/api\/chat\/conversations\/conv-1\/messages$/)) {
239
+ // Support both GET (select refresh) and POST (send)
240
+ // We can distinguish in a real test but here both return empty/delta
241
+ // If POST, return the SSE stream. Differentiate by checking if there's a body.
242
+ return new Response(
243
+ makeSSEStream([
244
+ { type: "delta", content: "persisted" },
245
+ { type: "done", messageId: "msg-a", quickAccess: [] },
246
+ ]),
247
+ { status: 200 }
248
+ );
249
+ }
250
+ if (u.startsWith("/api/chat/conversations/conv-1")) {
251
+ return new Response(
252
+ JSON.stringify({ id: "conv-1", title: "T" }),
253
+ { status: 200 }
254
+ );
255
+ }
256
+ return new Response(null, { status: 404 });
257
+ });
258
+ vi.stubGlobal("fetch", fetchMock);
259
+
260
+ render(<ProviderWithToggle />);
261
+
262
+ // Hydrate (sets conv-1 as active) and select
263
+ await act(async () => {
264
+ screen.getByTestId("c-hydrate").click();
265
+ });
266
+ await act(async () => {
267
+ screen.getByTestId("c-send").click();
268
+ });
269
+
270
+ await waitFor(() => {
271
+ expect(screen.getByTestId("c-assistantContent").textContent).toBe(
272
+ "persisted"
273
+ );
274
+ });
275
+
276
+ // Unmount the consumer
277
+ await act(async () => {
278
+ screen.getByTestId("toggle").click();
279
+ });
280
+ expect(screen.queryByTestId("c-assistantContent")).toBeNull();
281
+ expect(screen.getByTestId("consumer-visible").textContent).toBe("false");
282
+
283
+ // Remount the consumer — provider state should still be there
284
+ await act(async () => {
285
+ screen.getByTestId("toggle").click();
286
+ });
287
+ await waitFor(() => {
288
+ expect(screen.getByTestId("c-assistantContent").textContent).toBe(
289
+ "persisted"
290
+ );
291
+ });
292
+ });
293
+
294
+ it("selectConversation fetch failure calls toast.error and does not clear state", async () => {
295
+ // The bug this test pins down: `handleSelectConversation`'s old catch
296
+ // block was `setMessages([])`, which wiped all prior turns on any
297
+ // fetch hiccup. The fix: on failure, call toast.error and leave
298
+ // messagesByConversation untouched.
299
+ const fetchMock = vi.fn(async (url: RequestInfo | URL, init?: RequestInit) => {
300
+ const u = url.toString();
301
+ const method = init?.method ?? "GET";
302
+ if (u.startsWith("/api/settings/chat")) return new Response(null, { status: 204 });
303
+ if (u.startsWith("/api/chat/models")) return new Response(null, { status: 204 });
304
+ if (u.match(/\/api\/chat\/conversations\/conv-missing\/messages$/) && method === "GET") {
305
+ return new Response("boom", { status: 500 });
306
+ }
307
+ if (u.startsWith("/api/chat/conversations/conv-missing")) {
308
+ return new Response(JSON.stringify({ id: "conv-missing" }), { status: 200 });
309
+ }
310
+ return new Response(null, { status: 404 });
311
+ });
312
+ vi.stubGlobal("fetch", fetchMock);
313
+
314
+ // Custom consumer that exposes a button to select a specific (failing) conversation
315
+ function FailingSelectConsumer() {
316
+ const session = useChatSession();
317
+ return (
318
+ <div>
319
+ <div data-testid="cache-keys">
320
+ {Object.keys(session.conversations.length ? { placeholder: 1 } : {}).join(",")}
321
+ </div>
322
+ <button
323
+ data-testid="select-failing"
324
+ onClick={() => {
325
+ // Directly call setActiveConversation with an id that has no
326
+ // cache entry — this triggers loadMessagesForConversation,
327
+ // which will hit the failing mock.
328
+ session.setActiveConversation("conv-missing");
329
+ }}
330
+ >
331
+ select failing
332
+ </button>
333
+ </div>
334
+ );
335
+ }
336
+
337
+ render(
338
+ <ChatSessionProvider>
339
+ <FailingSelectConsumer />
340
+ </ChatSessionProvider>
341
+ );
342
+
343
+ await act(async () => {
344
+ screen.getByTestId("select-failing").click();
345
+ });
346
+
347
+ // The fetch fails → toast.error must be called. Prior to the fix,
348
+ // the code would have called `setMessages([])`. Now it calls toast and
349
+ // leaves state alone.
350
+ await waitFor(() => {
351
+ expect(toastErrorSpy).toHaveBeenCalledWith(
352
+ "Failed to load conversation messages"
353
+ );
354
+ });
355
+ });
356
+
357
+ it("stopStreaming aborts an in-flight stream", async () => {
358
+ const fetchMock = vi.fn(async (url: RequestInfo | URL, init?: RequestInit) => {
359
+ const u = url.toString();
360
+ if (u.startsWith("/api/settings/chat")) return new Response(null, { status: 204 });
361
+ if (u.startsWith("/api/chat/models")) return new Response(null, { status: 204 });
362
+ if (u === "/api/chat/conversations" || u.endsWith("/api/chat/conversations")) {
363
+ return new Response(
364
+ JSON.stringify({
365
+ id: "conv-abort",
366
+ projectId: null,
367
+ title: "T",
368
+ status: "active",
369
+ runtimeId: "claude-code",
370
+ modelId: "haiku",
371
+ createdAt: new Date().toISOString(),
372
+ updatedAt: new Date().toISOString(),
373
+ }),
374
+ { status: 200 }
375
+ );
376
+ }
377
+ if (u.match(/\/api\/chat\/conversations\/conv-abort\/messages$/)) {
378
+ const signal = init?.signal as AbortSignal;
379
+ return new Response(makeHangingStream(signal), { status: 200 });
380
+ }
381
+ return new Response(null, { status: 404 });
382
+ });
383
+ vi.stubGlobal("fetch", fetchMock);
384
+
385
+ render(
386
+ <ChatSessionProvider>
387
+ <Consumer />
388
+ </ChatSessionProvider>
389
+ );
390
+
391
+ await act(async () => {
392
+ screen.getByTestId("c-send").click();
393
+ });
394
+
395
+ // Give the fetch a microtask to kick off
396
+ await waitFor(() => {
397
+ expect(screen.getByTestId("c-isStreaming").textContent).toBe("true");
398
+ });
399
+
400
+ await act(async () => {
401
+ screen.getByTestId("c-stop").click();
402
+ });
403
+
404
+ await waitFor(() => {
405
+ expect(screen.getByTestId("c-isStreaming").textContent).toBe("false");
406
+ });
407
+ });
408
+ });
@@ -184,8 +184,8 @@ function ToolCatalogItems({
184
184
  text: entry.behavior === "execute_immediately"
185
185
  ? entry.name
186
186
  : entry.group === "Skills"
187
- ? `Use the ${entry.name} profile: `
188
- : `Use ${entry.name} to `,
187
+ ? `Use the ${entry.name} profile: `
188
+ : `Use ${entry.name} to `,
189
189
  })
190
190
  }
191
191
  >
@@ -9,6 +9,7 @@ import { ChatCommandPopover } from "./chat-command-popover";
9
9
  import { useChatAutocomplete, type MentionReference } from "@/hooks/use-chat-autocomplete";
10
10
  import { getToolCatalog } from "@/lib/chat/tool-catalog";
11
11
  import { useProjectSkills } from "@/hooks/use-project-skills";
12
+ import { toggleTheme } from "@/lib/theme";
12
13
  import type { ChatModelOption } from "@/lib/chat/types";
13
14
 
14
15
  interface ChatInputProps {
@@ -112,9 +113,7 @@ export function ChatInput({
112
113
  if (entry?.behavior === "execute_immediately") {
113
114
  autocomplete.close();
114
115
  if (entry.name === "toggle_theme") {
115
- const isDark = document.documentElement.classList.contains("dark");
116
- document.documentElement.classList.toggle("dark");
117
- localStorage.setItem("stagent-theme", isDark ? "light" : "dark");
116
+ toggleTheme();
118
117
  } else if (entry.name === "mark_all_read") {
119
118
  fetch("/api/notifications/mark-all-read", { method: "PATCH" });
120
119
  }