@vellumai/assistant 0.12.6-staging.3 → 0.12.7-staging.1

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 (97) hide show
  1. package/.env.example +2 -0
  2. package/ARCHITECTURE.md +14 -14
  3. package/docs/architecture/integrations.md +5 -5
  4. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/__tests__/channels.test.ts +2 -0
  5. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/channels.ts +8 -4
  6. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/__tests__/channels.test.ts +2 -0
  7. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/channels.ts +8 -4
  8. package/node_modules/@vellumai/gateway-client/src/__tests__/route-trust-class.test.ts +111 -0
  9. package/node_modules/@vellumai/gateway-client/src/admission-policy-contract.ts +5 -2
  10. package/node_modules/@vellumai/gateway-client/src/index.ts +9 -0
  11. package/node_modules/@vellumai/gateway-client/src/route-trust-class.ts +94 -0
  12. package/node_modules/@vellumai/service-contracts/src/__tests__/channels.test.ts +2 -0
  13. package/node_modules/@vellumai/service-contracts/src/channels.ts +8 -4
  14. package/openapi.yaml +153 -5
  15. package/package.json +1 -1
  16. package/src/__tests__/channel-policy.test.ts +7 -0
  17. package/src/__tests__/conversation-routes-disk-view.test.ts +141 -2
  18. package/src/__tests__/identity-routes.test.ts +1 -0
  19. package/src/__tests__/list-messages-client-message-id.test.ts +43 -0
  20. package/src/__tests__/openai-provider.test.ts +27 -0
  21. package/src/__tests__/skill-execute-input.test.ts +11 -0
  22. package/src/__tests__/tool-approval-handler.test.ts +4 -0
  23. package/src/__tests__/tool-preview-lifecycle.test.ts +26 -0
  24. package/src/agent/loop-unparseable-tool-retry.test.ts +333 -0
  25. package/src/agent/loop.ts +133 -12
  26. package/src/calls/__tests__/call-controller.test.ts +273 -1
  27. package/src/calls/__tests__/media-stream-server-integration.test.ts +40 -0
  28. package/src/calls/__tests__/media-stream-stt-session.test.ts +33 -0
  29. package/src/calls/call-constants.ts +10 -0
  30. package/src/calls/call-controller.ts +359 -25
  31. package/src/calls/media-stream-server.ts +18 -0
  32. package/src/calls/media-stream-stt-session.ts +30 -9
  33. package/src/channels/config.ts +8 -0
  34. package/src/cli/lib/bundled-marketplace.json +39 -0
  35. package/src/config/composer-preferences.test.ts +97 -0
  36. package/src/config/composer-preferences.ts +95 -0
  37. package/src/config/feature-flag-registry.json +16 -8
  38. package/src/config/schemas/llm.ts +1 -0
  39. package/src/contacts/types.ts +1 -0
  40. package/src/daemon/conversation-agent-loop-handlers.ts +18 -2
  41. package/src/daemon/conversation-agent-loop.ts +3 -0
  42. package/src/daemon/conversation-messaging.ts +4 -0
  43. package/src/daemon/conversation-process.ts +15 -0
  44. package/src/daemon/conversation-queue-manager.ts +2 -0
  45. package/src/daemon/conversation.ts +2 -0
  46. package/src/daemon/handlers/shared.ts +13 -2
  47. package/src/daemon/message-types/sync.ts +1 -0
  48. package/src/daemon/process-message.ts +5 -0
  49. package/src/i18n/__tests__/i18n.test.ts +25 -0
  50. package/src/i18n/assistant-text.ts +18 -0
  51. package/src/i18n/index.ts +1 -0
  52. package/src/i18n/messages.ts +11 -0
  53. package/src/ipc/routes/__tests__/route-adapter.test.ts +40 -0
  54. package/src/ipc/routes/route-adapter.ts +9 -0
  55. package/src/notifications/README.md +1 -1
  56. package/src/notifications/signal.ts +4 -0
  57. package/src/persistence/conversation-key-store.ts +12 -11
  58. package/src/providers/__tests__/unparseable-tool-args.test.ts +25 -0
  59. package/src/providers/inference/adapter-factory.ts +6 -0
  60. package/src/providers/ionet/client.test.ts +143 -0
  61. package/src/providers/ionet/client.ts +37 -0
  62. package/src/providers/model-catalog.ts +40 -0
  63. package/src/providers/openai/chat-completions-provider.ts +33 -2
  64. package/src/providers/retry.ts +10 -4
  65. package/src/providers/unparseable-tool-args.ts +29 -5
  66. package/src/runtime/__tests__/http-router-trust.test.ts +360 -0
  67. package/src/runtime/__tests__/shared-principal-lookup.test.ts +346 -0
  68. package/src/runtime/auth/__tests__/guard-tests.test.ts +51 -1
  69. package/src/runtime/auth/__tests__/route-policy.test.ts +85 -1
  70. package/src/runtime/auth/__tests__/scopes.test.ts +36 -0
  71. package/src/runtime/auth/route-policy.ts +44 -0
  72. package/src/runtime/auth/scopes.ts +15 -0
  73. package/src/runtime/auth/types.ts +1 -0
  74. package/src/runtime/channel-reply-delivery.ts +2 -0
  75. package/src/runtime/http-router.ts +61 -1
  76. package/src/runtime/http-types.ts +3 -0
  77. package/src/runtime/migrations/__tests__/vbundle-streaming-validator.test.ts +35 -10
  78. package/src/runtime/migrations/__tests__/vbundle-validator-v1-schema.test.ts +48 -0
  79. package/src/runtime/migrations/vbundle-builder.ts +19 -3
  80. package/src/runtime/migrations/vbundle-streaming-importer.ts +14 -19
  81. package/src/runtime/migrations/vbundle-streaming-validator.ts +14 -7
  82. package/src/runtime/migrations/vbundle-validator.ts +54 -1
  83. package/src/runtime/routes/composer-settings-routes.test.ts +71 -0
  84. package/src/runtime/routes/composer-settings-routes.ts +76 -0
  85. package/src/runtime/routes/conversation-routes.ts +54 -14
  86. package/src/runtime/routes/identity-routes.ts +2 -0
  87. package/src/runtime/routes/inbound-stages/background-dispatch.ts +4 -0
  88. package/src/runtime/routes/index.ts +2 -0
  89. package/src/runtime/routes/migration-routes.ts +3 -1
  90. package/src/runtime/routes/types.ts +4 -1
  91. package/src/runtime/shared-principal-lookup.ts +235 -0
  92. package/src/tools/host-terminal/host-shell.ts +4 -2
  93. package/src/tools/shared/shell-output.test.ts +42 -0
  94. package/src/tools/shared/shell-output.ts +27 -4
  95. package/src/tools/skills/execute.ts +10 -7
  96. package/src/tools/terminal/shell.ts +2 -0
  97. package/src/tools/tool-approval-handler.ts +9 -1
