@k2b/cloud 0.38.0 → 0.40.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 (244) hide show
  1. package/README.md +8 -0
  2. package/package.json +12 -5
  3. package/scripts/build-canvas-workers.ts +109 -29
  4. package/scripts/build.ts +3 -5
  5. package/src/_internal/app-favicon.ts +4 -2
  6. package/src/_internal/app-path.ts +25 -0
  7. package/src/_internal/app-presentation.ts +4 -0
  8. package/src/_internal/canvas-worker.ts +5 -3
  9. package/src/_internal/capabilities.ts +72 -48
  10. package/src/_internal/capability-reviews.ts +81 -0
  11. package/src/_internal/capability-schemas.ts +29 -0
  12. package/src/_internal/capability-sentences.ts +216 -1
  13. package/src/_internal/capability-streams.ts +7 -0
  14. package/src/_internal/define-app.ts +63 -16
  15. package/src/_internal/font-preloads.ts +2 -1
  16. package/src/_internal/panel-cookie.ts +39 -0
  17. package/src/_internal/registry-validation.ts +21 -26
  18. package/src/_internal/runtime-context.ts +1 -0
  19. package/src/_internal/runtime-watcher.ts +6 -0
  20. package/src/_internal/static-assets.ts +52 -3
  21. package/src/_internal/status-preserving-ssr.ts +2 -0
  22. package/src/access/PermissionEditor.browser-harness.tsx +57 -38
  23. package/src/access/PermissionEditor.tsx +32 -7
  24. package/src/access/ResourceApiKeys.tsx +5 -1
  25. package/src/account/EntitySearch.tsx +1 -1
  26. package/src/ai/action-review.ts +101 -0
  27. package/src/ai/approval-routes.ts +5 -2
  28. package/src/ai/approvals.ts +139 -28
  29. package/src/ai/background-review.ts +72 -0
  30. package/src/ai/browser.ts +10 -0
  31. package/src/ai/capabilities.ts +29 -11
  32. package/src/ai/capability-execution.ts +76 -43
  33. package/src/ai/capability-result.ts +95 -0
  34. package/src/ai/chat/action-card.ts +398 -0
  35. package/src/ai/chat/action-messages.ts +129 -0
  36. package/src/ai/chat/action-question.browser-harness.tsx +245 -0
  37. package/src/ai/chat/action-question.fixtures.ts +733 -0
  38. package/src/ai/chat/action-question.tsx +669 -0
  39. package/src/ai/chat/action-sentences.ts +1 -44
  40. package/src/ai/chat/blocks.tsx +19 -25
  41. package/src/ai/chat/message-actions.tsx +6 -0
  42. package/src/ai/chat/tool-action.tsx +481 -0
  43. package/src/ai/chat/turn-view.tsx +45 -163
  44. package/src/ai/chat/view-block.tsx +22 -7
  45. package/src/ai/chat-task-contracts.ts +31 -3
  46. package/src/ai/chat-task-reasons-migrate.ts +75 -0
  47. package/src/ai/chat-task-reasons.ts +92 -0
  48. package/src/ai/chat-tasks.ts +438 -78
  49. package/src/ai/client/controller.ts +59 -0
  50. package/src/ai/default-tools.ts +9 -1
  51. package/src/ai/executor.ts +190 -39
  52. package/src/ai/index.ts +14 -0
  53. package/src/ai/migrate.ts +39 -4
  54. package/src/ai/projects-routes.ts +3 -0
  55. package/src/ai/protocol.ts +14 -2
  56. package/src/ai/recent-resources.ts +240 -0
  57. package/src/ai/recent-sent-refs.ts +38 -0
  58. package/src/ai/request-context.ts +7 -1
  59. package/src/ai/routes.ts +172 -2
  60. package/src/ai/runtime.ts +13 -27
  61. package/src/ai/skills-routes.ts +3 -0
  62. package/src/ai/skills.ts +10 -1
  63. package/src/ai/store.ts +232 -27
  64. package/src/ai/timeline.ts +9 -1
  65. package/src/ai/tool-audit.ts +8 -0
  66. package/src/ai/tool-wording.ts +67 -0
  67. package/src/ai/turn-abort.ts +33 -0
  68. package/src/ai/types.ts +40 -4
  69. package/src/api/accounts-entities.ts +2 -1
  70. package/src/api/admin-ai-projects.ts +2 -0
  71. package/src/api/admin-ai-skills.ts +2 -0
  72. package/src/api/admin-ai-usage.ts +2 -0
  73. package/src/api/admin-core-settings.ts +190 -139
  74. package/src/api/admin-geoip.ts +80 -0
  75. package/src/api/admin-identity.ts +3 -0
  76. package/src/api/admin-lifecycle.ts +3 -0
  77. package/src/api/admin-outgoing-mail.ts +6 -2
  78. package/src/api/app-approval.ts +54 -6
  79. package/src/api/approvals-loader.ts +23 -1
  80. package/src/api/approvals.ts +3 -1
  81. package/src/api/auth.ts +25 -6
  82. package/src/api/capabilities.ts +44 -11
  83. package/src/api/capability-streams.ts +12 -0
  84. package/src/api/help.ts +4 -28
  85. package/src/api/index.ts +7 -1
  86. package/src/api/lasting-access-messages.ts +17 -0
  87. package/src/api/mcp.ts +3 -0
  88. package/src/api/me-panel-consent.ts +71 -0
  89. package/src/api/me.ts +80 -33
  90. package/src/api/search.ts +3 -1
  91. package/src/api/sync-ops.ts +2 -0
  92. package/src/api/widgets.ts +3 -0
  93. package/src/approvals/Approvals.tsx +180 -238
  94. package/src/approvals/messages.ts +12 -24
  95. package/src/browser/CloudResourceSearch.browser-harness.tsx +10 -2
  96. package/src/browser/CloudResourceSearch.tsx +10 -6
  97. package/src/browser/app-approval.ts +60 -6
  98. package/src/browser/commands.ts +22 -6
  99. package/src/browser/composer-catalog.ts +145 -0
  100. package/src/browser/composer-insert-messages.ts +31 -0
  101. package/src/browser/composer-insert.ts +160 -0
  102. package/src/browser/composer-open-messages.ts +17 -0
  103. package/src/browser/composer-open.ts +110 -0
  104. package/src/browser/discoverable-command.ts +6 -0
  105. package/src/browser/hints.ts +6 -0
  106. package/src/browser/live-connection.ts +26 -2
  107. package/src/browser/navigation-apps.ts +7 -0
  108. package/src/browser/panel-context-bridge.ts +30 -0
  109. package/src/browser/panel-context-receiver.ts +53 -0
  110. package/src/browser/panel-menu.tsx +56 -0
  111. package/src/browser/panel-messages.ts +35 -0
  112. package/src/browser/panel-shortcuts.ts +31 -0
  113. package/src/browser/panel-slot.ts +756 -0
  114. package/src/browser/panel.ts +65 -0
  115. package/src/browser/resource-picker.tsx +12 -1
  116. package/src/browser/search-commands.ts +6 -3
  117. package/src/capabilities/client.ts +8 -2
  118. package/src/capabilities/reviews.ts +205 -0
  119. package/src/capabilities/server.ts +10 -3
  120. package/src/capabilities/testing.ts +47 -0
  121. package/src/capabilities/types.ts +2 -2
  122. package/src/cli/account.ts +13 -3
  123. package/src/cli/admin/geoip.ts +115 -0
  124. package/src/cli/admin/index.ts +3 -0
  125. package/src/contracts/app-approval.ts +159 -12
  126. package/src/contracts/app.ts +14 -0
  127. package/src/contracts/approvals.ts +2 -2
  128. package/src/contracts/capabilities.ts +410 -18
  129. package/src/contracts/geoip.ts +42 -0
  130. package/src/contracts/index.ts +3 -0
  131. package/src/contracts/panel-context-plain.ts +99 -0
  132. package/src/contracts/panel-context.ts +93 -0
  133. package/src/contracts/registry.ts +3 -1
  134. package/src/contracts/seen-hints.ts +35 -0
  135. package/src/contracts/shared.ts +2 -0
  136. package/src/server/client-ip.ts +5 -0
  137. package/src/server/help-payload.ts +38 -0
  138. package/src/server/middleware/auth.ts +11 -1
  139. package/src/server/middleware/middleware.ts +9 -0
  140. package/src/server/middleware/no-transform.ts +19 -0
  141. package/src/server/middleware/rate-limit.ts +4 -6
  142. package/src/server/request-context/address.ts +45 -0
  143. package/src/server/request-context/geoip.ts +380 -0
  144. package/src/server/request-context/index.ts +33 -0
  145. package/src/server/request-context/user-agent.ts +95 -0
  146. package/src/server/services/access.ts +5 -1
  147. package/src/services/accounts/posix.ts +2 -1
  148. package/src/services/app-approval.ts +533 -90
  149. package/src/services/approvals/device.ts +9 -5
  150. package/src/services/approvals/index.ts +6 -5
  151. package/src/services/approvals/list.ts +26 -5
  152. package/src/services/auth-flows/notification-sender.ts +3 -0
  153. package/src/services/geoip/index.ts +164 -0
  154. package/src/services/geoip/store.ts +30 -0
  155. package/src/services/geoip/upload.ts +150 -0
  156. package/src/services/identity/routes.ts +5 -1
  157. package/src/services/index.ts +32 -3
  158. package/src/services/known-browsers.ts +312 -0
  159. package/src/services/login-address-store.ts +51 -0
  160. package/src/services/login-context.ts +118 -0
  161. package/src/services/mandates/index.ts +3 -0
  162. package/src/services/mandates/policy.ts +15 -3
  163. package/src/services/notifications/browser.ts +16 -5
  164. package/src/services/notifications/email-frame.ts +1 -1
  165. package/src/services/oauth-tokens.ts +16 -2
  166. package/src/services/panel-context-consent.ts +58 -0
  167. package/src/services/pdf/image-protocol.ts +135 -0
  168. package/src/services/pdf/image-variants.ts +124 -0
  169. package/src/services/pdf/image-worker.ts +31 -0
  170. package/src/services/pdf/image.ts +109 -0
  171. package/src/services/pdf/index.ts +9 -0
  172. package/src/services/service-account-credentials.ts +21 -0
  173. package/src/services/session/index.ts +97 -16
  174. package/src/services/session/recent.ts +13 -0
  175. package/src/services/settings/api.ts +11 -6
  176. package/src/services/settings/app.ts +5 -4
  177. package/src/services/settings/core-settings.ts +4 -3
  178. package/src/services/settings/index.ts +19 -17
  179. package/src/services/settings/namespace.ts +2 -1
  180. package/src/services/settings/store.ts +83 -11
  181. package/src/services/webauthn.ts +12 -0
  182. package/src/shared/ai-platform-prompt.ts +1 -1
  183. package/src/shared/app-presentation.ts +1 -0
  184. package/src/shared/email-html.ts +2 -0
  185. package/src/shared/help.ts +3 -0
  186. package/src/shared/redirect.ts +12 -0
  187. package/src/shared-text/cursor.ts +22 -0
  188. package/src/shared-text/documents.ts +142 -0
  189. package/src/shared-text/events.ts +43 -0
  190. package/src/shared-text/index.ts +12 -0
  191. package/src/shared-text/log.ts +48 -0
  192. package/src/shared-text/text-diff.ts +23 -0
  193. package/src/shared-text/types.ts +41 -0
  194. package/src/ssr/AdminLayout.tsx +2 -0
  195. package/src/ssr/AppLaunchpadPanel.tsx +2 -2
  196. package/src/ssr/CloudShell.island.tsx +95 -10
  197. package/src/ssr/Layout.tsx +66 -10
  198. package/src/ssr/LayoutHeader.tsx +15 -9
  199. package/src/ssr/LayoutHelp.browser-harness.tsx +2 -0
  200. package/src/ssr/LayoutHelp.tsx +435 -274
  201. package/src/ssr/LayoutHelpPanel.browser-harness.tsx +136 -0
  202. package/src/ssr/LayoutHelpTrigger.tsx +27 -2
  203. package/src/ssr/LayoutRail.tsx +2 -2
  204. package/src/ssr/LayoutTabBar.tsx +29 -0
  205. package/src/ssr/MobileNavigation.tsx +2 -2
  206. package/src/ssr/PanelLayout.tsx +35 -0
  207. package/src/ssr/PanelRuntime.island.tsx +94 -0
  208. package/src/ssr/PanelSlot.tsx +158 -0
  209. package/src/ssr/PanelTrigger.tsx +49 -0
  210. package/src/ssr/ShellRuntime.island.tsx +69 -5
  211. package/src/ssr/app-appearance.ts +3 -2
  212. package/src/ssr/app-color.ts +105 -0
  213. package/src/ssr/app-launchpad.tsx +18 -1
  214. package/src/ssr/app-navigation.ts +28 -0
  215. package/src/ssr/compact-shell.baseline.json +5270 -0
  216. package/src/ssr/compact-shell.browser-harness.ts +439 -0
  217. package/src/ssr/dialog-app-colors.browser-harness.tsx +43 -0
  218. package/src/ssr/help-messages.ts +4 -0
  219. package/src/ssr/help.ts +20 -0
  220. package/src/ssr/hints.ts +25 -0
  221. package/src/ssr/index.ts +5 -0
  222. package/src/ssr/islands/index.ts +4 -3
  223. package/src/ssr/layout-context.ts +2 -0
  224. package/src/ssr/layout-help-article.server.ts +50 -0
  225. package/src/ssr/layout-help-start.ts +33 -7
  226. package/src/ssr/panel-consent.ts +29 -0
  227. package/src/ssr/panel-context.ts +10 -0
  228. package/src/ssr/panel-slot.browser-harness.tsx +159 -0
  229. package/src/ssr/platform-messages.ts +48 -0
  230. package/src/ssr/rail-navigation.ts +44 -0
  231. package/src/ssr/shell-data.tsx +5 -0
  232. package/src/ssr/shell-shortcuts.ts +13 -2
  233. package/src/styles/effects.css +201 -11
  234. package/src/styles/file-chooser.css +2 -1
  235. package/src/styles/global.css +314 -63
  236. package/src/styles/panel.css +416 -0
  237. package/src/styles/resource-search.css +6 -2
  238. package/src/styles/tokens.css +36 -13
  239. package/src/styles/utilities-buttons.css +3 -2
  240. package/src/styles/utilities-data.css +2 -1
  241. package/src/styles/utilities-feedback.css +2 -2
  242. package/src/styles/utilities-layout.css +3 -13
  243. package/src/styles/utilities-markdown-editor.css +6 -6
  244. package/src/styles/utilities-navigation.css +22 -43
