@k2b/cloud 0.6.0 → 0.8.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 (349) hide show
  1. package/README.md +2 -2
  2. package/package.json +22 -11
  3. package/scripts/README.md +10 -0
  4. package/scripts/browser-performance.ts +14 -0
  5. package/scripts/build-pdf-renderer.ts +39 -0
  6. package/scripts/build.ts +20 -35
  7. package/scripts/preload.ts +16 -12
  8. package/scripts/sync-recovery-smoke.ts +22 -6
  9. package/src/_internal/app-presentation.ts +14 -2
  10. package/src/_internal/build-metadata.ts +8 -1
  11. package/src/_internal/capabilities.ts +131 -7
  12. package/src/_internal/capability-streams.ts +81 -0
  13. package/src/_internal/define-app.ts +80 -37
  14. package/src/_internal/heartbeat.ts +4 -0
  15. package/src/_internal/help-catalog.ts +27 -172
  16. package/src/_internal/help.ts +13 -76
  17. package/src/_internal/page-responses.ts +2 -0
  18. package/src/_internal/postgres-application-name.ts +30 -0
  19. package/src/_internal/process-identity.ts +15 -0
  20. package/src/_internal/process-sync.ts +1 -1
  21. package/src/_internal/readiness.ts +4 -1
  22. package/src/_internal/registry-validation.ts +29 -25
  23. package/src/_internal/registry.ts +2 -53
  24. package/src/_internal/runtime-context.ts +5 -16
  25. package/src/_internal/server-timing.ts +13 -0
  26. package/src/_internal/status-preserving-ssr.ts +18 -6
  27. package/src/_internal/web-vitals-asset.ts +10 -0
  28. package/src/access/PermissionEditor.tsx +18 -151
  29. package/src/access/PrincipalPicker.tsx +110 -0
  30. package/src/access/ResourceApiKeys.tsx +1 -1
  31. package/src/access/ui.ts +1 -0
  32. package/src/ai/admin.ts +14 -13
  33. package/src/ai/assistant-models.ts +12 -1
  34. package/src/ai/audio-format.ts +34 -0
  35. package/src/ai/audio-tool.ts +90 -0
  36. package/src/ai/browser-code-contracts.ts +201 -0
  37. package/src/ai/browser.ts +4 -0
  38. package/src/ai/capabilities.ts +55 -36
  39. package/src/ai/capability-execution.ts +47 -12
  40. package/src/ai/chat/blocks.tsx +229 -90
  41. package/src/ai/chat/builtin-tools.tsx +2 -4
  42. package/src/ai/chat/capability-result.ts +15 -0
  43. package/src/ai/chat/capability-table.tsx +2 -10
  44. package/src/ai/chat/composer-adapter.ts +83 -3
  45. package/src/ai/chat/message-actions.tsx +8 -7
  46. package/src/ai/chat/message-utils.ts +25 -1
  47. package/src/ai/chat/messages.ts +2 -0
  48. package/src/ai/chat/presentation.tsx +31 -47
  49. package/src/ai/chat/primitives.tsx +43 -1
  50. package/src/ai/chat/tool-disclosure.tsx +21 -2
  51. package/src/ai/chat/tool-groups.ts +58 -0
  52. package/src/ai/chat-quotas.ts +25 -0
  53. package/src/ai/chat-task-contracts.ts +14 -10
  54. package/src/ai/chat-tasks.ts +297 -43
  55. package/src/ai/client/controller.ts +165 -39
  56. package/src/ai/client/file-source.ts +12 -6
  57. package/src/ai/client/live-connection.ts +1 -0
  58. package/src/ai/client/projection.ts +29 -11
  59. package/src/ai/client/transport.ts +6 -0
  60. package/src/ai/code-capability-routes.ts +105 -0
  61. package/src/ai/code-capability-transport.ts +39 -0
  62. package/src/ai/code-execution.ts +53 -0
  63. package/src/ai/code-mode-skill.ts +118 -0
  64. package/src/ai/code-runtime-tools.ts +179 -0
  65. package/src/ai/code-source-contracts.ts +270 -0
  66. package/src/ai/code-source-tools.ts +188 -0
  67. package/src/ai/compaction.ts +27 -6
  68. package/src/ai/data-analysis-skill.ts +10 -0
  69. package/src/ai/default-tools.ts +99 -3
  70. package/src/ai/dictation-runtime.ts +171 -0
  71. package/src/ai/dictations.ts +188 -0
  72. package/src/ai/draft-content.ts +49 -0
  73. package/src/ai/enrich.ts +6 -0
  74. package/src/ai/executor.ts +189 -33
  75. package/src/ai/fetch-file-tool.ts +2 -2
  76. package/src/ai/file-content-version.ts +27 -0
  77. package/src/ai/file-context.ts +2 -1
  78. package/src/ai/file-media-type.ts +12 -0
  79. package/src/ai/file-reference-contracts.ts +15 -0
  80. package/src/ai/file-tools.ts +8 -1
  81. package/src/ai/files-store.ts +212 -71
  82. package/src/ai/fixtures/vision-encrypted.pdf +0 -0
  83. package/src/ai/grids-skill.ts +42 -10
  84. package/src/ai/http.ts +26 -8
  85. package/src/ai/index.ts +26 -3
  86. package/src/ai/inference-calls.ts +197 -0
  87. package/src/ai/live-events.ts +5 -1
  88. package/src/ai/live.ts +1 -1
  89. package/src/ai/markdown-pdf-tool.ts +2 -2
  90. package/src/ai/memories.ts +30 -27
  91. package/src/ai/memory-learning-runs.ts +1 -1
  92. package/src/ai/memory-learning.ts +64 -32
  93. package/src/ai/memory-tool.ts +1 -5
  94. package/src/ai/memory-workflow-evidence.ts +9 -4
  95. package/src/ai/message-queue.ts +178 -0
  96. package/src/ai/migrate.ts +189 -11
  97. package/src/ai/model-pricing.ts +43 -0
  98. package/src/ai/pdf-render-worker.ts +75 -0
  99. package/src/ai/pdf-render.fixture.ts +28 -0
  100. package/src/ai/pdf-render.ts +77 -0
  101. package/src/ai/projects-routes.ts +18 -43
  102. package/src/ai/projects.ts +33 -14
  103. package/src/ai/protocol.ts +3 -0
  104. package/src/ai/provider.ts +1 -8
  105. package/src/ai/quota-provider.ts +192 -0
  106. package/src/ai/quota-report.ts +152 -0
  107. package/src/ai/quotas-migrate.ts +45 -0
  108. package/src/ai/quotas.ts +168 -0
  109. package/src/ai/routes.ts +241 -11
  110. package/src/ai/run-timeout.ts +12 -0
  111. package/src/ai/runtime-tools.ts +7 -5
  112. package/src/ai/runtime.ts +74 -17
  113. package/src/ai/settings.ts +36 -8
  114. package/src/ai/short-id.ts +4 -5
  115. package/src/ai/skill-catalog.ts +0 -10
  116. package/src/ai/skill-search.ts +58 -0
  117. package/src/ai/skill-seeds.ts +114 -20
  118. package/src/ai/skill-tool.ts +21 -5
  119. package/src/ai/skills-routes.ts +49 -4
  120. package/src/ai/skills.ts +358 -163
  121. package/src/ai/store.ts +416 -201
  122. package/src/ai/stream.ts +5 -0
  123. package/src/ai/structured-runs.ts +4 -3
  124. package/src/ai/structured.ts +12 -1
  125. package/src/ai/system-prompt.ts +14 -2
  126. package/src/ai/task-contracts.ts +84 -0
  127. package/src/ai/task-execution.ts +92 -0
  128. package/src/ai/timeline.ts +2 -5
  129. package/src/ai/todo-contracts.ts +32 -0
  130. package/src/ai/todo-tool.ts +19 -0
  131. package/src/ai/tool-audit.ts +30 -40
  132. package/src/ai/tools.ts +6 -0
  133. package/src/ai/transcription.ts +202 -0
  134. package/src/ai/turn-timing.ts +114 -0
  135. package/src/ai/types.ts +58 -10
  136. package/src/ai/ui.tsx +3 -0
  137. package/src/ai/usage.ts +118 -118
  138. package/src/ai/vision-tool.ts +60 -10
  139. package/src/api/admin-ai-projects.ts +2 -2
  140. package/src/api/admin-ai-quotas.ts +80 -0
  141. package/src/api/admin-ai-skills.ts +48 -5
  142. package/src/api/admin-ai-usage.ts +2 -2
  143. package/src/api/admin-core-settings.ts +25 -0
  144. package/src/api/admin-identity.ts +91 -51
  145. package/src/api/admin-linux-identities.ts +10 -4
  146. package/src/api/admin-rail.ts +58 -0
  147. package/src/api/announcements.ts +14 -0
  148. package/src/api/app-approval.ts +2 -2
  149. package/src/api/auth.ts +1 -1
  150. package/src/api/capabilities.ts +324 -43
  151. package/src/api/capability-streams.ts +262 -0
  152. package/src/api/help.ts +8 -44
  153. package/src/api/index.ts +8 -2
  154. package/src/api/mcp.ts +45 -34
  155. package/src/api/me-web-vitals.ts +35 -0
  156. package/src/api/me.ts +2 -0
  157. package/src/api/search/schemas.ts +26 -15
  158. package/src/api/search.bench.ts +1 -1
  159. package/src/api/search.ts +54 -17
  160. package/src/browser/CloudResourceSearch.tsx +640 -702
  161. package/src/browser/app-approval-vault.ts +19 -7
  162. package/src/browser/command-bridge.ts +109 -0
  163. package/src/browser/command-shortcuts.ts +76 -0
  164. package/src/browser/commands.ts +67 -0
  165. package/src/browser/navigation-search.ts +48 -0
  166. package/src/browser/notifications.ts +1 -1
  167. package/src/browser/resource-picker.tsx +5 -18
  168. package/src/browser/resource-search-dialog.ts +9 -0
  169. package/src/browser/resource-search-input.ts +46 -0
  170. package/src/browser/resource-search-messages.ts +76 -40
  171. package/src/browser/resource-search.ts +7 -0
  172. package/src/browser/search-bridge.ts +102 -0
  173. package/src/browser/search-commands.ts +59 -0
  174. package/src/browser/search.ts +13 -0
  175. package/src/browser/spotlight-position.ts +208 -0
  176. package/src/browser/testing.ts +6 -0
  177. package/src/browser/web-vitals.ts +25 -0
  178. package/src/capabilities/claims.ts +179 -0
  179. package/src/capabilities/client.ts +34 -0
  180. package/src/capabilities/command-link.ts +16 -0
  181. package/src/capabilities/executions.ts +298 -0
  182. package/src/capabilities/migrate.ts +81 -0
  183. package/src/capabilities/server.ts +116 -35
  184. package/src/capabilities/stream-body.ts +16 -0
  185. package/src/capabilities/streams.ts +28 -0
  186. package/src/cli/admin/ai-quotas.ts +161 -0
  187. package/src/cli/admin/ai-skills.ts +64 -0
  188. package/src/cli/admin/ai-usage.ts +53 -30
  189. package/src/cli/admin/app-credentials.ts +77 -0
  190. package/src/cli/admin/data.ts +17 -0
  191. package/src/cli/admin/index.ts +10 -1
  192. package/src/cli/admin/instance.ts +2 -2
  193. package/src/cli/admin/linux.ts +4 -2
  194. package/src/cli/admin/sync.ts +1 -1
  195. package/src/cli/capabilities.ts +42 -0
  196. package/src/cli/capability-streams.ts +25 -0
  197. package/src/cli/index.ts +3 -2
  198. package/src/config/define-env.ts +97 -0
  199. package/src/config/env.ts +147 -51
  200. package/src/config/index.ts +3 -1
  201. package/src/contracts/app.ts +23 -3
  202. package/src/contracts/capabilities.ts +75 -5
  203. package/src/contracts/capability-streams.ts +46 -0
  204. package/src/contracts/commands.ts +49 -0
  205. package/src/contracts/index.ts +5 -2
  206. package/src/contracts/rail-admin.ts +38 -0
  207. package/src/contracts/rail-preferences.ts +16 -0
  208. package/src/contracts/registry.ts +5 -27
  209. package/src/contracts/settings-types.ts +1 -1
  210. package/src/contracts/shared.ts +18 -5
  211. package/src/contracts/web-vitals.ts +27 -0
  212. package/src/desktop/index.ts +1 -0
  213. package/src/desktop/solid.tsx +1 -1
  214. package/src/index.ts +8 -6
  215. package/src/server/index.ts +3 -0
  216. package/src/server/middleware/auth.ts +3 -2
  217. package/src/server/middleware/route-template.ts +9 -3
  218. package/src/server/middleware/runtime.ts +2 -1
  219. package/src/server/middleware/settings.ts +17 -11
  220. package/src/server/ratelimit.ts +1 -1
  221. package/src/server/services/access-revision.ts +10 -0
  222. package/src/server/services/access.ts +3 -1
  223. package/src/server/services/index.ts +1 -0
  224. package/src/services/account-category-policy.ts +9 -0
  225. package/src/services/account-lifecycle/audit.ts +1 -1
  226. package/src/services/accounts/app.ts +12 -2
  227. package/src/services/accounts/email-write.ts +23 -0
  228. package/src/services/accounts/groups.ts +15 -4
  229. package/src/services/accounts/identities.ts +249 -0
  230. package/src/services/accounts/identity-reconciliation.ts +87 -0
  231. package/src/services/accounts/ipa-data.ts +2 -2
  232. package/src/services/accounts/local-groups.ts +2 -2
  233. package/src/services/accounts/notification-sender.ts +12 -2
  234. package/src/services/accounts/posix.ts +45 -29
  235. package/src/services/accounts/users.ts +2 -2
  236. package/src/services/announcements/index.ts +69 -8
  237. package/src/services/app-approval.ts +11 -10
  238. package/src/services/audit/index.ts +1 -1
  239. package/src/services/cache-fill.ts +35 -0
  240. package/src/services/help/index.ts +109 -0
  241. package/src/services/help/maintenance.ts +25 -0
  242. package/src/services/help/store.ts +180 -0
  243. package/src/services/help/types.ts +46 -0
  244. package/src/services/identity/invocation-actor.ts +2 -2
  245. package/src/services/identity/key-config.ts +8 -5
  246. package/src/services/identity/key-ring.ts +1 -1
  247. package/src/services/identity/runtime-config.ts +6 -5
  248. package/src/services/index.ts +15 -0
  249. package/src/services/ipa/posix.ts +1 -1
  250. package/src/services/ipa/sync.ts +1 -1
  251. package/src/services/ipa/users.ts +268 -221
  252. package/src/services/legal-consent.ts +1 -1
  253. package/src/services/logging/index.ts +15 -13
  254. package/src/services/logging/trace.ts +2 -1
  255. package/src/services/logging/web-vitals.ts +81 -0
  256. package/src/services/mandates/index.ts +21 -4
  257. package/src/services/mandates/policy.ts +165 -2
  258. package/src/services/notifications/batches.ts +125 -57
  259. package/src/services/notifications/browser.ts +12 -0
  260. package/src/services/notifications/index.ts +0 -2
  261. package/src/services/notifications/platform.ts +0 -4
  262. package/src/services/oauth-tokens.ts +3 -3
  263. package/src/services/pdf/gotenberg.ts +131 -72
  264. package/src/services/pdf/index.ts +11 -8
  265. package/src/services/providers/local/users.ts +25 -16
  266. package/src/services/public-http.ts +101 -0
  267. package/src/services/rail-shortcuts.ts +95 -0
  268. package/src/services/rail-snapshot.ts +53 -0
  269. package/src/services/request-cache-redis.ts +29 -0
  270. package/src/services/session/index.ts +15 -2
  271. package/src/services/session/user.ts +62 -37
  272. package/src/services/settings/app.ts +5 -1
  273. package/src/services/settings/core-settings.ts +26 -35
  274. package/src/services/settings/defaults.ts +5 -0
  275. package/src/services/settings/index.ts +3 -3
  276. package/src/services/settings/store.ts +58 -84
  277. package/src/services/webauthn.ts +1 -1
  278. package/src/shared/ai-costs.ts +42 -0
  279. package/src/shared/ai-platform-prompt.ts +1 -1
  280. package/src/shared/ai-quotas.ts +136 -0
  281. package/src/shared/ai-usage.ts +6 -16
  282. package/src/shared/app-presentation.ts +21 -15
  283. package/src/shared/capability-messages.ts +14 -0
  284. package/src/shared/help.ts +6 -42
  285. package/src/shared/index.ts +3 -0
  286. package/src/shared/markdown/extensions/info-blocks.ts +1 -1
  287. package/src/ssr/AdminLayout.tsx +7 -5
  288. package/src/ssr/AdminSidebar.tsx +30 -16
  289. package/src/ssr/AppLaunchpad.island.tsx +33 -136
  290. package/src/ssr/AppLaunchpadPanel.tsx +181 -0
  291. package/src/ssr/BrowserPushRegistration.island.tsx +12 -0
  292. package/src/ssr/GlobalSearchDialog.tsx +167 -20
  293. package/src/ssr/GlobalSearchTrigger.island.tsx +33 -22
  294. package/src/ssr/Layout.tsx +17 -6
  295. package/src/ssr/LayoutBreadcrumbs.island.tsx +3 -5
  296. package/src/ssr/LayoutFooter.tsx +1 -1
  297. package/src/ssr/LayoutHeader.tsx +46 -14
  298. package/src/ssr/LayoutHelp.tsx +22 -23
  299. package/src/ssr/{HotkeysHelpRail.island.tsx → LayoutHelpTrigger.island.tsx} +18 -12
  300. package/src/ssr/LayoutRail.tsx +17 -9
  301. package/src/ssr/MinimalLayout.tsx +1 -1
  302. package/src/ssr/MobileNavigation.tsx +71 -0
  303. package/src/ssr/MobileProfileActions.tsx +49 -0
  304. package/src/ssr/ProfilePreferences.island.tsx +3 -36
  305. package/src/ssr/RailApps.island.tsx +2 -2
  306. package/src/ssr/RailEditor.tsx +17 -2
  307. package/src/ssr/WorkspaceNavigation.island.tsx +7 -0
  308. package/src/ssr/WorkspaceNavigationProvider.tsx +16 -0
  309. package/src/ssr/admin-navigation.ts +11 -1
  310. package/src/ssr/help.ts +29 -0
  311. package/src/ssr/islands/SearchBar.tsx +67 -0
  312. package/src/ssr/islands/index.ts +3 -1
  313. package/src/ssr/layout-context.ts +2 -2
  314. package/src/ssr/layout-help-search.ts +15 -0
  315. package/src/ssr/mobile-menu-history.ts +70 -0
  316. package/src/ssr/platform-messages.ts +16 -8
  317. package/src/ssr/profile-actions.ts +42 -0
  318. package/src/ssr/rail-context.ts +8 -4
  319. package/src/ssr/rail-messages.ts +2 -0
  320. package/src/ssr/rail-navigation.ts +3 -3
  321. package/src/ssr/workspace-navigation.ts +75 -0
  322. package/src/styles/effects.css +6 -6
  323. package/src/styles/global.css +54 -0
  324. package/src/styles/input.css +43 -41
  325. package/src/styles/resource-search.css +505 -0
  326. package/src/styles/utilities-feedback.css +13 -0
  327. package/src/styles/utilities-layout.css +1 -5
  328. package/src/styles/utilities-navigation.css +3 -39
  329. package/src/types/ambient.d.ts +5 -0
  330. package/src/workflows/ai/index.ts +2 -2
  331. package/src/workflows/ai/runtime.ts +19 -90
  332. package/src/workflows/ai/store.ts +2 -0
  333. package/src/workflows/ai/types.ts +6 -82
  334. package/src/workflows/ai-actions.ts +16 -13
  335. package/src/workflows/definition.ts +10 -5
  336. package/src/workflows/store/actions.ts +44 -20
  337. package/src/workflows/store/index.ts +4 -2
  338. package/src/workflows/store/runs.ts +36 -0
  339. package/src/workflows/store/worker-pool.ts +55 -0
  340. package/src/workflows/store/worker-runtime.ts +90 -0
  341. package/src/ai/kit-skill.ts +0 -22
  342. package/src/ai/usage-migrate.ts +0 -81
  343. package/src/browser/notification-target.ts +0 -27
  344. package/src/contracts/notification-live.ts +0 -66
  345. package/src/server/services/freeipa/test-certificates.ts +0 -44
  346. package/src/services/notifications/live.ts +0 -50
  347. package/src/services/session/test-fixture.ts +0 -18
  348. package/src/ssr/BrowserNotifications.island.tsx +0 -135
  349. package/src/ssr/islands/SearchBar.island.tsx +0 -80