@@ -99,6 +99,14 @@ const CHANNEL_POLICIES = {
99
99
  conversationStrategy: "continue_existing_conversation",
100
100
  },
101
101
  },
102
+ "vellum-shared": {
103
+ notification: {
104
+ // A shared conversation has no transport: the contact's own client
105
+ // reads replies over its connection, so there is nowhere to push to.
106
+ deliveryEnabled: false,
107
+ conversationStrategy: "not_deliverable",
108
+ },
109
+ },
102
110
  } as const satisfies Record<ChannelId, ChannelNotificationPolicy>;
103
111
 
104
112
  export type ChannelPolicies = typeof CHANNEL_POLICIES;
@@ -148,6 +148,19 @@
148
148
  "ref": "2d293cdcb68af03d446b61a9915a0ee3dc842d4e"
149
149
  }
150
150
  },
151
+ {
152
+ "name": "contractor-photo-to-quote",
153
+ "description": "Turns job-site and equipment photos into standardized trade estimates and quotes in Jobber or QuickBooks with multi-tier Good/Better/Best options in 5 minutes.",
154
+ "category": "commerce",
155
+ "icon": "📸",
156
+ "license": "MIT",
157
+ "homepage": "https://github.com/ZeebBoyBlue/contractor-photo-to-quote",
158
+ "source": {
159
+ "source": "github",
160
+ "repo": "ZeebBoyBlue/contractor-photo-to-quote",
161
+ "ref": "6c136186faaf03bcf125d202f0b1f51f97d94544"
162
+ }
163
+ },
151
164
  {
152
165
  "name": "contractor-voice-invoicer",
153
166
  "description": "Converts 20-second truck voice memos and job photos into itemized client invoices with custom labor rates, material markups, and one-click accounting staging.",
@@ -474,6 +487,19 @@
474
487
  "license": "MIT",
475
488
  "icon": "\ud83d\udcac"
476
489
  },
490
+ {
491
+ "name": "quickbooks-expense-categorizer",
492
+ "description": "Scans uncategorized QuickBooks Online expenses and bank feeds, matches receipts, and stages categorized entries with confidence scoring for 1-click batch review.",
493
+ "category": "commerce",
494
+ "icon": "🧾",
495
+ "license": "MIT",
496
+ "homepage": "https://github.com/ZeebBoyBlue/quickbooks-expense-categorizer",
497
+ "source": {
498
+ "source": "github",
499
+ "repo": "ZeebBoyBlue/quickbooks-expense-categorizer",
500
+ "ref": "120776619698cc3e9490caf5c756465f5caaa6c2"
501
+ }
502
+ },
477
503
  {
478
504
  "name": "reading-pal",
479
505
  "source": {
@@ -500,6 +526,19 @@
500
526
  "ref": "f1905e639941e98172c5032b6d722f668882d75d"
501
527
  }
502
528
  },