package/src/ai/runtime.ts CHANGED
@@ -8,7 +8,13 @@ import { type ApprovalDecisionChannel, approvals } from "../services/approvals";
8
8
  import { logger } from "../services/logging";
9
9
  import { superviseRuntimeTask } from "../services/runtime-lifecycle";
10
10
  import { coreSettings } from "../services/settings/api";
11
- import { AI_WEBSITE_APPROVAL_TOOL, type AiToolApprovalContext, aiTurnAllowsRememberedApprovals, rememberAiToolApproval } from "./approvals";
11
+ import {
12
+ AI_WEBSITE_APPROVAL_TOOL,
13
+ type AiToolApprovalChangeContext,
14
+ type AiToolApprovalContext,
15
+ aiTurnAllowsRememberedApprovals,
16
+ rememberAiToolApproval,
17
+ } from "./approvals";
12
18
  import { aiChatAccessSubject, selectAssistantAiModelId } from "./assistant-models";
13
19
  import { startAiDictationRuntime } from "./dictation-runtime";
14
20
  import { AiTurnExecutor, type ExecutorConfig } from "./executor";
@@ -20,8 +26,9 @@ import { aiQuotas } from "./quotas";
20
26
  import { parseAiResourceMarker } from "./resource-markers";
21
27
  import { isAiVisionModelConfigured } from "./settings";
