@k2b/cloud 0.39.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 (194) hide show
  1. package/README.md +8 -0
  2. package/package.json +8 -4
  3. package/src/_internal/app-favicon.ts +4 -2
  4. package/src/_internal/capabilities.ts +72 -48
  5. package/src/_internal/capability-reviews.ts +81 -0
  6. package/src/_internal/capability-schemas.ts +29 -0
  7. package/src/_internal/capability-sentences.ts +216 -1
  8. package/src/_internal/capability-streams.ts +7 -0
  9. package/src/_internal/define-app.ts +36 -15
  10. package/src/_internal/font-preloads.ts +2 -1
  11. package/src/_internal/runtime-watcher.ts +6 -0
  12. package/src/_internal/static-assets.ts +52 -3
  13. package/src/_internal/status-preserving-ssr.ts +2 -0
  14. package/src/access/PermissionEditor.tsx +2 -2
  15. package/src/account/EntitySearch.tsx +1 -1
  16. package/src/ai/action-review.ts +101 -0
  17. package/src/ai/background-review.ts +72 -0
  18. package/src/ai/capabilities.ts +29 -11
  19. package/src/ai/capability-execution.ts +76 -43
  20. package/src/ai/capability-result.ts +95 -0
  21. package/src/ai/chat/action-card.ts +398 -0
  22. package/src/ai/chat/action-messages.ts +129 -0
  23. package/src/ai/chat/action-question.browser-harness.tsx +245 -0
  24. package/src/ai/chat/action-question.fixtures.ts +733 -0
  25. package/src/ai/chat/action-question.tsx +669 -0
  26. package/src/ai/chat/blocks.tsx +19 -25
  27. package/src/ai/chat/message-actions.tsx +6 -0
  28. package/src/ai/chat/tool-action.tsx +481 -0
  29. package/src/ai/chat/turn-view.tsx +10 -160
  30. package/src/ai/chat/view-block.tsx +15 -5
  31. package/src/ai/chat-task-contracts.ts +4 -1
  32. package/src/ai/chat-task-reasons-migrate.ts +7 -0
  33. package/src/ai/chat-task-reasons.ts +1 -1
  34. package/src/ai/chat-tasks.ts +55 -25
  35. package/src/ai/client/controller.ts +59 -0
  36. package/src/ai/default-tools.ts +9 -1
  37. package/src/ai/executor.ts +190 -39
  38. package/src/ai/index.ts +2 -0
  39. package/src/ai/migrate.ts +11 -2
  40. package/src/ai/projects-routes.ts +3 -0
  41. package/src/ai/protocol.ts +14 -2
  42. package/src/ai/recent-resources.ts +240 -0
  43. package/src/ai/recent-sent-refs.ts +38 -0
  44. package/src/ai/routes.ts +145 -1
  45. package/src/ai/runtime.ts +1 -0
  46. package/src/ai/skills-routes.ts +3 -0
  47. package/src/ai/skills.ts +10 -1
  48. package/src/ai/store.ts +227 -24
  49. package/src/ai/tool-audit.ts +8 -0
  50. package/src/ai/tool-wording.ts +24 -2
  51. package/src/ai/types.ts +28 -3
  52. package/src/api/accounts-entities.ts +2 -1
  53. package/src/api/admin-ai-projects.ts +2 -0
  54. package/src/api/admin-ai-skills.ts +2 -0
  55. package/src/api/admin-ai-usage.ts +2 -0
  56. package/src/api/admin-geoip.ts +80 -0
  57. package/src/api/admin-identity.ts +3 -0
  58. package/src/api/admin-lifecycle.ts +3 -0
  59. package/src/api/admin-outgoing-mail.ts +2 -0
  60. package/src/api/app-approval.ts +46 -5
  61. package/src/api/approvals-loader.ts +23 -1
  62. package/src/api/approvals.ts +2 -0
  63. package/src/api/auth.ts +25 -6
  64. package/src/api/capabilities.ts +44 -11
  65. package/src/api/capability-streams.ts +12 -0
  66. package/src/api/help.ts +4 -28
  67. package/src/api/index.ts +6 -1
  68. package/src/api/lasting-access-messages.ts +17 -0
  69. package/src/api/mcp.ts +3 -0
  70. package/src/api/me-panel-consent.ts +71 -0
  71. package/src/api/me.ts +80 -33
  72. package/src/api/search.ts +3 -1
  73. package/src/api/sync-ops.ts +2 -0
  74. package/src/api/widgets.ts +3 -0
  75. package/src/approvals/Approvals.tsx +180 -238
  76. package/src/approvals/messages.ts +12 -24
  77. package/src/browser/CloudResourceSearch.browser-harness.tsx +10 -2
  78. package/src/browser/app-approval.ts +54 -5
  79. package/src/browser/commands.ts +22 -6
  80. package/src/browser/composer-catalog.ts +45 -3
  81. package/src/browser/composer-insert.ts +10 -0
  82. package/src/browser/composer-open-messages.ts +17 -0
  83. package/src/browser/composer-open.ts +110 -0
  84. package/src/browser/discoverable-command.ts +6 -0
  85. package/src/browser/hints.ts +6 -0
  86. package/src/browser/live-connection.ts +26 -2
  87. package/src/browser/navigation-apps.ts +7 -0
  88. package/src/browser/panel-context-bridge.ts +30 -0
  89. package/src/browser/panel-context-receiver.ts +53 -0
  90. package/src/browser/panel-menu.tsx +56 -0
  91. package/src/browser/panel-messages.ts +11 -0
  92. package/src/browser/panel-shortcuts.ts +31 -0
  93. package/src/browser/panel-slot.ts +412 -10
  94. package/src/browser/panel.ts +37 -0
  95. package/src/browser/search-commands.ts +6 -3
  96. package/src/capabilities/client.ts +8 -2
  97. package/src/capabilities/reviews.ts +205 -0
  98. package/src/capabilities/server.ts +10 -3
  99. package/src/capabilities/testing.ts +47 -0
  100. package/src/capabilities/types.ts +2 -2
  101. package/src/cli/account.ts +13 -3
  102. package/src/cli/admin/geoip.ts +115 -0
  103. package/src/cli/admin/index.ts +3 -0
  104. package/src/contracts/app-approval.ts +120 -13
  105. package/src/contracts/approvals.ts +2 -2
  106. package/src/contracts/capabilities.ts +410 -18
  107. package/src/contracts/geoip.ts +42 -0
  108. package/src/contracts/index.ts +3 -0
  109. package/src/contracts/panel-context-plain.ts +99 -0
  110. package/src/contracts/panel-context.ts +93 -0
  111. package/src/contracts/seen-hints.ts +35 -0
  112. package/src/server/client-ip.ts +5 -0
  113. package/src/server/help-payload.ts +38 -0
  114. package/src/server/middleware/auth.ts +11 -1
  115. package/src/server/middleware/middleware.ts +9 -0
  116. package/src/server/middleware/no-transform.ts +19 -0
  117. package/src/server/middleware/rate-limit.ts +4 -6
  118. package/src/server/request-context/address.ts +45 -0
  119. package/src/server/request-context/geoip.ts +380 -0
  120. package/src/server/request-context/index.ts +33 -0
  121. package/src/server/request-context/user-agent.ts +95 -0
  122. package/src/services/app-approval.ts +382 -59
  123. package/src/services/approvals/device.ts +7 -5
  124. package/src/services/approvals/index.ts +6 -5
  125. package/src/services/approvals/list.ts +26 -5
  126. package/src/services/auth-flows/notification-sender.ts +3 -0
  127. package/src/services/geoip/index.ts +164 -0
  128. package/src/services/geoip/store.ts +30 -0
  129. package/src/services/geoip/upload.ts +150 -0
  130. package/src/services/identity/routes.ts +5 -1
  131. package/src/services/index.ts +11 -0
  132. package/src/services/known-browsers.ts +312 -0
  133. package/src/services/login-address-store.ts +51 -0
  134. package/src/services/login-context.ts +118 -0
  135. package/src/services/mandates/index.ts +3 -0
  136. package/src/services/mandates/policy.ts +15 -3
  137. package/src/services/notifications/email-frame.ts +1 -1
  138. package/src/services/oauth-tokens.ts +16 -2
  139. package/src/services/panel-context-consent.ts +58 -0
  140. package/src/services/service-account-credentials.ts +21 -0
  141. package/src/services/session/index.ts +97 -16
  142. package/src/services/session/recent.ts +13 -0
  143. package/src/services/webauthn.ts +12 -0
  144. package/src/shared/email-html.ts +2 -0
  145. package/src/shared/help.ts +3 -0
  146. package/src/shared-text/cursor.ts +22 -0
  147. package/src/shared-text/documents.ts +142 -0
  148. package/src/shared-text/events.ts +43 -0
  149. package/src/shared-text/index.ts +12 -0
  150. package/src/shared-text/log.ts +48 -0
  151. package/src/shared-text/text-diff.ts +23 -0
  152. package/src/shared-text/types.ts +41 -0
  153. package/src/ssr/AdminLayout.tsx +2 -0
  154. package/src/ssr/AppLaunchpadPanel.tsx +2 -2
  155. package/src/ssr/CloudShell.island.tsx +10 -2
  156. package/src/ssr/Layout.tsx +25 -2
  157. package/src/ssr/LayoutHelp.browser-harness.tsx +2 -0
  158. package/src/ssr/LayoutHelp.tsx +435 -274
  159. package/src/ssr/LayoutHelpPanel.browser-harness.tsx +136 -0
  160. package/src/ssr/LayoutHelpTrigger.tsx +27 -2
  161. package/src/ssr/MobileNavigation.tsx +2 -2
  162. package/src/ssr/PanelLayout.tsx +9 -1
  163. package/src/ssr/PanelRuntime.island.tsx +7 -0
  164. package/src/ssr/PanelSlot.tsx +53 -3
  165. package/src/ssr/ShellRuntime.island.tsx +11 -2
  166. package/src/ssr/app-color.ts +14 -9
  167. package/src/ssr/app-navigation.ts +16 -3
  168. package/src/ssr/compact-shell.browser-harness.ts +30 -22
  169. package/src/ssr/dialog-app-colors.browser-harness.tsx +43 -0
  170. package/src/ssr/help-messages.ts +4 -0
  171. package/src/ssr/help.ts +20 -0
  172. package/src/ssr/hints.ts +25 -0
  173. package/src/ssr/index.ts +2 -0
  174. package/src/ssr/islands/index.ts +4 -3
  175. package/src/ssr/layout-context.ts +2 -0
  176. package/src/ssr/layout-help-article.server.ts +50 -0
  177. package/src/ssr/layout-help-start.ts +33 -7
  178. package/src/ssr/panel-consent.ts +29 -0
  179. package/src/ssr/panel-context.ts +10 -0
  180. package/src/ssr/panel-slot.browser-harness.tsx +53 -4
  181. package/src/ssr/platform-messages.ts +18 -0
  182. package/src/ssr/shell-data.tsx +2 -1
  183. package/src/styles/effects.css +198 -9
  184. package/src/styles/file-chooser.css +2 -1
  185. package/src/styles/global.css +49 -12
  186. package/src/styles/panel.css +47 -4
  187. package/src/styles/resource-search.css +6 -2
  188. package/src/styles/tokens.css +17 -11
  189. package/src/styles/utilities-buttons.css +3 -2
  190. package/src/styles/utilities-data.css +2 -1
  191. package/src/styles/utilities-feedback.css +2 -2
  192. package/src/styles/utilities-layout.css +3 -13
  193. package/src/styles/utilities-markdown-editor.css +6 -6
  194. package/src/styles/utilities-navigation.css +17 -22