529
+ {
530
+ "name": "shopify-payout-reconciler",
531
+ "description": "Unbundles lump-sum Shopify merchant bank deposits into gross sales, processor fees, customer refunds, shipping labels, and sales tax for penny-accurate QuickBooks reconciliation.",
532
+ "category": "commerce",
533
+ "icon": "🛍️",
534
+ "license": "MIT",
535
+ "homepage": "https://github.com/ZeebBoyBlue/shopify-payout-reconciler",
536
+ "source": {
537
+ "source": "github",
538
+ "repo": "ZeebBoyBlue/shopify-payout-reconciler",
539
+ "ref": "079fe0d86629686243a588cf3c4867d38d6ab8b8"
540
+ }
541
+ },
503
542
  {
504
543
  "name": "simple-memory",
505
544
  "source": {
@@ -0,0 +1,97 @@
1
+ import {
2
+ mkdtempSync,
3
+ readdirSync,
4
+ readFileSync,
5
+ rmSync,
6
+ writeFileSync,
7
+ } from "node:fs";
8
+ import { tmpdir } from "node:os";
9
+ import { join } from "node:path";
10
+ import { afterEach, describe, expect, test } from "bun:test";
11
+
12
+ import {
13
+ ComposerPreferencesPatchSchema,
14
+ readComposerPreferences,
15
+ updateComposerPreferences,
16
+ } from "./composer-preferences.js";
17
+
18
+ const directories: string[] = [];
19
+ function directory() {
20
+ const value = mkdtempSync(join(tmpdir(), "composer-preferences-"));
21
+ directories.push(value);
22
+ return value;
23
+ }
24
+ afterEach(() => {
25
+ for (const path of directories.splice(0)) {
26
+ rmSync(path, { recursive: true, force: true });
27
+ }
28
+ });
29
+ describe("composer preferences", () => {
30
+ test("new users retain the assistant defaults", () => {
31
+ expect(readComposerPreferences("user-1", directory())).toEqual({
32
+ favoriteModeIds: [],
33
+ lastModeId: null,
34
+ lastAutonomy: null,
35
+ });
36
+ });
37
+ test("partial updates preserve unrelated choices and isolate users", () => {
38
+ const root = directory();
39
+ updateComposerPreferences(
40
+ "user-1",
41
+ { lastModeId: "quality-optimized", lastAutonomy: "low" },
42
+ root,
43
+ );
44
+ const patch = ComposerPreferencesPatchSchema.safeParse({
45
+ favoriteModeIds: ["balanced"],
46
+ });
47
+ expect(patch.success).toBe(true);
48
+ if (!patch.success) {
49
+ throw patch.error;
50
+ }
51
+ const result = updateComposerPreferences("user-1", patch.data, root);
52
+ expect(result.lastModeId).toBe("quality-optimized");
53
+ expect(result.lastAutonomy).toBe("low");
54
+ expect(result.favoriteModeIds).toEqual(["balanced"]);
55
+ expect(readComposerPreferences("user-2", root).lastModeId).toBeNull();
56
+ });
57
+ test("rejects invalid thresholds, excess favorites, and foreign fields", () => {
58
+ for (const value of [
59
+ { lastAutonomy: "unrestricted" },
60
+ { favoriteModeIds: ["1", "2", "3", "4", "5", "6"] },
61
+ { principalId: "user-2" },
62
+ ]) {
63
+ expect(ComposerPreferencesPatchSchema.safeParse(value).success).toBe(
64
+ false,
65
+ );
66
+ }
67
+ });
68
+ test("deduplicates favorites and leaves no temporary files", () => {
69
+ const root = directory();
70
+ expect(
71
+ updateComposerPreferences(
72
+ "../../user-1",
73
+ { favoriteModeIds: ["a", "a", "b"] },
74
+ root,
75
+ ).favoriteModeIds,
76
+ ).toEqual(["a", "b"]);
77
+ expect(readdirSync(join(root, "composer-preferences"))).toHaveLength(1);
78
+ });
79
+ test("does not overwrite corrupt preferences on a failed update", () => {
80
+ const root = directory();
81
+ updateComposerPreferences("user-1", {}, root);
82
+ const path = join(
83
+ root,
84
+ "composer-preferences",
85
+ readdirSync(join(root, "composer-preferences"))[0]!,
86
+ );
87
+ writeFileSync(path, '{"lastAutonomy":"invalid"}');
88
+ expect(() =>
89
+ updateComposerPreferences(
90
+ "user-1",
91
+ { favoriteModeIds: ["balanced"] },
92
+ root,
93
+ ),
94
+ ).toThrow();
95
+ expect(readFileSync(path, "utf8")).toBe('{"lastAutonomy":"invalid"}');
96
+ });
97
+ });
@@ -0,0 +1,95 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import {
3
+ mkdirSync,
4
+ readFileSync,
5
+ renameSync,
6
+ rmSync,
7
+ writeFileSync,
8
+ } from "node:fs";
9
+ import { join } from "node:path";
10
+
11
+ import { z } from "zod";
12
+
13
+ import { getDataDir } from "../util/platform.js";
14
+
15
+ export const ComposerPreferencesSchema = z.object({
16
+ favoriteModeIds: z.array(z.string().min(1).max(200)).max(5).default([]),
17
+ lastModeId: z.string().min(1).max(200).nullable().default(null),
18
+ lastAutonomy: z
19
+ .enum(["none", "low", "medium", "high"])
20
+ .nullable()
21
+ .default(null),
22
+ });
23
+
24
+ export const ComposerPreferencesPatchSchema = z
25
+ .object({
26
+ favoriteModeIds:
27
+ ComposerPreferencesSchema.shape.favoriteModeIds.removeDefault(),
28
+ lastModeId: ComposerPreferencesSchema.shape.lastModeId.removeDefault(),
29
+ lastAutonomy: ComposerPreferencesSchema.shape.lastAutonomy.removeDefault(),
30
+ })
31
+ .partial()
32
+ .strict();
33
+ export type ComposerPreferences = z.infer<typeof ComposerPreferencesSchema>;
34
+ export type ComposerPreferencesPatch = z.infer<
35
+ typeof ComposerPreferencesPatchSchema
36
+ >;
37
+
38
+ function preferencesPath(principalId: string, dataDir: string): string {
39
+ const key = createHash("sha256").update(principalId).digest("hex");
40
+ return join(dataDir, "composer-preferences", `${key}.json`);
41
+ }
42
+
43
+ export function readComposerPreferences(
44
+ principalId: string,
45
+ dataDir = getDataDir(),
46
+ ): ComposerPreferences {
47
+ let content: string;
48
+ try {
49
+ content = readFileSync(preferencesPath(principalId, dataDir), "utf8");
50
+ } catch (error) {
51
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") {
52
+ return ComposerPreferencesSchema.parse({});
53
+ }
54
+ throw error;
55
+ }
56
+ const parsed = ComposerPreferencesSchema.safeParse(JSON.parse(content));
57
+ if (!parsed.success) {
58
+ throw new Error("Stored composer preferences are invalid", {
59
+ cause: parsed.error,
60
+ });
61
+ }
62
+ return parsed.data;
63
+ }
64
+
65
+ export function updateComposerPreferences(
66
+ principalId: string,
67
+ patch: ComposerPreferencesPatch,
68
+ dataDir = getDataDir(),
69
+ ): ComposerPreferences {
70
+ const parsed = ComposerPreferencesSchema.safeParse({
71
+ ...readComposerPreferences(principalId, dataDir),
72
+ ...patch,
73
+ });
74
+ if (!parsed.success) {
75
+ throw new Error("Invalid composer preferences", { cause: parsed.error });
76
+ }
77
+ const preferences = {
78
+ ...parsed.data,
79
+ favoriteModeIds: [...new Set(parsed.data.favoriteModeIds)],
80
+ };
81
+ const directory = join(dataDir, "composer-preferences");
82
+ mkdirSync(directory, { recursive: true });
83
+ const path = preferencesPath(principalId, dataDir);
84
+ const temporaryPath = `${path}.${randomUUID()}.tmp`;
85
+ try {
86
+ writeFileSync(temporaryPath, JSON.stringify(preferences), {
87
+ mode: 0o600,
88
+ flag: "wx",
89
+ });
90
+ renameSync(temporaryPath, path);
91
+ } finally {
92
+ rmSync(temporaryPath, { force: true });
93
+ }
94
+ return preferences;
95
+ }
@@ -146,6 +146,14 @@
146
146
  "description": "Auto-expand completed tool call step groups instead of showing them collapsed",
147
147
  "defaultEnabled": false
148
148
  },