@@ -4,6 +4,7 @@ import { type AiAttachmentRef, aiAttachmentMarker } from "../attachments";
4
4
  import { type AiStreamEvent, type AiTurnBlock, type AiTurnSnapshot, steerMessageBlockId } from "../protocol";
5
5
  import { type AiResourceMarker, aiResourceMarker } from "../resource-markers";
6
6
  import type {
7
+ AiClientToolId,
7
8
  AiConversation,
8
9
  AiConversationTimelineEntry,
9
10
  AiDraftContentPart,
@@ -18,6 +19,7 @@ import {
18
19
  activeTurnFromSnapshot,
19
20
  emptyProjection,
20
21
  mergeActiveTurn,
22
+ messagesWithPendingSend,
21
23
  reconcileActiveTurnActions,
22
24
  reduceProjection,
23
25
  visibleMessages,
@@ -25,6 +27,10 @@ import {
25
27
  import { type AiConversationStreamTransport, type AiStreamHandle, aiSseConversationStreamTransport } from "./transport";
26
28
 
27
29
  type ComposerDraftInput = {
30
+ draftContent?: AiDraftContentPart[];
31
+ /** Bind local content to the server draft it was actually based on. */
32
+ conversationId?: string;
33
+ expectedDraftRevision?: number;
28
34
  message?: string;
29
35
  content?: AiUserContentPart[];
30
36
  files?: File[];
@@ -36,6 +42,7 @@ export type AiChatRunStatus = "idle" | "streaming" | "waiting_for_action" | "sto
36
42
  export type AiStreamStatus = "idle" | "connecting" | "open" | "reconnecting";
37
43
 
38
44
  export type AiFrontendToolHandler = (request: {
45
+ conversationId: string;
39
46
  name: string;
40
47
  callId: string;
41
48
  args: unknown;
@@ -99,6 +106,8 @@ export type CreateAiChatControllerOptions = {
99
106
  /** Persist owner read state for conversation-list unread indicators. */
100
107
  trackViewedState?: boolean;
101
108
  frontendTools?: Record<string, AiFrontendToolHandler>;
109
+ /** Explicitly advertise only client tools with a connected handler. */
110
+ clientToolIds?: AiClientToolId[];
102
111
  /** Overrides the default SSE transport, for example with a shared WebSocket channel. */
103
112
  streamTransport?: AiConversationStreamTransport;
104
113
  };
@@ -141,6 +150,14 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
141
150
  const [streamStatus, setStreamStatus] = createSignal<AiStreamStatus>("idle");
142
151
  const initialProjection = options.initialDetail ? detailToProjection(options.initialDetail) : emptyProjection();
143
152
  const [state, setState] = createStore<AiChatProjection>(initialProjection);
153
+ const completedDuringSend = new Map<string, string>();
154
+ const [pendingSends, setPendingSends] = createSignal<Record<string, AiStoredMessage | undefined>>({});
155
+ const clearPendingSend = (conversationId: string) =>
156
+ setPendingSends((current) => {
157
+ const next = { ...current };
158
+ delete next[conversationId];
159
+ return next;
160
+ });
144
161
 
145
162
  // Cache of projections for conversations opened this session (fast switching).
146
163
  const cache = new Map<string, AiChatProjection>();
@@ -207,7 +224,7 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
207
224
  const error = () => globalError() ?? runError();
208
225
 
209
226
  const activeTurn = () => state.activeTurn;
210
- const messages = createMemo(() => visibleMessages(state));
227
+ const messages = createMemo(() => messagesWithPendingSend(visibleMessages(state), pendingSends()[activeConversationId() ?? ""]));
211
228
  const runStatus = (): AiChatRunStatus => {
212
229
  const override = runStatusRaw();
213
230
  if (override) return override;
@@ -226,6 +243,7 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
226
243
  const next = reduceProjection({ conversation: state.conversation, messages: state.messages, activeTurn: state.activeTurn }, event);
227
244
  setState(reconcile(next, { key: "id", merge: true }));
228
245
  cache.set(conversationId, next);
246
+ if (event.type === "turn_finished" && pendingSends()[conversationId]?.loopId === event.turnId) clearPendingSend(conversationId);
229
247
  };
230
248
 
231
249
  /**
@@ -241,6 +259,8 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
241
259
  setProjection(detailToProjection(detail), session.conversationId);
242
260
  setHasMore(session.conversationId, detail.hasMoreMessages ?? false);
243
261
  if (detail.timeline) setTimeline(session.conversationId, detail.timeline);
262
+ if (event.type === "turn_finished" && pendingSends()[session.conversationId]?.loopId === event.turnId)
263
+ clearPendingSend(session.conversationId);
244
264
  } else reduceEvent(session.conversationId, event);
245
265
  setRunStatusRaw(null);
246
266
  void markConversationViewed(session.conversationId);
@@ -249,6 +269,9 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
249
269
  const applyEvent = (session: AiStreamSession, event: AiStreamEvent) => {
250
270
  if (!isCurrentStreamSession(streamSession, session)) return;
251
271
  const conversationId = session.conversationId;
272
+ if (event.type === "turn_finished" && pendingSends()[conversationId]) {
273
+ completedDuringSend.set(conversationId, event.turnId);
274
+ }
252
275
 
253
276
  if (event.type === "state") setHasMore(conversationId, event.hasMoreMessages ?? false);
254
277
  const nextRunError = runErrorFromEvent(event, state.activeTurn?.turnId);
@@ -265,7 +288,7 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
265
288
 
266
289
  reduceEvent(conversationId, event);
267
290
  if (event.type === "state" && loadingConversationId() === conversationId) setLoadingConversationId(null);
268
- if (runStatusRaw() && runStatusRaw() !== "stopping") setRunStatusRaw(null);
291
+ if (runStatusRaw() && (runStatusRaw() !== "stopping" || !state.activeTurn)) setRunStatusRaw(null);
269
292
 
270
293
  if (event.type === "turn_finished") {
271
294
  setRunStatusRaw(null);
@@ -338,7 +361,7 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
338
361
  return false;
339
362
  }
340
363
  try {
341
- const result = await handler({ name, callId, args, turnId });
364
+ const result = await handler({ name, callId, args, turnId, conversationId });
342
365
  return submitTurnActionForConversation(conversationId, turnId, callId, { type: "tool_result", result });
343
366
  } catch (toolError) {
344
367
  return submitTurnActionForConversation(conversationId, turnId, callId, {
@@ -394,6 +417,7 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
394
417
  };
395
418
  cache.set(conversationId, next);
396
419
  setState(reconcile(next, { key: "id", merge: true }));
420
+ if (!next.activeTurn && runStatusRaw() === "stopping") setRunStatusRaw(null);
397
421
  };
398
422
 
399
423
  const openConversation = async (conversationId: string) => {
@@ -436,15 +460,25 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
436
460
  return generation === conversationOpenGeneration ? ("failed" as const) : ("stale" as const);
437
461
  };
438
462
 
463
+ let conversationRefreshGeneration = 0;
439
464
  const refreshActiveConversation = async (): Promise<void> => {
440
465
  const conversationId = activeConversationId();
441
466
  if (!conversationId) return;
467
+ const refreshGeneration = ++conversationRefreshGeneration;
468
+ const openGeneration = conversationOpenGeneration;
442
469
  const detail = await request<AiConversationDetail>(
443
470
  `/conversations/${conversationId}`,
444
471
  { method: "GET" },
445
472
  "Failed to refresh conversation",
446
473
  );
447
- if (!isActiveConversation(conversationId)) return;
474
+ if (
475
+ !isActiveConversation(conversationId) ||
476
+ refreshGeneration !== conversationRefreshGeneration ||
477
+ openGeneration !== conversationOpenGeneration
478
+ )
479
+ return;
480
+ const currentDraft = state.conversation?.draft;
481
+ if (currentDraft && currentDraft.revision > detail.conversation.draft.revision) detail.conversation.draft = currentDraft;
448
482
  const windowOldest = detail.messages[0]?.seq;
449
483
  const preservedOlder = windowOldest === undefined ? [] : state.messages.filter((message) => message.seq < windowOldest);
450
484
  setProjection({ ...detailToProjection(detail), messages: [...preservedOlder, ...detail.messages] }, conversationId);
@@ -653,7 +687,7 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
653
687
  }
654
688
  }
655
689
  const content: AiDraftContentPart[] = [
656
- ...textParts,
690
+ ...(input.draftContent ?? textParts),
657
691
  ...(input.resources ?? []).map((resource) => ({ type: "resource" as const, ...resource })),
658
692
  ...(input.storedFiles ?? []).map((file) => ({ type: "file" as const, ...file })),
659
693
  ...uploaded.map((file) => ({ type: "file" as const, ...file })),
@@ -661,10 +695,14 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
661
695
  try {
662
696
  const draft = await request<AiConversation["draft"]>(
663
697
  `/conversations/${conversationId}/draft`,
664
- { method: "PUT", body: JSON.stringify({ expectedRevision: conversation.draft.revision, content }) },
698
+ { method: "PUT", body: JSON.stringify({ expectedRevision: input.expectedDraftRevision ?? conversation.draft.revision, content }) },
665
699
  "Failed to save conversation draft",
666
700
  );
667
- const nextConversation = { ...conversation, draft };
701
+ const current = isActiveConversation(conversationId) ? state.conversation : cache.get(conversationId)?.conversation;
702
+ const nextConversation = {
703
+ ...(current ?? conversation),
704
+ draft: current && current.draft.revision > draft.revision ? current.draft : draft,
705
+ };
668
706
  const projection = isActiveConversation(conversationId)
669
707
  ? { conversation: nextConversation, messages: state.messages, activeTurn: state.activeTurn }
670
708
  : { ...(cache.get(conversationId) ?? emptyProjection(nextConversation)), conversation: nextConversation };
@@ -687,36 +725,79 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
687
725
  };
688
726
 
689
727
  const saveDraft = (input: ComposerDraftInput) => {
690
- const targetConversation = ensureConversation();
728
+ const targetConversation = input.conversationId ? Promise.resolve(input.conversationId) : ensureConversation();
691
729
  return queueDraftOperation(async () => persistComposerDraft(input, await targetConversation));
692
730
  };
693
731
 
732
+ const queuedAttempts = new Map<string, { key: string; files?: File[]; id: string; saved: { revision: number } }>();
733
+ const queueMessage = (input: ComposerDraftInput & { modelProfileId?: string }): Promise<boolean> => {
734
+ if (!input.conversationId || !isComposerDraftSendable(input)) return Promise.resolve(false);
735
+ const conversationId = input.conversationId;
736
+ const key = JSON.stringify({
737
+ message: input.message,
738
+ content: input.content,
739
+ draftContent: input.draftContent,
740
+ resources: input.resources,
741
+ storedFiles: input.storedFiles,
742
+ model: input.modelProfileId,
743
+ });
744
+ return queueDraftOperation(async () => {
745
+ try {
746
+ const previous = queuedAttempts.get(conversationId);
747
+ const same =
748
+ previous?.key === key &&
749
+ (previous.files ?? []).length === (input.files ?? []).length &&
750
+ (previous.files ?? []).every((file, index) => file === input.files?.[index]);
751
+ const saved = same ? previous.saved : await persistComposerDraft(input, conversationId);
752
+ if (!saved) return false;
753
+ const queueId = same ? previous.id : crypto.randomUUID();
754
+ queuedAttempts.set(conversationId, { key, files: input.files, id: queueId, saved });
755
+ await request(
756
+ `/conversations/${conversationId}/turns`,
757
+ {
758
+ method: "POST",
759
+ body: JSON.stringify({
760
+ draftRevision: saved.revision,
761
+ queueId,
762
+ modelProfileId: input.modelProfileId,
763
+ clientToolIds: options.clientToolIds?.filter((id) => options.frontendTools?.[id]),
764
+ }),
765
+ },
766
+ "Failed to queue message",
767
+ );
768
+ if (isActiveConversation(conversationId)) {
769
+ if ((state.conversation?.draft.revision ?? 0) <= saved.revision)
770
+ setState("conversation", "draft", { content: [], revision: saved.revision + 1, updatedAt: new Date().toISOString() });
771
+ } else invalidateInactiveCache(conversationId);
772
+ queuedAttempts.delete(conversationId);
773
+ if (isActiveConversation(conversationId)) setGlobalError(null);
774
+ return true;
775
+ } catch (error) {
776
+ setConversationError(conversationId, error instanceof Error ? error.message : "Failed to queue message");
777
+ return false;
778
+ }
779
+ });
780
+ };
781
+
694
782
  const send = (input: ComposerDraftInput & { modelProfileId?: string }): Promise<boolean> => {
695
783
  if (!isComposerDraftSendable(input)) return Promise.resolve(false);
696
- const targetConversation = ensureConversation();
784
+ const targetConversation = input.conversationId ? Promise.resolve(input.conversationId) : ensureConversation();
697
785
  return queueDraftOperation(async () => {
698
- const savedDraft = await persistComposerDraft(input, await targetConversation);
699
- if (!savedDraft) return false;
700
- const conversationId = savedDraft.conversationId;
701
- if (isActiveConversation(conversationId) && running()) return false;
702
-
786
+ const conversationId = await targetConversation;
787
+ if (!conversationId || (isActiveConversation(conversationId) && running())) return false;
703
788
  if (isActiveConversation(conversationId)) clearErrors();
704
789
  const baseProjection = isActiveConversation(conversationId)
705
790
  ? { conversation: state.conversation, messages: [...state.messages], activeTurn: state.activeTurn }
706
791
  : (cache.get(conversationId) ?? emptyProjection());
707
-
708
- // Optimistic view renders attachments through the same marker format the server persists.
709
792
  const optimisticContent: AiUserContentPart[] = [
710
- ...savedDraft.content.flatMap((part) => {
711
- if (part.type === "text") return [{ type: "text" as const, text: part.text }];
712
- if (part.type === "file") return [{ type: "text" as const, text: aiAttachmentMarker(part) }];
713
- return [
714
- {
715
- type: "text" as const,
716
- text: aiResourceMarker({ ref: part.ref, title: part.title, icon: part.icon, href: part.href }),
717
- },
718
- ];
719
- }),
793
+ ...(input.content ?? []),
794
+ ...(!input.content?.length && input.message ? [{ type: "text" as const, text: input.message }] : []),
795
+ ...(input.resources ?? []).map((ref) => ({ type: "text" as const, text: aiResourceMarker(ref) })),
796
+ ...(input.storedFiles ?? []).map((file) => ({ type: "text" as const, text: aiAttachmentMarker(file) })),
797
+ ...(input.files ?? []).map((file) => ({
798
+ type: "text" as const,
799
+ text: aiAttachmentMarker({ path: `/${file.name}`, mediaType: file.type, size: file.size }),
800
+ })),
720
801
  ];
721
802
 
722
803
  // Optimistic: show the user message immediately.
@@ -738,39 +819,72 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
738
819
  meta: null,
739
820
  createdAt: new Date().toISOString(),
740
821
  };
741
- const optimisticProjection = { ...baseProjection, messages: [...baseProjection.messages, optimistic] };
742
- cache.set(conversationId, optimisticProjection);
822
+ setPendingSends((current) => ({ ...current, [conversationId]: optimistic }));
743
823
  if (isActiveConversation(conversationId)) {
744
- setState("messages", (prev) => [...prev, optimistic]);
745
824
  setRunStatusRaw("streaming");
746
825
  }
747
826
 
748
827
  try {
828
+ const savedDraft = await persistComposerDraft(input, conversationId);
829
+ if (!savedDraft) {
830
+ clearPendingSend(conversationId);
831
+ if (isActiveConversation(conversationId)) setRunStatusRaw("failed");
832
+ return false;
833
+ }
834
+ setPendingSends((current) => ({
835
+ ...current,
836
+ [conversationId]: {
837
+ ...optimistic,
838
+ meta: { submittedDraftRevision: savedDraft.revision },
839
+ message: {
840
+ role: "user",
841
+ content: savedDraft.content.map((part) => ({
842
+ type: "text",
843
+ text:
844
+ part.type === "text"
845
+ ? part.text
846
+ : part.type === "file"
847
+ ? aiAttachmentMarker(part)
848
+ : part.type === "project-file"
849
+ ? `Project file: ${part.path}`
850
+ : aiResourceMarker({ ref: part.ref, title: part.title, icon: part.icon, href: part.href }),
851
+ })),
852
+ },
853
+ },
854
+ }));
749
855
  const result = await request<SubmitTurnResult>(
750
856
  `/conversations/${conversationId}/turns`,
751
857
  {
752
858
  method: "POST",
753
859
  body: JSON.stringify({
754
860
  draftRevision: savedDraft.revision,
861
+ clientToolIds: options.clientToolIds?.filter((id) => options.frontendTools?.[id]),
755
862
  modelProfileId: input.modelProfileId,
756
863
  }),
757
864
  },
758
865
  "AI request failed",
759
866
  );
867
+ const alreadyFinished = completedDuringSend.get(conversationId) === result.turn.id;
868
+ completedDuringSend.delete(conversationId);
869
+ if (alreadyFinished) clearPendingSend(conversationId);
870
+ else setPendingSends((current) => ({ ...current, [conversationId]: result.message }));
760
871
  if (invalidateInactiveCache(conversationId)) return true;
761
872
  // Replace the optimistic message with the persisted one.
762
- setState("messages", (prev) => prev.map((message) => (message.id === optimistic.id ? result.message : message)));
873
+ setState("messages", (prev) => [...prev.filter((message) => message.id !== result.message.id), result.message]);
763
874
  setState(
764
875
  "activeTurn",
765
876
  (current) =>
766
- current ?? {
767
- turnId: result.turn.id,
768
- attempt: 0,
769
- seq: 0,
770
- status: "running",
771
- blocks: [],
772
- modelProfileId: result.turn.modelProfileId,
773
- },
877
+ current ??
878
+ (alreadyFinished
879
+ ? null
880
+ : {
881
+ turnId: result.turn.id,
882
+ attempt: 0,
883
+ seq: 0,
884
+ status: "running",
885
+ blocks: [],
886
+ modelProfileId: result.turn.modelProfileId,
887
+ }),
774
888
  );
775
889
  if (state.conversation) {
776
890
  setState("conversation", "draft", {
@@ -787,6 +901,8 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
787
901
  }
788
902
  return true;
789
903
  } catch (sendError) {
904
+ completedDuringSend.delete(conversationId);
905
+ clearPendingSend(conversationId);
790
906
  if (invalidateInactiveCache(conversationId)) return false;
791
907
  setState("messages", (prev) => prev.filter((message) => message.id !== optimistic.id));
792
908
  cache.set(conversationId, { conversation: state.conversation, messages: state.messages, activeTurn: state.activeTurn });
@@ -1157,6 +1273,8 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
1157
1273
  conversation: () => state.conversation,
1158
1274
  setActiveConversationId,
1159
1275
  messages,
1276
+ /** Includes persisted model rounds still represented by live blocks in the timeline. */
1277
+ usageMessages: () => state.messages,
1160
1278
  activeTurn,
1161
1279
  hasMoreHistory,
1162
1280
  loadingOlder: () => isActiveConversationLoading(activeConversationId(), loadingOlderConversationId()),
@@ -1175,6 +1293,7 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
1175
1293
  refreshActiveConversation,
1176
1294
  createConversation,
1177
1295
  saveDraft,
1296
+ queueMessage,
1178
1297
  send,
1179
1298
  steer,
1180
1299
  retrySteer,
@@ -1194,7 +1313,14 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
1194
1313
  export type AiChatController = ReturnType<typeof createAiChatController>;
1195
1314
 
1196
1315
  const isComposerDraftSendable = (input: ComposerDraftInput): boolean =>
1197
- Boolean(input.message?.trim() || input.content?.length || input.files?.length || input.resources?.length || input.storedFiles?.length);
1316
+ Boolean(
1317
+ input.draftContent?.length ||
1318
+ input.message?.trim() ||
1319
+ input.content?.length ||
1320
+ input.files?.length ||
1321
+ input.resources?.length ||
1322
+ input.storedFiles?.length,
1323
+ );
1198
1324
 
1199
1325
  export const __aiControllerTest = {
1200
1326
  claimFrontendCall,
@@ -30,7 +30,7 @@ const bytesToBase64 = (bytes: Uint8Array): string => {
30
30
  return btoa(binary);
31
31
  };
32
32
 
33
- export const conversationFileSource = (baseUrl: string, conversationId: string): FileSource => {
33
+ export const conversationFileSource = (baseUrl: string, conversationId: string): FileSource & { listFiles(): Promise<AiFileStat[]> } => {
34
34
  const filesUrl = (suffix = "", params?: Record<string, string>) => {
35
35
  const query = params ? `?${new URLSearchParams(params).toString()}` : "";
36
36
  return `${baseUrl}/conversations/${conversationId}/files${suffix}${query}`;
@@ -45,15 +45,19 @@ export const conversationFileSource = (baseUrl: string, conversationId: string):
45
45
  return (await response.json()) as T;
46
46
  };
47
47
 
48
+ const listFiles = async (): Promise<AiFileStat[]> =>
49
+ (await request<{ files: AiFileStat[] }>(filesUrl(), { method: "GET" }, "Failed to load files")).files;
50
+
48
51
  return {
52
+ previewPreferencesKey: "assistant.csv-preview",
53
+ listFiles,
49
54
  async list(): Promise<FileTreeEntry[]> {
50
- const result = await request<{ files: AiFileStat[] }>(filesUrl(), { method: "GET" }, "Failed to load files");
51
- return result.files.map((file) => ({
55
+ return (await listFiles()).map((file) => ({
52
56
  path: file.path,
53
57
  size: file.size,
54
58
  mediaType: file.mediaType,
55
59
  updatedAt: file.updatedAt,
56
- badge: file.origin === "user" ? "upload" : undefined,
60
+ badge: file.dictationRecordedAt ? undefined : file.origin === "user" ? "upload" : undefined,
57
61
  }));
58
62
  },
59
63
 
@@ -62,7 +66,8 @@ export const conversationFileSource = (baseUrl: string, conversationId: string):
62
66
  if (!response.ok) throw new Error(await readError(response, "Failed to load file"));
63
67
  const mediaType = response.headers.get("Content-Type")?.split(";")[0]?.trim() || "application/octet-stream";
64
68
  const bytes = new Uint8Array(await response.arrayBuffer());
65
- return isTextMediaType(mediaType)
69
+ const delimited = /\.(csv|tsv)$/i.test(path) || mediaType === "text/csv" || mediaType === "text/tab-separated-values";
70
+ return isTextMediaType(mediaType) && !delimited
66
71
  ? { encoding: "utf8", content: new TextDecoder().decode(bytes), mediaType }
67
72
  : { encoding: "base64", content: bytesToBase64(bytes), mediaType };
68
73
  },
@@ -79,10 +84,11 @@ export const conversationFileSource = (baseUrl: string, conversationId: string):
79
84
  await request(filesUrl("/rename"), { method: "POST", body: JSON.stringify({ from, to }) }, "Failed to rename file");
80
85
  },
81
86
 
82
- async upload(_dirPath, files) {
87
+ async upload(dirPath, files) {
83
88
  for (const file of files) {
84
89
  const form = new FormData();
85
90
  form.append("file", file);
91
+ form.append("directory", dirPath);
86
92
  const response = await fetch(filesUrl(), { method: "POST", body: form });
87
93
  if (!response.ok) throw new Error(await readError(response, `Failed to upload ${file.name}`));
88
94
  }
@@ -63,6 +63,7 @@ export const createAiLiveConnection = (options: CreateAiLiveConnectionOptions):
63
63
 
64
64
  socket = createLiveWebSocket<AiLiveServerMessage>({
65
65
  url: options.url ?? "/api/ai/live",
66
+ activity: "always",
66
67
  initialCursor: options.initialCursor,
67
68
  subscribe: (cursor) => ({
68
69
  type: AI_LIVE_WS_TYPE.subscribe,
@@ -23,6 +23,18 @@ export const emptyProjection = (conversation: AiConversation | null = null): AiC
23
23
  activeTurn: null,
24
24
  });
25
25
 
26
+ /** A local send is an overlay, never part of a replaceable server snapshot. */
27
+ export const messagesWithPendingSend = (messages: AiStoredMessage[], pending?: AiStoredMessage): AiStoredMessage[] => {
28
+ if (!pending) return messages;
29
+ const revision = pending.meta?.submittedDraftRevision;
30
+ const confirmed = messages.some(
31
+ (message) =>
32
+ message.id === pending.id ||
33
+ (revision !== undefined && message.message.role === "user" && message.meta?.submittedDraftRevision === revision),
34
+ );
35
+ return confirmed ? messages : [...messages, pending];
36
+ };
37
+
26
38
  const mergeMessages = (existing: AiStoredMessage[], incoming: AiStoredMessage[]): AiStoredMessage[] => {
27
39
  if (incoming.length === 0) return existing;
28
40
  const byId = new Map(existing.map((message) => [message.id, message]));
@@ -69,19 +81,16 @@ const normalizeCustomApprovalBlocks = (blocks: AiTurnBlock[]): AiTurnBlock[] =>
69
81
  return normalized;
70
82
  };
71
83
 
72
- /** Never let a same-turn DB/SSE snapshot remove blocks the browser already observed. */
84
+ /** A complete snapshot replaces its baseline; stale snapshots cannot resurrect old block IDs. */
73
85
  export const mergeActiveTurn = (previous: AiActiveTurn | null, incoming: AiActiveTurn | null): AiActiveTurn | null => {
74
86
  if (!previous || !incoming || previous.turnId !== incoming.turnId) return incoming;
75
87
  if (previous.attempt !== incoming.attempt) return incoming.attempt > previous.attempt ? incoming : previous;
76
-
77
- const incomingIsNewer = incoming.seq >= previous.seq;
78
- const blocks = incomingIsNewer ? overlayBlocks(previous.blocks, incoming.blocks) : overlayBlocks(incoming.blocks, previous.blocks);
79
- return {
80
- ...(incomingIsNewer ? incoming : previous),
81
- seq: Math.max(previous.seq, incoming.seq),
82
- blocks,
83
- status: deriveStatus(blocks),
84
- };
88
+ if (incoming.seq < previous.seq) return previous;
89
+ // Locally submitted steering may not have reached the snapshot yet.
90
+ const pendingSteers = previous.blocks.filter((block) => block.kind === "steer_message" && block.status !== "consumed");
91
+ const known = new Set(incoming.blocks.map((block) => block.id));
92
+ const blocks = [...incoming.blocks, ...pendingSteers.filter((block) => !known.has(block.id))];
93
+ return { ...incoming, blocks, status: deriveStatus(blocks) };
85
94
  };
86
95
 
87
96
  export const reconcileActiveTurnActions = (
@@ -141,7 +150,7 @@ export const reduceWireEvent = (state: AiChatProjection, event: AiWireEvent): Ai
141
150
  const active = state.activeTurn;
142
151
 
143
152
  if (event.type === "turn_started") {
144
- if (active && active.turnId === event.turnId && event.attempt < active.attempt) return state;
153
+ if (active && active.turnId === event.turnId && !isNewerWireEvent(event, active)) return state;
145
154
  const pendingSteers =
146
155
  active?.turnId === event.turnId ? active.blocks.filter((block) => block.kind === "steer_message" && block.status !== "consumed") : [];
147
156
  const blocks = event.blocks ? overlayBlocks(normalizeCustomApprovalBlocks(event.blocks), pendingSteers) : pendingSteers;
@@ -163,6 +172,15 @@ export const reduceWireEvent = (state: AiChatProjection, event: AiWireEvent): Ai
163
172
  return { ...state, messages: mergeMessages(state.messages, event.messages ?? []), activeTurn: null };
164
173
  }
165
174
 
175
+ if (event.type === "message_saved") {
176
+ if (!active || active.turnId !== event.turnId || !isNewerWireEvent(event, active)) return state;
177
+ return {
178
+ ...state,
179
+ messages: mergeMessages(state.messages, [event.message]),
180
+ activeTurn: { ...active, seq: event.seq, attempt: event.attempt },
181
+ };
182
+ }
183
+
166
184
  // block_set / block_delta
167
185
  if (!active || active.turnId !== event.turnId) return state;
168
186
  if (!isNewerWireEvent(event, active)) return state;
@@ -21,6 +21,11 @@ const CONNECT_TIMEOUT_MS = 10_000;
21
21
  export async function* parseAiSse(response: Response, signal: AbortSignal): AsyncGenerator<AiStreamEvent> {
22
22
  const reader = response.body?.getReader();
23
23
  if (!reader) return;
24
+ const cancel = () => {
25
+ void reader.cancel().catch(() => undefined);
26
+ };
27
+ signal.addEventListener("abort", cancel, { once: true });
28
+ if (signal.aborted) cancel();
24
29
  const decoder = new TextDecoder();
25
30
  let buffer = "";
26
31
  try {
@@ -40,6 +45,7 @@ export async function* parseAiSse(response: Response, signal: AbortSignal): Asyn
40
45
  }
41
46
  }
42
47
  } finally {
48
+ signal.removeEventListener("abort", cancel);
43
49
  await reader.cancel().catch(() => undefined);
44
50
  }
45
51
  }
@@ -0,0 +1,105 @@
1
+ import { type Context, Hono } from "hono";
2
+ import { z } from "zod";
3
+ import { readBoundedJson } from "../_internal/bounded-json";
4
+ import { dispatchCapability } from "../api/capabilities";
5
+ import { dispatchCapabilityStream } from "../api/capability-streams";
6
+ import { CAPABILITY_MAX_REQUEST_BYTES } from "../contracts/capabilities";
7
+ import { type AuthContext, getLocale, type RequestAuthority } from "../server";
8
+ import { requireInvocation } from "../server/middleware/invocation";
9
+ import { getMandate } from "../services/mandates";
10
+ import { codeCapabilityOperation } from "./code-capability-transport";
11
+ import { authorizeCodeExecution } from "./code-execution";
12
+ import { aiConversations } from "./store";
13
+
14
+ const Input = z
15
+ .object({ input: z.unknown() })
16
+ .strict()
17
+ .refine((value) => Object.hasOwn(value, "input"));
18
+ const denied = () => Response.json({ code: "ACCESS_DENIED", message: "Code execution is no longer authorized" }, { status: 403 });
19
+
20
+ /** Core-only continuation of a code turn, not a general invocation exchange. */
21
+ export function createCodeCapabilityRoutes(
22
+ dependencies: {
23
+ invocation?: Parameters<typeof requireInvocation>[1];
24
+ store?: Pick<typeof aiConversations, "getConversation" | "getTurn" | "getActiveTurn" | "getTurnRunConfig">;
25
+ getMandate?: typeof getMandate;
26
+ dispatch?: typeof dispatchCapability;
27
+ stream?: typeof dispatchCapabilityStream;
28
+ } = {},
29
+ ) {
30
+ const store = dependencies.store ?? aiConversations;
31
+ // This Core-only exchange resumes the verified turn; background calls also pass
32
+ // their persisted mandate to the dispatcher. Ordinary
33
+ // app invocations remain non-exchangeable; this is not an interactive cookie.
34
+ const turnAuthority = (c: Context<AuthContext>): RequestAuthority => ({
35
+ actor: c.get("actor"),
36
+ accessSubject: c.get("accessSubject"),
37
+ credentialKind: "session",
38
+ scopes: [],
39
+ });
40
+ const invoke = async (c: Context<AuthContext>, review = false) => {
41
+ const body = await readBoundedJson(c.req.raw, CAPABILITY_MAX_REQUEST_BYTES);
42
+ const input = body.ok ? Input.safeParse(body.data) : null;
43
+ if (!input?.success) return Response.json({ code: "VALIDATION_FAILED", message: "Invalid capability input" }, { status: 400 });
44
+ const conversation = await store.getConversation({ conversationId: c.req.param("conversationId")! });
45
+ const appId = c.req.param("appId")!;
46
+ const capabilityId = c.req.param("capabilityId")!;
47
+ if (conversation?.allowedTools && !conversation.allowedTools.includes(`${appId}.${capabilityId}`)) return denied();
48
+ const config = await store.getTurnRunConfig({ conversationId: c.req.param("conversationId")!, turnId: c.req.param("turnId")! });
49
+ if (!config || config.kind === "compact") return denied();
50
+ return (dependencies.dispatch ?? dispatchCapability)({
51
+ request: c.req.raw,
52
+ authority: turnAuthority(c),
53
+ origin: "assistant",
54
+ continuation: codeCapabilityOperation(c.req.param("conversationId")!, c.req.param("turnId")!),
55
+ mandate: config.mandate ? { mandateId: config.mandate.id, mandateRevision: config.mandate.revision, ownerAppId: "core" } : undefined,
56
+ review,
57
+ kind: review || c.req.param("kind") === "actions" ? "actions" : "queries",
58
+ appId,
59
+ capabilityId,
60
+ input: input.data.input,
61
+ locale: getLocale(c),
62
+ });
63
+ };
64
+ return new Hono<AuthContext>()
65
+ .use("/:conversationId/:turnId/*", async (c, next) => {
66
+ await next();
67
+ c.header("Cache-Control", "no-store");
68
+ })
69
+ .use(
70
+ "/:conversationId/:turnId/*",
71
+ requireInvocation((c) => {
72
+ const conversationId = z.uuid().safeParse(c.req.param("conversationId"));
73
+ const turnId = z.uuid().safeParse(c.req.param("turnId"));
74
+ return conversationId.success && turnId.success
75
+ ? { targetAppId: "core", operation: codeCapabilityOperation(conversationId.data, turnId.data), schemaHash: null }
76
+ : null;
77
+ }, dependencies.invocation),
78
+ )
79
+ .use("/:conversationId/:turnId/*", async (c, next) => {
80
+ const actor = c.get("actor");
81
+ if (actor.kind !== "user" || actor.delegation?.callingAppId !== "assistant" || actor.delegation.credentialKind !== "session")
82
+ return denied();
83
+ const conversationId = c.req.param("conversationId")!;
84
+ const turnId = c.req.param("turnId")!;
85
+ try {
86
+ await authorizeCodeExecution(conversationId, turnId, actor.user.id, { store, getMandate: dependencies.getMandate ?? getMandate });
87
+ } catch {
88
+ return denied();
89
+ }
90
+ await next();
91
+ })
92
+ .post("/:conversationId/:turnId/capabilities/v1/actions/:appId/:capabilityId/review", (c) => invoke(c, true))
93
+ .post("/:conversationId/:turnId/capabilities/v1/:kind{queries|actions}/:appId/:capabilityId", (c) => invoke(c))
94
+ .post("/:conversationId/:turnId/capabilities/v1/streams/:verb", async (c) => {
95
+ const config = await store.getTurnRunConfig({ conversationId: c.req.param("conversationId")!, turnId: c.req.param("turnId")! });
96
+ if (!config || config.kind === "compact" || config.mandate) return denied();
97
+ return (dependencies.stream ?? dispatchCapabilityStream)(c.req.raw, turnAuthority(c), c.req.param("verb"), {
98
+ continuation: codeCapabilityOperation(c.req.param("conversationId")!, c.req.param("turnId")!),
99
+ allow: async ({ appId, capabilityId }) => {
100
+ const conversation = await store.getConversation({ conversationId: c.req.param("conversationId")! });
101
+ return !!conversation && (!conversation.allowedTools || conversation.allowedTools.includes(`${appId}.${capabilityId}`));
102
+ },
103
+ });
104
+ });
105
+ }