@@ -0,0 +1,101 @@
1
+ import type {
2
+ CapabilityActionCallReview,
3
+ CapabilityActionPolicy,
4
+ CapabilityActionReceipt,
5
+ CapabilityActionReview,
6
+ CapabilityActionReviewData,
7
+ CapabilitySemanticLink,
8
+ } from "../contracts/capabilities";
9
+
10
+ /**
11
+ * What the chat stores about one Action call so that a reload shows the same question and receipt even after the app
12
+ * changed: the app's per-call review as it was when Cloud asked, or, for an app that still returns the old review shape,
13
+ * that review as literal text. Pure and browser-safe: the server stores it, the chat renders it.
14
+ */
15
+
16
+ /** One old review detail: a label and its value, kept complete. */
17
+ export type AiLegacyReviewRow = { label: string; value: string; format?: "date" | "date-time" };
18
+
19
+ /**
20
+ * An old review (`message`, `details`, `links`) kept as literal text, never as a template: the message is the question,
21
+ * every inline or plain-text detail is a row without the 12-row bound, Markdown details are one Markdown block, and
22
+ * `summary` is the old success summary used as the receipt text.
23
+ */
24
+ export type AiLegacyActionReview = {
25
+ legacy: {
26
+ question: string;
27
+ rows: AiLegacyReviewRow[];
28
+ markdown?: string;
29
+ summary?: string;
30
+ links?: CapabilitySemanticLink[];
31
+ };
32
+ };
33
+
34
+ /** A new review, with `ask` filled from the declared policy when the call itself names no reason. */
35
+ export type AiStoredActionReview = CapabilityActionCallReview | AiLegacyActionReview;
36
+
37
+ export const isLegacyActionReview = (review: AiStoredActionReview): review is AiLegacyActionReview => "legacy" in review;
38
+
39
+ /** The old review shape as stored literal text. */
40
+ export const legacyActionReview = (review: CapabilityActionReview): AiLegacyActionReview => {
41
+ const details = review.details ?? [];
42
+ // One Markdown block, each detail under its own label, so a reader keeps knowing which text belongs where.
43
+ const markdown = details
44
+ .filter((detail) => detail.format === "markdown" && detail.value.trim())
45
+ .map((detail) => `**${detail.label.replace(/([\\*_`[\]])/g, "\\$1")}**\n\n${detail.value}`)
46
+ .join("\n\n");
47
+ return {
48
+ legacy: {
49
+ question: review.message,
50
+ rows: details
51
+ .filter((detail) => detail.format !== "markdown")
52
+ .map((detail) => ({
53
+ label: detail.label,
54
+ value: detail.value,
55
+ ...(detail.format === "date" || detail.format === "date-time" ? { format: detail.format } : {}),
56
+ })),
57
+ ...(markdown ? { markdown } : {}),
58
+ ...(review.links?.length ? { links: review.links } : {}),
59
+ },
60
+ };
61
+ };
62
+
63
+ /** The review Cloud stores and shows for one call. */
64
+ export const storedActionReview = (review: CapabilityActionReviewData, policy?: CapabilityActionPolicy | null): AiStoredActionReview => {
65
+ if (!("sentence" in review)) return legacyActionReview(review);
66
+ if (review.ask || !policy || !("ask" in policy)) return review;
67
+ return { ...review, ask: policy.ask };
68
+ };
69
+
70
+ /**
71
+ * The app's `check` Query after an outcome Cloud could not see. `checking` while it runs; `done` and `not_done` settle
72
+ * the call; `pending` means the app does not know yet; `failed` means the check itself gave no usable answer.
73
+ */
74
+ export type AiActionCheckState = "checking" | "done" | "not_done" | "pending" | "failed";
75
+ export type AiActionCheck = { state: AiActionCheckState; receipt?: CapabilityActionReceipt; at?: string };
76
+
77
+ /** A settled outcome survives an open response; otherwise prefer the later timestamp, or incoming without both. */
78
+ export const preferredActionCheck = (current: AiActionCheck | undefined, incoming: AiActionCheck): AiActionCheck => {
79
+ if (!current) return incoming;
80
+ const currentSettled = current.state === "done" || current.state === "not_done";
81
+ const incomingSettled = incoming.state === "done" || incoming.state === "not_done";
82
+ if (currentSettled !== incomingSettled) return currentSettled ? current : incoming;
83
+ return current.at && incoming.at && Date.parse(current.at) > Date.parse(incoming.at) ? current : incoming;
84
+ };
85
+
86
+ /** The framework's unknown-outcome codes, in persisted `CODE: message` or structured error results. */
87
+ export const isUnknownActionOutcome = (result: unknown): boolean => {
88
+ const code =
89
+ typeof result === "string"
90
+ ? /^([A-Z][A-Z0-9_]{2,}):/.exec(result.trim())?.[1]
91
+ : result !== null && typeof result === "object" && "code" in result && typeof result.code === "string"
92
+ ? result.code
93
+ : undefined;
94
+ return code === "ACTION_OUTCOME_UNKNOWN" || code === "IDEMPOTENCY_UNCERTAIN" || code === "IDEMPOTENCY_IN_PROGRESS";
95
+ };
96
+
97
+ /**
98
+ * A `checking` claim older than this belonged to a worker or request that stopped and is open again: twice a Query's
99
+ * 15-second budget, plus a margin. Core takes such a claim over, and the card offers to check again.
100
+ */
101
+ export const AI_TOOL_CHECK_STALE_MS = 2 * 15_000 + 30_000;
@@ -0,0 +1,72 @@
1
+ import type { CapabilityActionPolicy, CapabilityActionReviewData } from "../contracts/capabilities";
2
+ import { aiChatTasks } from "./chat-tasks";
3
+
4
+ export class AiBackgroundApprovalRequiredError extends Error {
5
+ readonly code = "NEEDS_APPROVAL";
6
+
7
+ constructor(readonly action: string) {
8
+ super(
9
+ `NEEDS_APPROVAL: ${action} needs the user's approval every time, and routines never ask. Stop and say in one sentence what was missing.`,
10
+ );
11
+ this.name = "AiBackgroundApprovalRequiredError";
12
+ }
13
+ }
14
+
15
+ type BackgroundActionInput = {
16
+ turnId: string;
17
+ mandate: { id: string; revision: number };
18
+ appId: string;
19
+ capabilityId: string;
20
+ kind: "query" | "action";
21
+ args: unknown;
22
+ policy?: CapabilityActionPolicy;
23
+ approval?: "none" | "rememberable" | "always";
24
+ hasReview: boolean;
25
+ action: string;
26
+ review: () => Promise<CapabilityActionReviewData | null>;
27
+ signal?: AbortSignal;
28
+ };
29
+
30
+ /** Core routine gate. A review can narrow a mandate, never grant authority. */
31
+ export const authorizeBackgroundActionWith = async (
32
+ input: BackgroundActionInput,
33
+ authorizeCapability: typeof aiChatTasks.authorizeCapability,
34
+ ): Promise<CapabilityActionReviewData | null> => {
35
+ // These declarations can never be granted to a routine. Stop with the approval state even if a stale grant exists.
36
+ if (input.kind === "action" && input.policy && (!("silent" in input.policy) || input.policy.routines === false)) {
37
+ await aiChatTasks.markNeedsApproval({ turnId: input.turnId, action: input.action });
38
+ throw new AiBackgroundApprovalRequiredError(input.action);
39
+ }
40
+ await authorizeCapability({
41
+ mandate: input.mandate,
42
+ appId: input.appId,
43
+ capabilityId: input.capabilityId,
44
+ kind: input.kind,
45
+ input: input.args,
46
+ approval: input.approval,
47
+ ...(input.policy ? { policy: input.policy } : {}),
48
+ });
49
+ if (input.kind === "query" || (!input.policy && input.approval === "none")) return null;
50
+ input.signal?.throwIfAborted();
51
+ if (!input.policy && input.approval !== "rememberable") {
52
+ await aiChatTasks.markNeedsApproval({ turnId: input.turnId, action: input.action });
53
+ throw new AiBackgroundApprovalRequiredError(input.action);
54
+ }
55
+ let review: CapabilityActionReviewData | null = null;
56
+ if (input.hasReview && !input.signal?.aborted) {
57
+ try {
58
+ review = await input.review();
59
+ } catch {
60
+ // An unavailable review cannot authorize an unattended effect.
61
+ input.signal?.throwIfAborted();
62
+ }
63
+ }
64
+ input.signal?.throwIfAborted();
65
+ if (review && (input.policy ? "sentence" in review && review.ask === undefined : !("sentence" in review) && review.approvalScope))
66
+ return review;
67
+ await aiChatTasks.markNeedsApproval({ turnId: input.turnId, action: input.action });
68
+ throw new AiBackgroundApprovalRequiredError(input.action);
69
+ };
70
+
71
+ export const authorizeBackgroundAction = (input: BackgroundActionInput): Promise<CapabilityActionReviewData | null> =>
72
+ authorizeBackgroundActionWith(input, aiChatTasks.authorizeCapability);
@@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
2
2
  import type { Tool, ToolContext, ToolResolver } from "@k2b/nessi";