149
+ {
150
+ "id": "quiet-turn-activity",
151
+ "scope": "client",
152
+ "key": "quiet-turn-activity",
153
+ "label": "Quiet Turn Activity",
154
+ "description": "While the assistant works, show a reply skeleton with one line naming where it is working instead of its intermediate text and steps; a settled turn shows only its opening line and its answer, with the steps behind a Worked summary in the message footer",
155
+ "defaultEnabled": false
156
+ },
149
157
  {
150
158
  "id": "inline-assistant-intermediates",
151
159
  "scope": "client",
@@ -560,14 +568,6 @@
560
568
  "description": "Streams an interactive virtual desktop from a platform-hosted, containerized assistant over /v1/desktop/stream. Requires platform hosting; self-hosted assistants are excluded. Off: the virtual desktop is hidden. On: the chat header offers its viewer and controls.",
561
569
  "defaultEnabled": false
562
570
  },
563
- {
564
- "id": "assistant-inbox",
565
- "scope": "client",
566
- "key": "assistant-inbox",
567
- "label": "Assistant Inbox",
568
- "description": "Shows an Assistant Inbox entry above Preferences in the web sidebar and the /assistant/inbox page it opens: a mailbox for the assistant's managed vellum.me address (received and sent, with a reading pane), the inline email setup card for entitled orgs with no address yet, and an upgrade card for orgs without the managed_email entitlement. With it on, email setup happens only there: the billing wizard hands off to the inbox instead of its own domain step, the billing page's finish-setup nudge gives way to the inbox's rail entry, and the Channels page's Email section points there instead of registering an address itself. Off hides the entry, redirects the route to chat, and keeps the older setup surfaces.",
569
- "defaultEnabled": false
570
- },
571
571
  {
572
572
  "id": "sidebar-done",
573
573
  "scope": "both",
@@ -575,6 +575,14 @@
575
575
  "label": "Sidebar Done",
576
576
  "description": "Turns archiving a chat into marking it done: the sidebar row carries a Done check instead of the overflow menu, an All chats page lists the full history, and done chats stay findable in search by default. Off keeps today's Archive wording, the overflow menu, and search that hides archived rows unless the query asks for them.",
577
577
  "defaultEnabled": false
578
+ },
579
+ {
580
+ "id": "vellum-trusted-contacts",
581
+ "scope": "both",
582
+ "key": "vellum-trusted-contacts",
583
+ "label": "Vellum Trusted Contacts",
584
+ "description": "Lets a guardian give another person access to talk to their assistant in the Vellum UI as a trusted contact. Gates contact-role token minting in the gateway, trusted-contact trust resolution for `vellum` channel principals, and the assistant's trusted-contact route allowlist. Off: none of those surfaces admit a contact, and the assistant stays reachable only by its guardian.",
585
+ "defaultEnabled": false
578
586
  }