22
28
  import { aiConversations } from "./store";
23
- import { publishAiTurnAbort, publishAiWireEvent } from "./stream";
29
+ import { publishAiWireEvent } from "./stream";
24
30
  import { aiToolAudit } from "./tool-audit";
31
+ import { finishAiTurnAbort } from "./turn-abort";
25
32
  import type {
26
33
  AiChatTurnRunConfig,
27
34
  AiClientToolId,
@@ -40,6 +47,7 @@ import type {
40
47
  import { isAiImageMediaType } from "./types";
41
48
  import { validateAiTurnRequest } from "./validate";
42
49
 
50
+ export { createAiTurnCodeAndClientTools } from "./default-tools";
43
51
  export type { ValidateAiTurnInput } from "./validate";
44
52
  export { isAiSettingsError, validateAiTurnRequest } from "./validate";
45
53
 
@@ -273,32 +281,10 @@ export const submitAiCompaction = async (input: SubmitAiCompactionInput): Promis
273
281
  // ---------------------------------------------------------------------------
274
282
 
275
283
  export const abortAiTurn = async (input: { conversationId: string; turnId: string }): Promise<{ ok: true }> => {
276
- // Capture the wire coordinates before finalization so we can emit turn_finished.
284
+ // Capture wire coordinates before the cancellation flag can wake its owner.
277
285
  const active = await aiConversations.getActiveTurn({ conversationId: input.conversationId }).catch(() => null);
278
286
  const request = await aiConversations.requestTurnAbort({ ...input, reason: "user" });
279
- if (!request.found) return { ok: true };
280
-
281
- // Tell any live owner to stop (its heartbeat also detects the cancel flag).
282
- await publishAiTurnAbort(input).catch(() => undefined);
283
-
284
- if (request.ownerless) {
285
- const finalized = await aiConversations.completeTurn({ ...input, status: "aborted", error: null });
286
- const config = await aiConversations.getTurnRunConfig(input);
287
- if (finalized === "completed" && !(config?.kind === "chat" && config.background)) {
288
- const attempt = active?.turn.id === input.turnId ? active.turn.attempt : 1;
289
- const seq = (active?.turn.id === input.turnId ? active.liveSeq : 0) + 1;
290
- await publishAiWireEvent({
291
- v: 1,
292
- conversationId: input.conversationId,
293
- turnId: input.turnId,
294
- attempt,
295
- seq,
296
- type: "turn_finished",
297
- status: "aborted",
298
- error: null,
299
- }).catch(() => undefined);
300
- }
301
- }
287
+ await finishAiTurnAbort(input, request, active);
302
288
  return { ok: true };
303
289
  };
304
290
 
@@ -331,7 +317,7 @@ export const submitAiTurnAction = async (input: {
331
317
  callId: string;
332
318
  action: AiTurnActionInput;
333
319
  decidedBy: { userId: string; via: ApprovalDecisionChannel; deviceId?: string };
334
- toolApprovalContext?: AiToolApprovalContext;
320
+ toolApprovalContext?: AiToolApprovalChangeContext;
335
321
  /** Whether a person decides in a signed-in browser session; only such a decision may remember a website. */
336
322
  signedInSession?: boolean;
337
323
  }): Promise<{ ok: true } | { ok: false; status: 400 | 403 | 404 | 409; message: string }> => {
@@ -3,6 +3,7 @@ import { HTTPException } from "hono/http-exception";
3
3
  import { z } from "zod";
4
4
  import { AuthenticatedPrincipalSchema } from "../contracts/shared";
5
5
  import { type AuthContext, auth, err, fail, ok, rateLimit, respond, v } from "../server";
6
+ import { noTransform } from "../server/middleware/no-transform";
6
7
  import { localizedAppName } from "../ssr/runtime";
7
8
  import { aiProjects } from "./projects";
8
9
  import {
@@ -63,6 +64,8 @@ type AiSkillsRouteDependencies = {
63
64
 
64
65
  const buildAiSkillsRoutes = (dependencies: AiSkillsRouteDependencies = {}) =>
65
66
  new Hono<AuthContext>()
67
+ // The list combines skills from separate grants; the writer of one can rename it next to another skill's text.
68
+ .use("*", noTransform())
66
69
  .onError((error, c) => {
67
70
  if (error instanceof AiSkillAppForbiddenError) return respond(c, fail(err.forbidden(error.message)));
68
71
  if (error instanceof HTTPException) return error.getResponse();
package/src/ai/skills.ts CHANGED
@@ -522,7 +522,15 @@ const skillSnapshotFiles = (skill: AiSkill): { path: string; content: string }[]
522
522
  ...skill.references.map((reference) => ({ path: `${skill.name}/${reference.path}`, content: reference.content })),
523
523
  ];
524
524
 
525
- type SkillListOptions = { name?: string; query?: string; enabledOnly?: boolean; modelInvocableOnly?: boolean; limit?: number };
525
+ type SkillListOptions = {
526
+ name?: string;
527
+ /** Only these Skills, by short ID, such as the ones a person recently sent. */
528
+ shortIds?: readonly string[];
529
+ query?: string;
530
+ enabledOnly?: boolean;
531
+ modelInvocableOnly?: boolean;
532
+ limit?: number;
533
+ };
526
534
  async function listSkillSummaries(
527
535
  subject: AccessSubject | null,
528
536
  options: SkillListOptions & {
@@ -551,6 +559,7 @@ async function listSkillSummaries(
551
559
  ON disabled.skill_id = skill.id AND disabled.user_id = ${userId}::uuid
552
560
  WHERE access.permission <> 'none'
553
561
  AND ${options.name === undefined ? sql`true` : sql`skill.name = ${options.name}`}
562
+ AND ${options.shortIds === undefined ? sql`true` : sql`skill.short_id = ANY(${toPgTextArray([...options.shortIds])}::text[])`}
554
563
  AND (${!options.manageOnly} OR access.permission='admin')
555
564
  AND ${options.project ? sql`EXISTS(SELECT 1 FROM ai.project_skills link WHERE link.skill_id=skill.id AND link.project_id=${options.project.id}::uuid) = ${options.project.linked}` : sql`true`}
556
565
  AND (${!options.modelInvocableOnly} OR NOT manual_only)
package/src/ai/store.ts CHANGED
@@ -1,13 +1,19 @@
1
1
  import type { DoneReason, InboundEvent, LoopAggregate, Message, SessionStore, StoreEntry } from "@k2b/nessi";
2
2
  import type { Usage } from "@k2b/nessi/ai";
3
3
  import { type SQL, sql } from "bun";
4
- import { type CapabilityActionReview, CapabilityActionReviewSchema } from "../contracts/capabilities";
4
+ import { type CapabilityActionReviewData, CapabilityActionReviewDataSchema } from "../contracts/capabilities";
5
5
  import { logger } from "../services/logging";
6
6
  import { toPgTextArray } from "../services/postgres";
7
+ import { AI_TOOL_CHECK_STALE_MS, type AiActionCheck, type AiStoredActionReview } from "./action-review";
8
+
9
+ export { AI_TOOL_CHECK_STALE_MS };
10
+
7
11
  import { AI_WEBSITE_APPROVAL_TOOL } from "./approvals";
12
+ import { projectSettledAiToolChecks } from "./capability-result";
8
13
  import { aiTurnErrorText } from "./chat/turn-error";
9
14
  import { AI_MEMORY_LEARNING_DEFAULT_ENABLED } from "./prefs";
10
15
  import type { AiTurnBlock } from "./protocol";
16
+ import { collectRecentSentResourceRefs, RECENT_SENT_CONVERSATION_SCAN_LIMIT, RECENT_SENT_MESSAGE_SCAN_LIMIT } from "./recent-sent-refs";
11
17
  import { AiRunTimeout } from "./run-timeout";
12
18
  import { withAiShortId, withAiShortIdForDb } from "./short-id";
13
19
  import { parseAiTodoPlan } from "./todo-contracts";
@@ -65,6 +71,45 @@ export const resolveAiTurnShortIds = async (input: {
65
71
  return new Map(rows.map((row) => [row.id, row.short_id]));
66
72
  };
67
73
 
74
+ /**
75
+ * Marker texts of the owner's latest sent messages, newest first. The candidates carry no message bodies; only the
76
+ * newest few are read for their resource markers.
77
+ */
78
+ export const recentSentResourceMarkersQuery = (ownerUserId: string) => sql<
79
+ { conversation_id: string; short_id: string; created_at: Date | string; markers: string[] }[]
80
+ >`
81
+ WITH conversations AS MATERIALIZED (
82
+ SELECT id FROM ai.conversations
83
+ WHERE created_by_user_id = ${ownerUserId}::uuid AND archived_at IS NULL
84
+ ORDER BY updated_at DESC
85
+ LIMIT ${RECENT_SENT_CONVERSATION_SCAN_LIMIT}
86
+ ), recent_messages AS MATERIALIZED (
87
+ SELECT candidate.id, candidate.conversation_id, candidate.created_at FROM conversations
88
+ CROSS JOIN LATERAL (
89
+ SELECT message.id, message.conversation_id, message.created_at
90
+ FROM ai.messages message
91
+ WHERE message.conversation_id = conversations.id
92
+ AND message.role = 'user' AND message.kind = 'message'
93
+ AND NOT (COALESCE(message.meta, '{}'::jsonb) ? 'scheduledTask')
94
+ AND NOT (COALESCE(message.meta, '{}'::jsonb) ? 'agentMessage')
95
+ ORDER BY message.seq DESC
96
+ LIMIT ${RECENT_SENT_MESSAGE_SCAN_LIMIT}
97
+ ) candidate
98
+ ORDER BY candidate.created_at DESC, candidate.id DESC
99
+ LIMIT ${RECENT_SENT_MESSAGE_SCAN_LIMIT}
100
+ )
101
+ SELECT recent_messages.conversation_id, sent.short_id, recent_messages.created_at,
102
+ array_agg(part->>'text' ORDER BY ordinal) AS markers
103
+ FROM recent_messages
104
+ JOIN ai.messages sent ON sent.id = recent_messages.id
105
+ CROSS JOIN LATERAL jsonb_array_elements(
106
+ CASE WHEN jsonb_typeof(sent.message->'content') = 'array' THEN sent.message->'content' ELSE '[]'::jsonb END
107
+ ) WITH ORDINALITY AS content(part, ordinal)
108
+ WHERE part->>'type' = 'text' AND ltrim(part->>'text') LIKE '[Attached Cloud resource %'
109
+ GROUP BY recent_messages.id, recent_messages.conversation_id, sent.short_id, recent_messages.created_at
110
+ ORDER BY recent_messages.created_at DESC, recent_messages.id DESC
111
+ `;
112
+
68
113
  type ConversationSearchBackend = "native" | "bm25";
69
114
  let conversationSearchBackendPromise: Promise<ConversationSearchBackend> | null = null;
70
115
 
@@ -485,9 +530,9 @@ const parseJsonValue = <T>(value: unknown): T => {
485
530
  return value as T;
486
531
  };
487
532
 
488
- const parseCapabilityActionReview = (value: unknown): CapabilityActionReview | undefined => {
533
+ const parseCapabilityActionReview = (value: unknown): CapabilityActionReviewData | undefined => {
489
534
  if (value === null || value === undefined) return undefined;
490
- const parsed = CapabilityActionReviewSchema.safeParse(parseJsonValue(value));
535
+ const parsed = CapabilityActionReviewDataSchema.safeParse(parseJsonValue(value));
491
536
  return parsed.success ? parsed.data : undefined;
492
537
  };
493
538
 
@@ -530,6 +575,13 @@ const conversationRunStatus = (status: AiTurnStatus | null | undefined, browserP
530
575
  const todoMessageJson = () => sql`(CASE WHEN jsonb_typeof(message) = 'string' THEN (message #>> '{}')::jsonb ELSE message END)`;
531
576
  const todoMetaJson = () => sql`(CASE WHEN jsonb_typeof(meta) = 'string' THEN (meta #>> '{}')::jsonb ELSE meta END)`;
532
577
 
578
+ /** What a copied message keeps of its meta: its todo plan, and its calls' reviews and checks, so a fork reads the same. */
579
+ const copiedMessageMetaJson = () => sql`NULLIF(jsonb_strip_nulls(jsonb_build_object(
580
+ 'todoPlan', ${todoMetaJson()}->'todoPlan',
581
+ 'toolReviews', ${todoMetaJson()}->'toolReviews',
582
+ 'toolChecks', ${todoMetaJson()}->'toolChecks'
583
+ )), '{}'::jsonb)`;
584
+
533
585
  // A scheduled result arriving during an interactive turn becomes context on the next turn.
534
586
  const visibleToTurn = (turnId?: string) => sql`(
535
587
  ${turnId ?? null}::text IS NULL OR NOT COALESCE(meta ? 'scheduledTask', false) OR seq < ALL (
@@ -929,6 +981,114 @@ export const recordAiWebsiteReceipts = async (input: {
929
981
  `;
930
982
  };
931
983
 
984
+ /** Reviews and outcome checks belong to the assistant message containing the call, including scheduled turns. */
985
+ const recordAiToolMeta = async (
986
+ input: { conversationId: string; turnId: string; callId: string },
987
+ key: "toolReviews" | "toolChecks",
988
+ value: AiStoredActionReview | AiActionCheck,
989
+ db = sql,
990
+ ): Promise<void> => {
991
+ let written = false;
992
+ for (const table of ["ai.messages", "ai.task_messages"]) {
993
+ const rows = await db<{ id: string }[]>`
994
+ UPDATE ${db(table)}
995
+ SET meta = COALESCE(meta, '{}'::jsonb) || jsonb_build_object(
996
+ ${key}::text, COALESCE(meta->${key}::text, '{}'::jsonb) ||
997
+ jsonb_build_object(${input.callId}::text, ${JSON.stringify(value)}::text::jsonb)
998
+ )
999
+ WHERE conversation_id = ${input.conversationId}::uuid AND loop_id = ${input.turnId}::text
1000
+ AND role = 'assistant'
1001
+ AND message->'content' @> jsonb_build_array(jsonb_build_object('type', 'tool_call', 'id', ${input.callId}::text))
1002
+ RETURNING id`;
1003
+ written ||= rows.length > 0;
1004
+ }
1005
+ if (!written) throw new Error("AI tool call message is unavailable.");
1006
+ };
1007
+
1008
+ export const recordAiToolReview = (input: { conversationId: string; turnId: string; callId: string; review: AiStoredActionReview }) =>
1009
+ recordAiToolMeta(input, "toolReviews", input.review);
1010
+
1011
+ export const recordAiToolCheck = (input: { conversationId: string; turnId: string; callId: string; check: AiActionCheck }, db = sql) =>
1012
+ recordAiToolMeta(input, "toolChecks", input.check, db);
1013
+
1014
+ /**
1015
+ * Writes one call's check only where `allowed` holds for its current state, in one statement per table, so two Core
1016
+ * replicas or the executor and a person's re-check can never both hold a check, and a settled result is never replaced.
1017
+ */
1018
+ const writeAiToolCheckWhere = async (
1019
+ input: { conversationId: string; turnId: string; callId: string },
1020
+ check: AiActionCheck,
1021
+ allowed: (current: SQL.Query<unknown>) => SQL.Query<unknown>,
1022
+ ): Promise<boolean> => {
1023
+ let written = false;
1024
+ for (const table of ["ai.messages", "ai.task_messages"]) {
1025
+ const current = sql`meta->'toolChecks'->${input.callId}::text`;
1026
+ const rows = await sql<{ id: string }[]>`
1027
+ UPDATE ${sql(table)}
1028
+ SET meta = COALESCE(meta, '{}'::jsonb) || jsonb_build_object(
1029
+ 'toolChecks', COALESCE(meta->'toolChecks', '{}'::jsonb) ||
1030
+ jsonb_build_object(${input.callId}::text, ${JSON.stringify(check)}::text::jsonb)
1031
+ )
1032
+ WHERE conversation_id = ${input.conversationId}::uuid AND loop_id = ${input.turnId}::text
1033
+ AND role = 'assistant'
1034
+ AND message->'content' @> jsonb_build_array(jsonb_build_object('type', 'tool_call', 'id', ${input.callId}::text))
1035
+ AND (${allowed(current)})
1036
+ RETURNING id`;
1037
+ written ||= rows.length > 0;
1038
+ }
1039
+ return written;
1040
+ };
1041
+
1042
+ /**
1043
+ * Claims the check of one call: stores `checking` at `at`, which is the claim's token, when no check holds and none
1044
+ * settled. A call without any check yet may be claimed only with `first`, which the executor uses after an unknown
1045
+ * outcome. A `checking` older than `staleBefore` belonged to a worker or request that stopped and may be taken over.
1046
+ */
1047
+ export const claimAiToolCheck = (input: {
1048
+ conversationId: string;
1049
+ turnId: string;
1050
+ callId: string;
1051
+ at: string;
1052
+ staleBefore: string;
1053
+ first?: boolean;
1054
+ }): Promise<boolean> =>
1055
+ writeAiToolCheckWhere(
1056
+ input,
1057
+ { state: "checking", at: input.at },
1058
+ (current) => sql`
1059
+ ${input.first ? sql`${current} IS NULL OR` : sql``}
1060
+ ${current}->>'state' IN ('pending', 'failed')
1061
+ OR (${current}->>'state' = 'checking' AND COALESCE(${current}->>'at', '') < ${input.staleBefore}::text)`,
1062
+ );
1063
+
1064
+ /** Stores the result of the claim made at `claimedAt`, only while that claim still holds. */
1065
+ export const settleAiToolCheck = (input: {
1066
+ conversationId: string;
1067
+ turnId: string;
1068
+ callId: string;
1069
+ claimedAt: string;
1070
+ check: AiActionCheck;
1071
+ }): Promise<boolean> =>
1072
+ writeAiToolCheckWhere(
1073
+ input,
1074
+ input.check,
1075
+ (current) => sql`${current}->>'state' = 'checking' AND ${current}->>'at' = ${input.claimedAt}::text`,
1076
+ );
1077
+
1078
+ /** The stored check of one call, or null. */
1079
+ export const readAiToolCheck = async (input: { conversationId: string; turnId: string; callId: string }): Promise<AiActionCheck | null> => {
1080
+ for (const table of ["ai.messages", "ai.task_messages"]) {
1081
+ const [row] = await sql<{ check: AiActionCheck | null }[]>`
1082
+ SELECT meta->'toolChecks'->${input.callId}::text AS check FROM ${sql(table)}
1083
+ WHERE conversation_id = ${input.conversationId}::uuid AND loop_id = ${input.turnId}::text
1084
+ AND role = 'assistant'
1085
+ AND message->'content' @> jsonb_build_array(jsonb_build_object('type', 'tool_call', 'id', ${input.callId}::text))
1086
+ LIMIT 1`;
1087
+ if (row?.check) return row.check;
1088
+ }
1089
+ return null;
1090
+ };
1091
+
932
1092
  /** Insert a message inside an open conversation-lock transaction and bump the conversation. */
933
1093
  const insertMessageLocked = async (
934
1094
  input: {
@@ -1219,15 +1379,17 @@ export const aiConversations: AiConversationService = {
1219
1379
  );
1220
1380
  const target = rows[0];
1221
1381
  if (!target) throw new Error("Failed to fork conversation.");
1382
+ // Copies keep their original send time because Recent orders by it.
1222
1383
  await tx`
1223
1384
  INSERT INTO ai.messages (
1224
1385
  short_id, conversation_id, seq, kind, role, message, search_text, loop_id,
1225
- model_profile_id, provider_model, usage, stop_reason, loop_aggregate, loop_done_reason, meta
1386
+ model_profile_id, provider_model, usage, stop_reason, loop_aggregate, loop_done_reason, meta, created_at
1226
1387
  )
1227
1388
  SELECT
1228
1389
  short_id, ${target.id}::uuid, seq, kind, role, message, search_text, loop_id,
1229
1390
  model_profile_id, provider_model, usage, stop_reason, loop_aggregate, loop_done_reason,
1230
- CASE WHEN ${todoMetaJson()} ? 'todoPlan' THEN jsonb_build_object('todoPlan', ${todoMetaJson()}->'todoPlan') ELSE NULL END
1391
+ ${copiedMessageMetaJson()},
1392
+ created_at
1231
1393
  FROM ai.messages
1232
1394
  WHERE conversation_id = ${input.sourceConversationId}::uuid
1233
1395
  AND compacted_at IS NULL
@@ -1563,6 +1725,18 @@ export const aiConversations: AiConversationService = {
1563
1725
  };
1564
1726
  },
1565
1727
 
1728
+ listRecentSentResourceRefs: async ({ ownerUserId }) => {
1729
+ const rows = await recentSentResourceMarkersQuery(ownerUserId);
1730
+ return collectRecentSentResourceRefs(
1731
+ rows.map((row) => ({
1732
+ conversationId: row.conversation_id,
1733
+ messageId: row.short_id,
1734
+ lastSentAt: iso(row.created_at),
1735
+ markers: row.markers,
1736
+ })),
1737
+ );
1738
+ },
1739
+
1566
1740
  listUserConversationResources: async (input) => {
1567
1741
  const limit = Math.min(Math.max(Math.floor(input.limit ?? 20), 1), 100);
1568
1742
  const pattern = searchPattern(input.search);
@@ -2507,6 +2681,7 @@ export const aiConversations: AiConversationService = {
2507
2681
 
2508
2682
  await sql.begin(async (tx) => {
2509
2683
  await tx`SELECT id FROM ai.conversations WHERE id = ${input.targetConversationId} FOR UPDATE`;
2684
+ // Copies keep their original send time because Recent orders by it.
2510
2685
  await tx`
2511
2686
  INSERT INTO ai.messages (
2512
2687
  short_id,
@@ -2523,7 +2698,8 @@ export const aiConversations: AiConversationService = {
2523
2698
  stop_reason,
2524
2699
  loop_aggregate,
2525
2700
  loop_done_reason,
2526
- meta
2701
+ meta,
2702
+ created_at
2527
2703
  )
2528
2704
  SELECT
2529
2705
  short_id,
@@ -2540,7 +2716,8 @@ export const aiConversations: AiConversationService = {
2540
2716
  stop_reason,
2541
2717
  loop_aggregate,
2542
2718
  loop_done_reason,
2543
- CASE WHEN ${todoMetaJson()} ? 'todoPlan' THEN jsonb_build_object('todoPlan', ${todoMetaJson()}->'todoPlan') ELSE NULL END
2719
+ ${copiedMessageMetaJson()},
2720
+ created_at
2544
2721
  FROM ai.messages
2545
2722
  WHERE conversation_id = ${input.sourceConversationId}
2546
2723
  AND compacted_at IS NULL
@@ -2862,10 +3039,33 @@ export const aiConversations: AiConversationService = {
2862
3039
  `;
2863
3040
  if (!rows[0]) return null;
2864
3041
  // The waits let a reconnecting client show work time without time spent waiting for the user.
2865
- const waits = await activeTurnWaits(rows[0].id);
3042
+ const [waits, metadata] = await Promise.all([
3043
+ activeTurnWaits(rows[0].id),
3044
+ sql<{ tool_reviews: Record<string, AiStoredActionReview> | null; tool_checks: Record<string, AiActionCheck> | null }[]>`
3045
+ SELECT meta->'toolReviews' AS tool_reviews, meta->'toolChecks' AS tool_checks
3046
+ FROM (
3047
+ SELECT meta, seq, conversation_id, loop_id, role FROM ai.messages
3048
+ UNION ALL
3049
+ SELECT meta, seq, conversation_id, loop_id, role FROM ai.task_messages
3050
+ ) messages
3051
+ WHERE conversation_id = ${input.conversationId}
3052
+ AND loop_id = ${rows[0].id}
3053
+ AND role = 'assistant'
3054
+ AND (meta ? 'toolReviews' OR meta ? 'toolChecks')
3055
+ ORDER BY seq ASC
3056
+ `,
3057
+ ]);
3058
+ // A person may re-check while the model continues. Durable checks outrank the worker's earlier live snapshot.
3059
+ const reviews = new Map(metadata.flatMap((row) => Object.entries(row.tool_reviews ?? {})));
3060
+ const checks = new Map(metadata.flatMap((row) => Object.entries(row.tool_checks ?? {})));
2866
3061
  return {
2867
3062
  turn: rowToTurn(rows[0]),
2868
- liveBlocks: rowToLiveBlocks(rows[0]),
3063
+ liveBlocks: rowToLiveBlocks(rows[0]).map((block) => {
3064
+ if (block.kind !== "tool") return block;
3065
+ const review = reviews.get(block.callId);
3066
+ const check = checks.get(block.callId);
3067
+ return { ...block, ...(review ? { review } : {}), ...(check ? { check } : {}) };
3068
+ }),
2869
3069
  liveSeq: Number(rows[0].live_seq ?? 0),
2870
3070
  actionWaitMs: Math.max(0, Math.round(waits.actionWaitMs)),
2871
3071
  waitingSince: waits.waitingSince,
@@ -2992,12 +3192,14 @@ export const aiConversations: AiConversationService = {
2992
3192
  return Boolean(rows[0]);
2993
3193
  },
2994
3194
 
2995
- requestTurnAbort: async (input) => {
3195
+ requestTurnAbort: async (input, options) => {
2996
3196
  const reason = input.reason ?? "user";
2997
- const rows = await sql<TurnRow[]>`
3197
+ const rows = await (options?.db ?? sql)<TurnRow[]>`
2998
3198
  UPDATE ai.turns
2999
3199
  SET cancel_requested_at = COALESCE(cancel_requested_at, now()),
3000
- cancellation_reason = COALESCE(cancellation_reason, ${reason})
3200
+ cancellation_reason = CASE WHEN ${reason} = 'routine_deleted' THEN ${reason}
3201
+ WHEN ${reason} = 'routine_paused' AND cancellation_reason IS DISTINCT FROM 'routine_deleted' THEN ${reason}
3202
+ ELSE COALESCE(cancellation_reason, ${reason}) END
3001
3203
  WHERE id = ${input.turnId}
3002
3204
  AND conversation_id = ${input.conversationId}
3003
3205
  AND status IN ('queued', 'running', 'waiting_for_action')
@@ -3576,7 +3778,7 @@ export const aiConversations: AiConversationService = {
3576
3778
  if (input.background && input.turnId) {
3577
3779
  const own = await aiConversations.listTurnMessages({ conversationId: input.conversationId, loopId: input.turnId });
3578
3780
  const context = input.background.context;
3579
- return [...context, ...own.map((row) => ({ seq: context.length + row.seq, kind: row.kind, message: row.message }))];
3781
+ return [...context, ...projectSettledAiToolChecks(own.map((row) => ({ ...row, seq: context.length + row.seq })))];
3580
3782
  }
3581
3783
  // Results delivered after this turn started belong to the next turn's context.
3582
3784
  // Compare durable message sequences rather than wall-clock timestamps.
@@ -3590,20 +3792,23 @@ export const aiConversations: AiConversationService = {
3590
3792
  `
3591
3793
  ).map(rowToMessage)
3592
3794
  : await aiConversations.listContextMessages({ conversationId: input.conversationId });
3593
- return rows.map((row) => ({
3594
- seq: row.seq,
3595
- kind: row.kind,
3596
- message:
3597
- input.turnInput !== undefined && input.turnId === row.loopId && row.message.role === "user" && !row.meta?.steerId
3598
- ? {
3599
- role: "user" as const,
3600
- content:
3601
- typeof input.turnInput === "string"
3602
- ? [{ type: "text" as const, text: input.turnInput }]
3603
- : input.turnInput.map((part) => (typeof part === "string" ? { type: "text" as const, text: part } : part)),
3604
- }
3605
- : row.message,
3606
- }));
3795
+ return projectSettledAiToolChecks(
3796
+ rows.map((row) => ({
3797
+ seq: row.seq,
3798
+ kind: row.kind,
3799
+ meta: row.meta,
3800
+ message:
3801
+ input.turnInput !== undefined && input.turnId === row.loopId && row.message.role === "user" && !row.meta?.steerId
3802
+ ? {
3803
+ role: "user" as const,
3804
+ content:
3805
+ typeof input.turnInput === "string"
3806
+ ? [{ type: "text" as const, text: input.turnInput }]
3807
+ : input.turnInput.map((part) => (typeof part === "string" ? { type: "text" as const, text: part } : part)),
3808
+ }
3809
+ : row.message,
3810
+ })),
3811
+ );
3607
3812
  },
3608
3813
  append: async (message, opts) => {
3609
3814
  // Initial input and durable steering are already persisted transactionally before Nessi appends them.
@@ -36,6 +36,9 @@ const isAssistantPart = (entry: AiStoredMessage): boolean =>
36
36
  entry.kind === "message" && (entry.message.role === "assistant" || entry.message.role === "tool_result");
37
37
 
38
38
  const sameLoop = (entry: AiStoredMessage, loopId: string | null): boolean => (loopId ? entry.loopId === loopId : entry.loopId === null);
39
+ /** Each delivered routine run is its own answer, with its own run link and system line, also when runs follow each other. */
40
+ const sameRoutineRun = (entry: AiStoredMessage, first: AiStoredMessage): boolean =>
41
+ (entry.meta?.scheduledTask?.occurrenceId ?? null) === (first.meta?.scheduledTask?.occurrenceId ?? null);
39
42
 
40
43
  const assistantActionEntry = (entries: AiStoredMessage[]): AiStoredMessage | null => {
41
44
  const assistantEntries = entries.filter((entry) => entry.kind === "message" && entry.message.role === "assistant");
@@ -107,7 +110,12 @@ export const buildAiMessageTimeline = (messages: AiStoredMessage[]): AiMessageTi
107
110
  pendingSteers = [];
108
111
  const entries = [entry];
109
112
  index += 1;
110
- while (index < messages.length && isAssistantPart(messages[index]!) && sameLoop(messages[index]!, loopId)) {
113
+ while (
114
+ index < messages.length &&
115
+ isAssistantPart(messages[index]!) &&
116
+ sameLoop(messages[index]!, loopId) &&
117
+ (loopId !== null || sameRoutineRun(messages[index]!, entry))
118
+ ) {
111
119
  entries.push(messages[index]!);
112
120
  index += 1;
113
121
  }
@@ -30,6 +30,14 @@ export const aiToolAudit = {
30
30
  LIMIT 1`;
31
31
  return row?.conversation_id ?? null;
32
32
  },
33
+ hasCapabilityDispatch: async (input: { turnId: string; callId: string }): Promise<boolean> => {
34
+ const rows = await sql`
35
+ SELECT 1 FROM ai.tool_calls
36
+ WHERE turn_id = ${input.turnId} AND call_id = ${input.callId} AND request_id IS NOT NULL
37
+ LIMIT 1
38
+ `;
39
+ return rows.length > 0;
40
+ },
33
41
  noteCapabilityDispatch: async (input: {
34
42
  conversationId: string;
35
43
  turnId: string;
@@ -0,0 +1,67 @@
1
+ import {
2
+ type CapabilityActionWording,
3
+ capabilityActionOutcome,
4
+ capabilityActionSubject,
5
+ capabilityReviewSentence,
6
+ capabilitySentenceText,
7
+ resolveCapabilityActionSentences,
8
+ } from "../_internal/capability-sentences";
9
+ import { CapabilityActionReceiptSchema } from "../contracts/capabilities";
10
+ import { type AiStoredActionReview, isLegacyActionReview, storedActionReview } from "./action-review";
11
+ import { displayToolName, isRecord } from "./chat/message-utils";
12
+ import type { AiTurnBlock } from "./protocol";
13
+ import { CLOUD_AI_TOOL_PRESENTATION } from "./tool-sentences";
14
+
15
+ type ToolBlock = Extract<AiTurnBlock, { kind: "tool" }>;
16
+
17
+ /** The review Cloud stored for the call, else the one its open approval carries. */
18
+ const reviewOf = (block: ToolBlock): AiStoredActionReview | null =>
19
+ block.review ?? (block.approval?.review ? storedActionReview(block.approval.review) : null);
20
+
21
+ /**
22
+ * How the chat words one call that acts on something: an app Action through its saved presentation, a
23
+ * Cloud tool through Cloud's own catalog of the same shape. `null` for calls that only read.
24
+ */
25
+ export const toolWording = (block: ToolBlock, locale: string): CapabilityActionWording | null => {
26
+ const presentation = block.presentation;
27
+ if (presentation?.kind === "capability") {
28
+ if (presentation.capabilityKind !== "action") return null;
29
+ return {
30
+ title: presentation.title,
31
+ ...(presentation.sentences ? { sentences: presentation.sentences } : {}),
32
+ ...(presentation.fields ? { fields: presentation.fields } : {}),
33
+ };
34
+ }
35
+ const sentences = resolveCapabilityActionSentences(block.name, CLOUD_AI_TOOL_PRESENTATION, locale);
36
+ return sentences ? { title: displayToolName(block.name, locale), sentences } : null;
37
+ };
38
+
39
+ /**
40
+ * What the call does or would do, as one sentence; `null` for calls that only read. A stored review's action sentence
41
+ * wins, so it reads the same after a reload and after the app changed. An old review's message is the question of the
42
+ * card, not the call's short subject: steps and receipts keep the app's catalog sentence.
43
+ */
44
+ export const toolSubject = (block: ToolBlock, locale: string, timeZone: string): string | null => {
45
+ const review = reviewOf(block);
46
+ if (review && !isLegacyActionReview(review))
47
+ return capabilitySentenceText(review.sentence.action, review.slots, { locale: review.locale, timeZone });
48
+ const wording = toolWording(block, locale);
49
+ return wording ? capabilityActionSubject(wording, block.args, { locale, timeZone }) : null;
50
+ };
51
+
52
+ /** The sentence for one outcome, or `null` when the tool has none that renders for this call. */
53
+ export const toolOutcome = (block: ToolBlock, key: "done" | "rejected" | "notRun", locale: string, timeZone: string): string | null => {
54
+ const result = isRecord(block.result) ? block.result : undefined;
55
+ const review = reviewOf(block);
56
+ if (review && !isLegacyActionReview(review)) {
57
+ const receipt = key === "done" ? CapabilityActionReceiptSchema.safeParse(result?.receipt) : null;
58
+ if (receipt?.success) return capabilitySentenceText(receipt.data.text, receipt.data.slots ?? {}, { locale: review.locale, timeZone });
59
+ return capabilityReviewSentence(review, key, { timeZone });
60
+ }
61
+ if (review && key === "done" && review.legacy.summary) return review.legacy.summary;
62
+ const wording = toolWording(block, locale);
63
+ if (!wording) return null;
64
+ // App Actions return their data inside the result envelope; Cloud tools return the data itself.
65
+ const data = block.presentation?.kind === "capability" ? result?.data : block.result;
66
+ return capabilityActionOutcome(wording, key, { input: block.args, data }, { locale, timeZone });
67
+ };
@@ -0,0 +1,33 @@
1
+ import { aiConversations } from "./store";
2
+ import { publishAiTurnAbort, publishAiWireEvent } from "./stream";
3
+ import type { AiTurnAbortRequest } from "./types";
4
+
5
+ /** Finish a committed cancellation request; shared by interactive and routine cancellation. */
6
+ export const finishAiTurnAbort = async (
7
+ input: { conversationId: string; turnId: string },
8
+ request: AiTurnAbortRequest,
9
+ active: Awaited<ReturnType<typeof aiConversations.getActiveTurn>> = null,
10
+ ): Promise<void> => {
11
+ if (!request.found) return;
12
+ // Tell any live owner to stop (its heartbeat also detects the cancel flag).
13
+ await publishAiTurnAbort(input).catch(() => undefined);
14
+
15
+ if (request.ownerless) {
16
+ const finalized = await aiConversations.completeTurn({ ...input, status: "aborted", error: null });
17
+ const config = await aiConversations.getTurnRunConfig(input);
18
+ if (finalized === "completed" && !(config?.kind === "chat" && config.background)) {
19
+ const attempt = active?.turn.id === input.turnId ? active.turn.attempt : 1;
20
+ const seq = (active?.turn.id === input.turnId ? active.liveSeq : 0) + 1;
21
+ await publishAiWireEvent({
22
+ v: 1,
23
+ conversationId: input.conversationId,
24
+ turnId: input.turnId,
25
+ attempt,
26
+ seq,
27
+ type: "turn_finished",
28
+ status: "aborted",
29
+ error: null,
30
+ }).catch(() => undefined);
31
+ }
32
+ }
33
+ };