3
3
  import { z } from "zod";
4
4
  import { resolveCapabilityActionWording, resolveCapabilityOperationTitle } from "../_internal/capabilities";
5
+ import { capabilityReviewView } from "../_internal/capability-reviews";
5
6
  import {
6
7
  CAPABILITY_APPROVAL_REASON_FIELD,
7
8
  CAPABILITY_APPROVAL_REASON_MAX_CHARS,
@@ -13,8 +14,9 @@ import {
13
14
  import { HELP_READ_MAX_CHARS, HELP_SEARCH_MAX_LIMIT, readHelpArticle } from "../_internal/help-catalog";
14
15
  import {
15
16
  type CapabilityActionManifest,
16
- type CapabilityActionReview,
17
+ type CapabilityActionReviewData,
17
18
  type CapabilityQueryManifest,
19
+ capabilityActionPolicy,
18
20
  cloudResourceRefAppId,
19
21
  LinkedCloudResourceReferenceSchema,
20
22
  resolveCapabilityResourceReader,
@@ -756,9 +758,14 @@ export const createLoadedAiCapabilityTools = (input: {
756
758
  actor: RequestActor;
757
759
  /** Locale of the turn; the approval text a person reads is worded in it. */
758
760
  locale?: string;
759
- review?: (entry: AiCapabilityCatalogEntry, args: unknown, context: ToolContext) => Promise<CapabilityActionReview | null>;
760
- authorizeBackground?: (entry: AiCapabilityCatalogEntry, args: unknown) => Promise<void>;
761
- onReview?: (callId: string, review: CapabilityActionReview) => void;
761
+ review?: (entry: AiCapabilityCatalogEntry, args: unknown, context: ToolContext) => Promise<CapabilityActionReviewData | null>;
762
+ authorizeBackground?: (entry: AiCapabilityCatalogEntry, args: unknown, context: ToolContext) => Promise<void>;
763
+ onReview?: (
764
+ callId: string,
765
+ review: CapabilityActionReviewData,
766
+ approval: CapabilityActionManifest["approval"],
767
+ entry: AiCapabilityCatalogEntry,
768
+ ) => void | Promise<void>;
762
769
  execute: (entry: AiCapabilityCatalogEntry, args: unknown, context: ToolContext) => Promise<unknown>;
763
770
  }): AiRuntimeTool[] => {
764
771
  const byName = new Map(input.catalog.map((entry) => [entry.name, entry]));
@@ -769,7 +776,7 @@ export const createLoadedAiCapabilityTools = (input: {
769
776
  defineAiTool({
770
777
  name: entry.providerName,
771
778
  canonicalName: entry.name,
772
- description: `${entry.title}. ${entry.description} Never retry ACTION_OUTCOME_UNKNOWN. Do not retry unchanged after INTERNAL or INVALID_APP_RESPONSE; report the provider error.`,
779
+ description: `${entry.title}. ${entry.description} Never retry ACTION_OUTCOME_UNKNOWN or NEEDS_APPROVAL. Never retry IDEMPOTENCY_UNCERTAIN or IDEMPOTENCY_IN_PROGRESS. After ACTION_NOT_DONE, ask the person before trying again. Do not retry unchanged after INTERNAL or INVALID_APP_RESPONSE; report the provider error.`,
773
780
  inputSchema: aiCapabilityInputSchema(
774
781
  offersApprovalReason(entry) ? withApprovalReason(entry.operation.inputSchema) : entry.operation.inputSchema,
775
782
  ),
@@ -779,16 +786,21 @@ export const createLoadedAiCapabilityTools = (input: {
779
786
  approval: "never",
780
787
  }).server(async (modelArgs, context) => {
781
788
  const args = withoutApprovalReason(entry, modelArgs);
782
- await input.authorizeBackground?.(entry, args);
789
+ await input.authorizeBackground?.(entry, args, context);
783
790
  if (!input.authorizeBackground && entry.kind === "action" && (entry.operation as CapabilityActionManifest).approval !== "none") {
784
791
  const review = (await input.review?.(entry, args, context)) ?? null;
785
- if (review && context.callId) input.onReview?.(context.callId, review);
792
+ if (review && context.callId)
793
+ await input.onReview?.(context.callId, review, "approval" in entry.operation ? entry.operation.approval : undefined, entry);
786
794
  // Text-only readers such as the CLI read what the card shows: the app's sentence, its review, and the model's reason.
787
795
  const locale = input.locale ?? DEFAULT_LOCALE;
788
796
  const message = capabilityApprovalText({
789
797
  appName: entry.display.appName,
790
798
  subject: capabilityActionSubject(aiCapabilityActionWording(entry, locale), args, { locale, timeZone: context.timeZone }),
791
- review: review?.message,
799
+ review: review
800
+ ? capabilityReviewView(review, {
801
+ timeZone: context.timeZone ?? "UTC",
802
+ }).message
803
+ : undefined,
792
804
  reason: offersApprovalReason(entry) ? capabilityApprovalReason(modelArgs) : null,
793
805
  locale,
794
806
  });
@@ -873,9 +885,14 @@ export const createAiToolResolver =
873
885
  locale?: string;
874
886
  maxLoadedTools?: number;
875
887
  execute?: (entry: AiCapabilityCatalogEntry, args: unknown, context: ToolContext) => Promise<unknown>;
876
- review?: (entry: AiCapabilityCatalogEntry, args: unknown, context: ToolContext) => Promise<CapabilityActionReview | null>;
877
- authorizeBackground?: (entry: AiCapabilityCatalogEntry, args: unknown) => Promise<void>;
878
- onReview?: (callId: string, review: CapabilityActionReview) => void;
888
+ review?: (entry: AiCapabilityCatalogEntry, args: unknown, context: ToolContext) => Promise<CapabilityActionReviewData | null>;
889
+ authorizeBackground?: (entry: AiCapabilityCatalogEntry, args: unknown, context: ToolContext) => Promise<void>;
890
+ onReview?: (
891
+ callId: string,
892
+ review: CapabilityActionReviewData,
893
+ approval: CapabilityActionManifest["approval"],
894
+ entry: AiCapabilityCatalogEntry,
895
+ ) => void | Promise<void>;
879
896
  onPrepared?: (snapshot: {
880
897
  prepared: PreparedAiTools;
881
898
  presentations: Map<string, AiToolPresentation>;
@@ -907,6 +924,7 @@ export const createAiToolResolver =
907
924
  capabilityId: entry.operation.localId,
908
925
  kind: entry.kind,
909
926
  approval: entry.kind === "action" && "approval" in entry.operation ? entry.operation.approval : undefined,
927
+ capabilityPolicy: capabilityActionPolicy(entry.app.manifest, entry.operation.localId),
910
928
  }));
911
929
  const mandateExcluded = new Set(
912
930
  fullCapabilityCatalog.filter((entry) => !mandateAllows(entry)).flatMap((entry) => [entry.name, entry.providerName]),
@@ -1,8 +1,15 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import type { ToolContext } from "@k2b/nessi";
3
+ import { capabilityActionReceiptIssues, capabilityQueryChecksActionOutcome } from "../_internal/capability-reviews";
3
4
  import { type CapabilityDispatchDependencies, dispatchCapability } from "../api/capabilities";
4
5
  import { capabilityValueMeta, recordCapabilityExecution } from "../capabilities/executions";
5
- import { type CapabilityActionManifest, type CapabilityActionReview, CapabilityActionReviewSchema } from "../contracts/capabilities";
6
+ import {
7
+ type CapabilityActionCallReview,
8
+ CapabilityActionCheckSchema,
9
+ type CapabilityActionManifest,
10
+ type CapabilityActionReviewData,
11
+ CapabilityActionReviewDataSchema,
12
+ } from "../contracts/capabilities";
6
13
  import type { RequestActor } from "../server";
7
14
  import type { AccessSubject } from "../server/services/access";
8
15
  import { isAccountCategoryAllowed } from "../services/account-category-policy";
@@ -10,10 +17,14 @@ import { isAccountExpired } from "../services/account-model";
10
17
  import { accounts } from "../services/accounts";
11
18
  import { withoutAdminRole } from "../services/session/user";
12
19
  import { LOCALE_HEADER } from "../shared/locale";
20
+ import type { AiActionCheck } from "./action-review";
13
21
  import type { AiCapabilityCatalogEntry } from "./capabilities";
22
+ import { AiCapabilityExecutionError } from "./capability-result";
14
23
  import { aiToolAudit } from "./tool-audit";
15
24
  import type { AiConversationService } from "./types";
16
25
 
26
+ export { AiCapabilityExecutionError, aiCapabilityCheckResult } from "./capability-result";
27
+
17
28
  type CapabilityActor = Extract<RequestActor, { kind: "user" }>;
18
29
 
19
30
  export const resolveAiCapabilityActor = async (input: {
@@ -38,17 +49,6 @@ export const resolveAiCapabilityActor = async (input: {
38
49
  return { actor: { kind: "user", user }, accessSubject: { type: "user", userId: user.id } };
39
50
  };
40
51
 
41
- export class AiCapabilityExecutionError extends Error {
42
- constructor(
43
- readonly code: string,
44
- readonly status: number,
45
- message: string,
46
- ) {
47
- super(`${code}: ${message}`);
48
- this.name = "AiCapabilityExecutionError";
49
- }
50
- }
51
-
52
52
  const idempotencyKey = (conversationId: string, callId: string): string =>
53
53
  `ai-${createHash("sha256").update(`${conversationId}\0${callId}`).digest("hex")}`;
54
54
 
@@ -71,13 +71,13 @@ const validationIssueText = (details: unknown): string => {
71
71
  return issues.length > 0 ? ` Input issues: ${issues.join("; ")}` : "";
72
72
  };
73
73
 
74
- const parseCapabilityResponse = async (response: Response): Promise<unknown> => {
74
+ const parseCapabilityResponse = async (response: Response, dispatched: boolean): Promise<unknown> => {
75
75
  const body = (await response.json().catch(() => null)) as { code?: unknown; message?: unknown; details?: unknown } | null;
76
76
  if (response.ok) return body;
77
77
  const code = typeof body?.code === "string" ? body.code : "CAPABILITY_FAILED";
78
78
  const message = typeof body?.message === "string" ? body.message : "Capability execution failed";
79
79
  const issueText = code === "VALIDATION_FAILED" ? validationIssueText(body?.details) : "";
80
- throw new AiCapabilityExecutionError(code, response.status, `${message}${issueText}`);
80
+ throw new AiCapabilityExecutionError(code, response.status, `${message}${issueText}`, dispatched);
81
81
  };
82
82
 
83
83
  type AiCapabilityCall = {
@@ -96,7 +96,10 @@ type AiCapabilityCall = {
96
96
  };
97
97
  };
98
98
 
99
- const dispatchAiCapability = async (input: AiCapabilityCall, review: boolean): Promise<unknown> => {
99
+ const dispatchAiCapability = async (
100
+ input: Omit<AiCapabilityCall, "context"> & { context: Pick<ToolContext, "callId" | "signal"> },
101
+ review: boolean,
102
+ ): Promise<unknown> => {
100
103
  if (input.authority.accessSubject.type !== "user" || input.authority.accessSubject.userId !== input.authority.actor.user.id) {
101
104
  throw new Error("Cloud capability authority is inconsistent.");
102
105
  }
@@ -128,41 +131,71 @@ const dispatchAiCapability = async (input: AiCapabilityCall, review: boolean): P
128
131
  headers,
129
132
  signal: input.context.signal,
130
133
  });
131
- return await parseCapabilityResponse(
132
- await dispatch({
133
- request,
134
- kind: input.entry.kind === "query" ? "queries" : "actions",
135
- origin: "assistant",
136
- review,
137
- appId: input.entry.appId,
138
- capabilityId: input.entry.operation.localId,
139
- input: input.args,
140
- authority: {
141
- actor: input.authority.actor,
142
- accessSubject: input.authority.accessSubject,
143
- credentialKind: "session",
144
- scopes: [],
145
- },
146
- mandate: input.mandate
147
- ? {
148
- mandateId: input.mandate.id,
149
- mandateRevision: input.mandate.revision,
150
- ownerAppId: "core",
151
- ...(!review && input.actionApproval ? { actionApproval: input.actionApproval } : {}),
152
- }
153
- : undefined,
154
- dependencies: input.dependencies,
155
- }),
156
- );
134
+ let dispatched = false;
135
+ const response = await dispatch({
136
+ request,
137
+ kind: input.entry.kind === "query" ? "queries" : "actions",
138
+ origin: "assistant",
139
+ review,
140
+ appId: input.entry.appId,
141
+ capabilityId: input.entry.operation.localId,
142
+ input: input.args,
143
+ authority: {
144
+ actor: input.authority.actor,
145
+ accessSubject: input.authority.accessSubject,
146
+ credentialKind: "session",
147
+ scopes: [],
148
+ },
149
+ mandate: input.mandate
150
+ ? {
151
+ mandateId: input.mandate.id,
152
+ mandateRevision: input.mandate.revision,
153
+ ownerAppId: "core",
154
+ ...(!review && input.actionApproval ? { actionApproval: input.actionApproval } : {}),
155
+ }
156
+ : undefined,
157
+ dependencies: input.dependencies,
158
+ onUpstreamAttempt: () => {
159
+ dispatched = true;
160
+ },
161
+ });
162
+ return await parseCapabilityResponse(response, dispatched);
157
163
  };
158
164
 
159
- export const reviewAiCapability = async (input: AiCapabilityCall): Promise<CapabilityActionReview | null> => {
165
+ export const reviewAiCapability = async (input: AiCapabilityCall): Promise<CapabilityActionReviewData | null> => {
160
166
  if (input.entry.kind !== "action" || !(input.entry.operation as CapabilityActionManifest).review) return null;
161
- return CapabilityActionReviewSchema.parse(await dispatchAiCapability(input, true));
167
+ return CapabilityActionReviewDataSchema.parse(await dispatchAiCapability(input, true));
162
168
  };
163
169
 
164
170
  export const executeAiCapability = (input: AiCapabilityCall): Promise<unknown> => dispatchAiCapability(input, false);
165
171
 
172
+ /** Run only the app-owned read-only outcome Query, once. Invalid answers stay unknown and remain recoverable. */
173
+ export const checkAiCapability = async (
174
+ input: Omit<AiCapabilityCall, "args" | "actionApproval" | "context"> & {
175
+ context: Pick<ToolContext, "callId" | "signal">;
176
+ check: NonNullable<CapabilityActionCallReview["check"]>;
177
+ /** The app's catalog, in which the check Query is looked up; the caller builds it (no import cycle). */
178
+ catalog: readonly AiCapabilityCatalogEntry[];
179
+ },
180
+ ): Promise<AiActionCheck> => {
181
+ try {
182
+ const query = input.catalog.find(
183
+ (entry) => entry.appId === input.entry.appId && entry.kind === "query" && entry.operation.localId === input.check.query,
184
+ );
185
+ if (!query || "idempotency" in query.operation || !capabilityQueryChecksActionOutcome(query.operation))
186
+ throw new Error("Action outcome Query is unavailable.");
187
+ // The Action's audit keeps its own dispatch identity; the Query has its separate platform execution record.
188
+ const result = await dispatchAiCapability({ ...input, turnId: undefined, entry: query, args: input.check.input }, false);
189
+ if (!result || typeof result !== "object" || !("data" in result)) throw new Error("Invalid outcome check envelope.");
190
+ const checked = CapabilityActionCheckSchema.parse(result.data);
191
+ if (checked.receipt && capabilityActionReceiptIssues(checked.receipt, "links" in result ? result.links : undefined).length)
192
+ throw new Error("Invalid outcome check receipt.");
193
+ return { state: checked.outcome, ...(checked.receipt ? { receipt: checked.receipt } : {}), at: new Date().toISOString() };
194
+ } catch {
195
+ return { state: "failed", at: new Date().toISOString() };
196
+ }
197
+ };
198
+
166
199
  /**
167
200
  * A rejected approval never reaches the dispatcher, so the assistant records
168
201
  * the same execution row the dispatcher would have written for the attempt.
@@ -0,0 +1,95 @@
1
+ import type { StoreEntry } from "@k2b/nessi";
2
+ import type { AiActionCheck } from "./action-review";
3
+
4
+ export class AiCapabilityExecutionError extends Error {
5
+ constructor(
6
+ readonly code: string,
7
+ readonly status: number,
8
+ message: string,
9
+ readonly dispatched: boolean = false,
10
+ ) {
11
+ super(`${code}: ${message}`);
12
+ this.name = "AiCapabilityExecutionError";
13
+ }
14
+ }
15
+
16
+ /** A check settles only what the app knows; it never authorizes another Action attempt. */
17
+ export const aiCapabilityCheckResult = (check: AiActionCheck): unknown => {
18
+ if (check.state === "done")
19
+ return {
20
+ data: null,
21
+ ...(check.receipt ? { receipt: check.receipt } : {}),
22
+ outcomeCheck: "done",
23
+ message: "The app's check confirmed the Action happened. Do not run it again.",
24
+ };
25
+ if (check.state === "not_done")
26
+ throw new AiCapabilityExecutionError(
27
+ "ACTION_NOT_DONE",
28
+ 409,
29
+ "The app's check confirmed nothing happened. Ask the person before trying again.",
30
+ );
31
+ throw new AiCapabilityExecutionError(
32
+ "ACTION_OUTCOME_UNKNOWN",
33
+ 409,
34
+ check.state === "pending"
35
+ ? "The app does not know the outcome yet. Do not retry the Action."
36
+ : "The outcome check failed. Do not retry the Action.",
37
+ );
38
+ };
39
+
40
+ /** Project durable, settled checks into model history without changing the stored messages or retrying an Action. */
41
+ export const projectSettledAiToolChecks = (
42
+ rows: readonly (StoreEntry & { meta?: { toolChecks?: Record<string, AiActionCheck> } | null })[],
43
+ ): StoreEntry[] => {
44
+ const entries = rows.map(({ seq, kind, message }) => ({ seq, kind, message }));
45
+ const missing = new Map<number, StoreEntry[]>();
46
+ for (const [index, row] of rows.entries()) {
47
+ if (row.message.role !== "assistant" || !row.meta?.toolChecks) continue;
48
+ for (const call of row.message.content) {
49
+ if (call.type !== "tool_call") continue;
50
+ const check = row.meta.toolChecks[call.id];
51
+ if (!check || (check.state !== "done" && check.state !== "not_done")) continue;
52
+ let resultIndex: number | undefined;
53
+ // Call IDs can be reused in another exchange; a check belongs only to this call's result.
54
+ for (let next = index + 1; next < rows.length; next++) {
55
+ const message = rows[next]!.message;
56
+ if (message.role === "user") break;
57
+ if (message.role === "assistant" && message.content.some((part) => part.type === "tool_call" && part.id === call.id)) break;
58
+ if (message.role === "tool_result" && message.callId === call.id) {
59
+ resultIndex = next;
60
+ break;
61
+ }
62
+ }
63
+ const existing = resultIndex !== undefined ? rows[resultIndex]!.message : undefined;
64
+ // A result that already agrees keeps its original data and receipt.
65
+ if (
66
+ existing?.role === "tool_result" &&
67
+ ((check.state === "done" && !existing.isError) ||
68
+ (check.state === "not_done" &&
69
+ existing.isError &&
70
+ typeof existing.result === "string" &&
71
+ existing.result.startsWith("ACTION_NOT_DONE:")))
72
+ )
73
+ continue;
74
+ let result: unknown;
75
+ let isError = false;
76
+ try {
77
+ result = aiCapabilityCheckResult(check);
78
+ } catch (error) {
79
+ if (!(error instanceof AiCapabilityExecutionError)) throw error;
80
+ result = error.message;
81
+ isError = true;
82
+ }
83
+ const message = { role: "tool_result" as const, callId: call.id, name: call.name, result, isError };
84
+ if (resultIndex !== undefined) {
85
+ entries[resultIndex] = { ...entries[resultIndex]!, message };
86
+ } else {
87
+ // A cut-off call has no result row. Share its sequence so compaction still checkpoints durable rows.
88
+ const answers = missing.get(index) ?? [];
89
+ answers.push({ seq: row.seq, kind: "message", message });
90
+ missing.set(index, answers);
91
+ }
92
+ }
93
+ }
94
+ return entries.flatMap((entry, index) => [entry, ...(missing.get(index) ?? [])]);
95
+ };