579
587
  ]
580
588
  }
@@ -74,6 +74,7 @@ export const KNOWN_LLM_PROVIDERS = [
74
74
  "litellm",
75
75
  "opencode",
76
76
  "baseten",
77
+ "ionet",
77
78
  "poolside",
78
79
  "typesafe",
79
80
  // Routing identities: "vellum" = the platform-managed route (upstream
@@ -74,4 +74,5 @@ export type ChannelType =
74
74
  | "phone"
75
75
  | "telegram"
76
76
  | "discord"
77
+ | "vellum-shared"
77
78
  | "other";
@@ -107,6 +107,7 @@ import { faviconUrlForDomain } from "../util/favicon.js";
107
107
  import { redactLogString } from "../util/log-redact.js";
108
108
  import { getLogger } from "../util/logger.js";
109
109
  import { withSqliteRetry } from "../util/sqlite-retry.js";
110
+ import { safeStringSlice } from "../util/unicode.js";
110
111
  import type { DirectiveRequest } from "./assistant-attachments.js";
111
112
  import {
112
113
  cleanAssistantContent,
@@ -640,6 +641,8 @@ export interface EventHandlerState {
640
641
  * rather than on every delta.
641
642
  */
642
643
  readonly surfacePendingScannedToolUseIds: Set<string>;
644
+ /** App previews whose cumulative JSON reached the client payload cap. */
645
+ readonly cappedAppPreviewToolUseIds: Set<string>;
643
646
  /**
644
647
  * In-flight priming of {@link liveRevealGuardEntries}. The dispatcher
645
648
  * awaits this before processing a `text_delta` (and before the
@@ -777,6 +780,7 @@ export function createEventHandlerState(): EventHandlerState {
777
780
  forChatMintWatermark: currentForChatMintWatermark(),
778
781
  stagedRevealIdentities: new Set(),
779
782
  surfacePendingScannedToolUseIds: new Set(),
783
+ cappedAppPreviewToolUseIds: new Set(),
780
784
  liveRevealGuardPriming: undefined,
781
785
  };
782
786
  }
@@ -1411,6 +1415,7 @@ function schedulePartialFlush(
1411
1415
  // tools the client discards it (extractCodePreview only handles app tools),
1412
1416
  // so we skip forwarding entirely to avoid transport/decode overhead.
1413
1417
  const APP_TOOL_NAMES = new Set(["app_create"]);
1418
+ const APP_INPUT_PREVIEW_CHAR_LIMIT = 256_000;
1414
1419
 
1415
1420
  // ── Surface Placeholder Detection ────────────────────────────────────
1416
1421
  // A `visual` ui_show streams the longest tool input the model produces, and
@@ -2111,13 +2116,24 @@ export function handleInputJsonDelta(
2111
2116
  // Only forward input deltas for app tools — the client only uses this
2112
2117
  // stream for app_create code previews. Non-app tools would send large
2113
2118
  // cumulative JSON on every delta with no benefit.
2114
- if (!APP_TOOL_NAMES.has(event.toolName)) {
2119
+ if (
2120
+ !APP_TOOL_NAMES.has(event.toolName) ||
2121
+ state.cappedAppPreviewToolUseIds.has(event.toolUseId)
2122
+ ) {
2115
2123
  return;
2116
2124
  }
2125
+ const content = safeStringSlice(
2126
+ event.accumulatedJson,
2127
+ 0,
2128
+ APP_INPUT_PREVIEW_CHAR_LIMIT,
2129
+ );
2130
+ if (content.length < event.accumulatedJson.length) {
2131
+ state.cappedAppPreviewToolUseIds.add(event.toolUseId);
2132
+ }
2117
2133
  deps.onEvent({
2118
2134
  type: "tool_input_delta",
2119
2135
  toolName: event.toolName,
2120
- content: event.accumulatedJson,
2136
+ content,
2121
2137
  conversationId: deps.ctx.conversationId,
2122
2138
  toolUseId: event.toolUseId,
2123
2139
  messageId: state.lastAssistantMessageId,
@@ -44,6 +44,7 @@ import { isSendUserMessageActiveForTurn } from "../config/send-user-message-gate
44
44
  import { desktopAutomationLease } from "../desktop/desktop-automation-lease.js";
45
45
  import { writeRelationshipState } from "../home/relationship-state-writer.js";
46
46
  import type { UserPromptSubmitInputContext } from "../hooks/types.js";
47
+ import type { SupportedLocale } from "../i18n/index.js";
47
48
  import {
48
49
  addMessage,
49
50
  deleteMessageById,
@@ -500,6 +501,7 @@ export async function runAgentLoopImpl(
500
501
  * loop defaults to `'mainAgent'` for user-initiated turns.
501
502
  */
502
503
  callSite?: LLMCallSite;
504
+ locale?: SupportedLocale;
503
505
  /** Skip fresh retrieval while keeping resident memory and static context. */
504
506
  skipMemoryRetrieval?: boolean;
505
507
  /**
@@ -1719,6 +1721,7 @@ export async function runAgentLoopImpl(
1719
1721
  onCheckpoint,
1720
1722
  callSite: turnCallSite,
1721
1723
  inferenceCallSite,
1724
+ locale: options?.locale,
1722
1725
  suppressAssistantText: sendUserMessageActive,
1723
1726
  supportsDynamicUi: conversationSupportsDynamicUi(ctx),
1724
1727
  trust: loopTrust,
@@ -26,6 +26,7 @@ import {
26
26
  parseInterfaceId,
27
27
  } from "../channels/types.js";
28
28
  import { parseImageDimensions } from "../context/image-dimensions.js";
29
+ import type { SupportedLocale } from "../i18n/index.js";
29
30
  import {
30
31
  type ProviderMessageMetadata,
31
32
  providerMessageMetadataSchema,
@@ -824,6 +825,7 @@ export interface EnqueueMessageOptions {
824
825
  currentPage?: string;
825
826
  metadata?: Record<string, unknown>;
826
827
  isInteractive?: boolean;
828
+ locale?: SupportedLocale;
827
829
  displayContent?: string;
828
830
  transport?: ConversationTransportMetadata;
829
831
  clientMessageId?: string;
@@ -873,6 +875,7 @@ export function enqueueMessage(
873
875
  currentPage,
874
876
  metadata,
875
877
  isInteractive,
878
+ locale,
876
879
  displayContent,
877
880
  transport,
878
881
  clientMessageId,
@@ -912,6 +915,7 @@ export function enqueueMessage(
912
915
  turnChannelContext,
913
916
  turnInterfaceContext,
914
917
  isInteractive,
918
+ locale,
915
919
  sourceActorPrincipalId,
916
920
  authContext: queuedAuthContext,
917
921
  trustContext: queuedTrustContext,
@@ -20,6 +20,7 @@ import {
20
20
  type TurnInterfaceContext,
21
21
  } from "../channels/types.js";
22
22
  import type { LLMCallSite } from "../config/schemas/llm.js";
23
+ import type { SupportedLocale } from "../i18n/index.js";
23
24
  import { extractPreferences } from "../notifications/preference-extractor.js";
24
25
  import { createPreference } from "../notifications/preferences-store.js";
25
26
  import {
@@ -1278,6 +1279,7 @@ async function drainSingleMessage(
1278
1279
  titleText?: string;
1279
1280
  isHiddenPrompt?: boolean;
1280
1281
  turnTrustContext?: TrustContext;
1282
+ locale?: SupportedLocale;
1281
1283
  cronRunId?: string | null;
1282
1284
  } = {
1283
1285
  isUserMessage: true,
@@ -1289,6 +1291,9 @@ async function drainSingleMessage(
1289
1291
  if (next.isInteractive !== undefined) {
1290
1292
  drainLoopOptions.isInteractive = next.isInteractive;
1291
1293
  }
1294
+ if (next.locale !== undefined) {
1295
+ drainLoopOptions.locale = next.locale;
1296
+ }
1292
1297
  if (agentLoopContent !== resolvedContent) {
1293
1298
  drainLoopOptions.titleText = resolvedContent;
1294
1299
  }
@@ -1797,6 +1802,7 @@ async function drainBatch(
1797
1802
  isHiddenPrompt?: boolean;
1798
1803
  notifyUserMessageId?: string;
1799
1804
  turnTrustContext?: TrustContext;
1805
+ locale?: SupportedLocale;
1800
1806
  cronRunId?: string | null;
1801
1807
  } = {
1802
1808
  isUserMessage: true,
@@ -1816,6 +1822,9 @@ async function drainBatch(
1816
1822
  if (lastSuccessfulBatchEntry?.isInteractive !== undefined) {
1817
1823
  drainLoopOptions.isInteractive = lastSuccessfulBatchEntry.isInteractive;
1818
1824
  }
1825
+ if (lastSuccessfulBatchEntry?.locale !== undefined) {
1826
+ drainLoopOptions.locale = lastSuccessfulBatchEntry.locale;
1827
+ }
1819
1828
  // A batch counts as a hidden turn only when every message in it is a
1820
1829
  // hidden machine signal — one genuine user prompt justifies the
1821
1830
  // prompt-as-user-speech consumers (title generation).
@@ -1871,6 +1880,7 @@ export interface ProcessMessageOptions {
1871
1880
  currentPage?: string;
1872
1881
  isInteractive?: boolean;
1873
1882
  callSite?: LLMCallSite;
1883
+ locale?: SupportedLocale;
1874
1884
  /**
1875
1885
  * Optional ad-hoc inference-profile override applied to every LLM call
1876
1886
  * this turn issues (e.g. a schedule's pinned profile). Forwarded to
@@ -1928,6 +1938,7 @@ export async function processMessage(
1928
1938
  currentPage,
1929
1939
  isInteractive,
1930
1940
  callSite,
1941
+ locale,
1931
1942
  overrideProfile,
1932
1943
  displayContent,
1933
1944
  sourceActorPrincipalId,
@@ -2451,6 +2462,7 @@ export async function processMessage(
2451
2462
  isUserMessage?: boolean;
2452
2463
  titleText?: string;
2453
2464
  callSite?: LLMCallSite;
2465
+ locale?: SupportedLocale;
2454
2466
  overrideProfile?: string;
2455
2467
  turnTrustContext?: TrustContext;
2456
2468
  } = {
@@ -2471,6 +2483,9 @@ export async function processMessage(
2471
2483
  if (callSite !== undefined) {
2472
2484
  loopOptions.callSite = callSite;
2473
2485
  }
2486
+ if (locale !== undefined) {
2487
+ loopOptions.locale = locale;
2488
+ }
2474
2489
  if (overrideProfile !== undefined) {
2475
2490
  loopOptions.overrideProfile = overrideProfile;
2476
2491
  }
@@ -10,6 +10,7 @@ import type {
10
10
  TurnChannelContext,
11
11
  TurnInterfaceContext,
12
12
  } from "../channels/types.js";
13
+ import type { SupportedLocale } from "../i18n/index.js";
13
14
  import type { AuthContext } from "../runtime/auth/types.js";
14
15
  import { getLogger } from "../util/logger.js";
15
16
  import type { UserMessageAttachment } from "./message-protocol.js";
@@ -30,6 +31,7 @@ export interface QueuedMessage {
30
31
  turnInterfaceContext?: TurnInterfaceContext;
31
32
  /** When false, the turn has no interactive user and should skip clarification prompts. */
32
33
  isInteractive?: boolean;
34
+ locale?: SupportedLocale;
33
35
  /** Requester identity captured from the verified auth context at enqueue time. */
34
36
  sourceActorPrincipalId?: string;
35
37
  /** Full auth snapshot captured at enqueue time for turn-scoped authorization decisions. */
@@ -44,6 +44,7 @@ import {
44
44
  derefToolResultReReads,
45
45
  postTurnTruncateToolResults,
46
46
  } from "../context/post-turn-tool-result-truncation.js";
47
+ import type { SupportedLocale } from "../i18n/index.js";
47
48
  import { readProviderMetadata } from "../messaging/read-provider-metadata.js";
48
49
  import { isGuardianCardRow } from "../notifications/approval-card-data.js";
49
50
  import { PermissionPrompter } from "../permissions/prompter.js";
@@ -3528,6 +3529,7 @@ export class Conversation {
3528
3529
  */
3529
3530
  replyDeliveredInAppOnly?: boolean;
3530
3531
  callSite?: LLMCallSite;
3532
+ locale?: SupportedLocale;
3531
3533
  /** Skip fresh retrieval while keeping resident memory and static context. */
3532
3534
  skipMemoryRetrieval?: boolean;
3533
3535
  /** Provider configuration source when distinct from turn semantics. */
@@ -19,6 +19,10 @@ import type {
19
19
  import { ConfirmationDecisionSchema } from "../../api/responses/conversation-message.js";
20
20
  import { getConfig } from "../../config/loader.js";
21
21
  import type { LLMCallSite, Speed } from "../../config/schemas/llm.js";
22
+ import {
23
+ resolveStoredMessageText,
24
+ type SupportedLocale,
25
+ } from "../../i18n/index.js";
22
26
  import { ipcCall as gatewayIpcCall } from "../../ipc/gateway-client.js";
23
27
  import type { ProviderMessageMetadata } from "../../messaging/provider-message-metadata.js";
24
28
  import type { SecretPromptResult } from "../../permissions/secret-prompt-types.js";
@@ -409,6 +413,7 @@ export function renderHistoryContent(
409
413
  >,
410
414
  messageId?: string,
411
415
  metadata?: unknown,
416
+ locale?: SupportedLocale,
412
417
  ): RenderedHistoryContent {
413
418
  // A row whose turn routed its reply through `send_user_message` carries
414
419
  // private working notes, so every consumer of this render (web history,
@@ -575,8 +580,14 @@ export function renderHistoryContent(
575
580
  const rawText =
576
581
  typeof block._redactionVersion === "number" &&
577
582
  block._redactionVersion >= SENTINEL_REDACTION_VERSION
578
- ? block.text
579
- : neutralizeRedactedSentinels(block.text);
583
+ ? locale
584
+ ? resolveStoredMessageText(block.text, locale)
585
+ : block.text
586
+ : neutralizeRedactedSentinels(
587
+ locale
588
+ ? resolveStoredMessageText(block.text, locale)
589
+ : block.text,
590
+ );
580
591
  const displayText = unwrapExternalContentForDisplay(rawText);
581
592
  // Skip empty/whitespace-only text blocks. During streaming the client
582
593
  // discards empty text deltas (guard !text.isEmpty), so including them
@@ -11,6 +11,7 @@ export const SYNC_TAGS = {
11
11
  assistantAvatar: "assistant:self:avatar",
12
12
  assistantIdentity: "assistant:self:identity",
13
13
  assistantConfig: "assistant:self:config",
14
+ assistantComposerPreferences: "assistant:self:composerPreferences",
14
15
  assistantSounds: "assistant:self:sounds",
15
16
  assistantSchedules: "assistant:self:schedules",
16
17
  assistantDesktop: "assistant:self:desktop",
@@ -19,6 +19,7 @@ import {
19
19
  parseChannelId,
20
20
  parseInterfaceId,
21
21
  } from "../channels/types.js";
22
+ import type { SupportedLocale } from "../i18n/index.js";
22
23
  import { resolveAttachmentsForPersist } from "../persistence/attachments-store.js";
23
24
  import {
24
25
  addMessage,
@@ -88,6 +89,8 @@ type ProcessMessageOptions = ConversationCreateOptions & {
88
89
  sourceChannel?: string;
89
90
  /** Originating interface (e.g. "cli", "web"). Defaults to "web". */
90
91
  sourceInterface?: string;
92
+ /** Locale for daemon-authored text emitted during this turn. */
93
+ locale?: SupportedLocale;
91
94
  /**
92
95
  * Origin tag of the turn (the conversation's `TitleOrigin`, e.g.
93
96
  * "memory_consolidation"), threaded from `runBackgroundJob`. Propagated
@@ -780,6 +783,7 @@ export async function processMessage(
780
783
  onEvent: emitEvent,
781
784
  isInteractive: options?.isInteractive ?? false,
782
785
  isUserMessage: true,
786
+ locale: options?.locale,
783
787
  ...(options?.callSite ? { callSite: options.callSite } : {}),
784
788
  ...(options?.overrideProfile
785
789
  ? { overrideProfile: options.overrideProfile }
@@ -854,6 +858,7 @@ export async function processMessageInBackground(
854
858
  onEvent: emitEvent,
855
859
  isInteractive: options?.isInteractive ?? false,
856
860
  isUserMessage: true,
861
+ locale: options?.locale,
857
862
  ...(options?.callSite ? { callSite: options.callSite } : {}),
858
863
  ...(options?.overrideProfile
859
864
  ? { overrideProfile: options.overrideProfile }
@@ -8,6 +8,7 @@ import {
8
8
  MESSAGE_KEYS,
9
9
  negotiateLocale,
10
10
  resolveConversationTitle,
11
+ resolveStoredMessageText,
11
12
  SUPPORTED_LOCALES,
12
13
  t,
13
14
  } from "../index.js";
@@ -103,6 +104,30 @@ describe("resolveConversationTitle", () => {
103
104
  });
104
105
  });
105
106
 
107
+ describe("resolveStoredMessageText", () => {
108
+ test("resolves exact stored keys while preserving surrounding whitespace", () => {
109
+ expect(
110
+ resolveStoredMessageText(
111
+ `\n\n${MESSAGE_KEYS.AGENT_LOOP_UNPARSEABLE_TOOL_RETRY_STOP}`,
112
+ "es",
113
+ ),
114
+ ).toBe(
115
+ `\n\n${
116
+ MESSAGE_CATALOGS.es[MESSAGE_KEYS.AGENT_LOOP_UNPARSEABLE_TOOL_RETRY_STOP]
117
+ }`,
118
+ );
119
+ });
120
+
121
+ test("passes legacy and ordinary display text through", () => {
122
+ const legacy =
123
+ "I stopped after repeated malformed tool calls to keep this conversation from growing indefinitely. Please try again, or switch models if the problem continues.";
124
+ expect(resolveStoredMessageText(legacy, "es")).toBe(legacy);
125
+ expect(resolveStoredMessageText("ordinary assistant text", "zh")).toBe(
126
+ "ordinary assistant text",
127
+ );
128
+ });
129
+ });
130
+
106
131
  describe("isMessageKey", () => {
107
132
  test("recognizes catalog keys only", () => {
108
133
  expect(isMessageKey(MESSAGE_KEYS.CONVERSATION_TITLE_UNTITLED)).toBe(true);