@vellumai/assistant 0.11.4-staging.2 → 0.11.4-staging.4

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 (209) hide show
  1. package/AGENTS.md +8 -2
  2. package/ARCHITECTURE.md +2 -0
  3. package/docs/architecture/memory.md +15 -0
  4. package/docs/browser-use-architecture-phase2.md +128 -56
  5. package/docs/flux-turn-detection-spike.md +11 -6
  6. package/docs/guardian-request-flow.md +35 -0
  7. package/knip.json +3 -0
  8. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/remote-web-pairing.ts +60 -0
  9. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/remote-web-pairing.ts +60 -0
  10. package/node_modules/@vellumai/service-contracts/src/remote-web-pairing.ts +60 -0
  11. package/openapi.yaml +136 -76
  12. package/package.json +1 -1
  13. package/scripts/write-plugin-api-shim.ts +10 -0
  14. package/src/__tests__/app-control-flow.test.ts +1 -0
  15. package/src/__tests__/approval-routes-http.test.ts +2 -2
  16. package/src/__tests__/assistant-feature-flag-guard.test.ts +25 -3
  17. package/src/__tests__/channel-setup-panel-ack.test.ts +1 -1
  18. package/src/__tests__/compaction-events.test.ts +8 -10
  19. package/src/__tests__/conversation-agent-loop.test.ts +4 -1
  20. package/src/__tests__/conversation-confirmation-signals.test.ts +112 -0
  21. package/src/__tests__/conversation-load-history-repair.test.ts +209 -0
  22. package/src/__tests__/conversation-notifiers-provenance.test.ts +1 -1
  23. package/src/__tests__/conversation-queue.test.ts +39 -62
  24. package/src/__tests__/conversation-routes-disk-view.test.ts +1 -1
  25. package/src/__tests__/conversation-routes-enabled-plugins.test.ts +1 -1
  26. package/src/__tests__/conversation-routes-guardian-reply.test.ts +9 -9
  27. package/src/__tests__/conversation-routes-hidden-queue.test.ts +1 -1
  28. package/src/__tests__/conversation-routes-slash-commands.test.ts +1 -1
  29. package/src/__tests__/conversation-runtime-assembly.test.ts +53 -0
  30. package/src/__tests__/conversation-slash-queue.test.ts +3 -0
  31. package/src/__tests__/conversation-surfaces-action-delivery.test.ts +1 -0
  32. package/src/__tests__/conversation-surfaces-activation-emit.test.ts +1 -0
  33. package/src/__tests__/conversation-surfaces-app-control.test.ts +1 -0
  34. package/src/__tests__/conversation-surfaces-app-open.test.ts +1 -1
  35. package/src/__tests__/conversation-surfaces-data-persist.test.ts +1 -1
  36. package/src/__tests__/conversation-surfaces-history-restored-completion.test.ts +21 -14
  37. package/src/__tests__/conversation-surfaces-queued-emit.test.ts +1 -0
  38. package/src/__tests__/conversation-surfaces-standalone-payloads.test.ts +1 -0
  39. package/src/__tests__/conversation-surfaces-standalone.test.ts +1 -0
  40. package/src/__tests__/conversation-surfaces-state-update.test.ts +1 -1
  41. package/src/__tests__/conversation-surfaces-table-action.test.ts +1 -1
  42. package/src/__tests__/conversation-surfaces-task-progress.test.ts +1 -1
  43. package/src/__tests__/conversation-tool-setup-app-refresh.test.ts +1 -1
  44. package/src/__tests__/conversation-tool-setup-attribution.test.ts +1 -1
  45. package/src/__tests__/cu-unified-flow.test.ts +1 -0
  46. package/src/__tests__/document-sync-tags.test.ts +0 -75
  47. package/src/__tests__/file-ops-service.test.ts +163 -30
  48. package/src/__tests__/filesystem-tools.test.ts +23 -24
  49. package/src/__tests__/gateway-only-guard.test.ts +2 -5
  50. package/src/__tests__/host-file-read-tool.test.ts +16 -19
  51. package/src/__tests__/http-user-message-parity.test.ts +1 -1
  52. package/src/__tests__/init-feature-flag-overrides.test.ts +49 -0
  53. package/src/__tests__/managed-skill-lifecycle.test.ts +7 -0
  54. package/src/__tests__/media-generate-image.test.ts +131 -21
  55. package/src/__tests__/memory-retrieval-hook.test.ts +94 -2
  56. package/src/__tests__/plugin-api-webhook-url.test.ts +10 -7
  57. package/src/__tests__/plugin-import-boundary-reverse-guard.test.ts +7 -3
  58. package/src/__tests__/proxy-approval-callback.test.ts +1 -0
  59. package/src/__tests__/qdrant-manager.test.ts +14 -1
  60. package/src/__tests__/run-due-schedules.test.ts +21 -0
  61. package/src/__tests__/scaffold-managed-skill-tool.test.ts +187 -18
  62. package/src/__tests__/schedule-routes.test.ts +23 -0
  63. package/src/__tests__/schedule-store.test.ts +17 -0
  64. package/src/__tests__/secret-ingress-http.test.ts +1 -1
  65. package/src/__tests__/send-endpoint-busy.test.ts +3 -3
  66. package/src/__tests__/starter-task-flow.test.ts +5 -4
  67. package/src/__tests__/subagent-fork-prompt-role.test.ts +1 -1
  68. package/src/__tests__/subagent-spawn-and-await.test.ts +4 -7
  69. package/src/__tests__/subagent-tool-gate-mode.test.ts +1 -1
  70. package/src/__tests__/subagent-tools.test.ts +81 -101
  71. package/src/__tests__/surface-completion-in-flight-snapshot.test.ts +1 -0
  72. package/src/__tests__/tool-executor.test.ts +5 -1
  73. package/src/__tests__/ui-choice-copy-surfaces.test.ts +1 -1
  74. package/src/__tests__/ui-visual-surface.test.ts +1 -1
  75. package/src/__tests__/ui-voice-picker-surface.test.ts +1 -1
  76. package/src/__tests__/ui-work-result-surface.test.ts +1 -1
  77. package/src/__tests__/voice-scoped-grant-consumer.test.ts +5 -3
  78. package/src/__tests__/voice-session-bridge.test.ts +85 -29
  79. package/src/acp/session-manager.ts +8 -1
  80. package/src/api/events/host-file.ts +2 -2
  81. package/src/api/surfaces.ts +5 -0
  82. package/src/calls/__tests__/voice-session-bridge.test.ts +21 -10
  83. package/src/calls/__tests__/voice-triage-escalate.test.ts +8 -0
  84. package/src/calls/voice-session-bridge.ts +44 -25
  85. package/src/calls/voice-triage-escalate.ts +1 -0
  86. package/src/cli/bundled-modules.ts +29 -0
  87. package/src/cli/commands/db/repair.ts +4 -8
  88. package/src/cli/commands/domain.ts +6 -3
  89. package/src/cli/commands/email.ts +6 -3
  90. package/src/cli/commands/keys.ts +8 -3
  91. package/src/cli/commands/plugins.ts +85 -36
  92. package/src/cli/commands/schedules.ts +35 -1
  93. package/src/cli/lib/bundled-marketplace.json +1 -1
  94. package/src/config/__tests__/balanced-model-experiment.test.ts +278 -0
  95. package/src/config/assistant-feature-flags.ts +36 -15
  96. package/src/config/balanced-model-experiment.ts +35 -0
  97. package/src/config/bundled-skills/image-studio/SKILL.md +5 -4
  98. package/src/config/bundled-skills/image-studio/TOOLS.json +1 -1
  99. package/src/config/bundled-skills/image-studio/tools/media-generate-image.ts +101 -0
  100. package/src/config/bundled-skills/skill-management/TOOLS.json +9 -3
  101. package/src/config/bundled-skills/subagent/SKILL.md +17 -12
  102. package/src/config/bundled-skills/subagent/TOOLS.json +4 -4
  103. package/src/config/call-site-defaults.ts +7 -0
  104. package/src/config/default-profile-catalog.ts +96 -4
  105. package/src/config/feature-flag-registry.json +11 -11
  106. package/src/config/skills.ts +9 -2
  107. package/src/daemon/__tests__/conversation-surfaces-launch.test.ts +1 -1
  108. package/src/daemon/conversation-agent-loop.ts +14 -13
  109. package/src/daemon/conversation-notifiers.ts +11 -9
  110. package/src/daemon/conversation-process.ts +0 -27
  111. package/src/daemon/conversation-runtime-assembly.ts +9 -2
  112. package/src/daemon/conversation-store.ts +4 -4
  113. package/src/daemon/conversation-surfaces.ts +27 -13
  114. package/src/daemon/conversation-tool-setup.ts +2 -4
  115. package/src/daemon/conversation.ts +104 -51
  116. package/src/daemon/doordash-steps.ts +2 -2
  117. package/src/daemon/lifecycle.ts +14 -1
  118. package/src/daemon/process-message.ts +0 -13
  119. package/src/daemon/windows-compiled-entry.ts +4 -0
  120. package/src/documents/document-store.ts +5 -235
  121. package/src/hooks/types.ts +5 -0
  122. package/src/ipc/gateway-flag-listener.ts +17 -3
  123. package/src/live-voice/__tests__/live-voice-flux-turn-end.test.ts +118 -0
  124. package/src/live-voice/live-voice-manager.ts +16 -3
  125. package/src/live-voice/live-voice-session.ts +63 -9
  126. package/src/live-voice/windows-compiled-live-voice.ts +4 -0
  127. package/src/monitoring/control.ts +1 -0
  128. package/src/monitoring/db-integrity-sample.ts +4 -5
  129. package/src/notifications/AGENTS.md +2 -0
  130. package/src/notifications/approval-card-data.ts +33 -0
  131. package/src/permissions/prompter.ts +1 -5
  132. package/src/persistence/conversation-queries.ts +66 -16
  133. package/src/persistence/embeddings/qdrant-manager.ts +84 -49
  134. package/src/persistence/migrations/360-add-document-workspace-path.ts +5 -14
  135. package/src/persistence/schema/documents.ts +4 -4
  136. package/src/plugin-api/constants.ts +8 -0
  137. package/src/plugin-api/index.ts +5 -1
  138. package/src/plugin-api/webhook-url.ts +13 -11
  139. package/src/plugins/defaults/main.ts +6 -7
  140. package/src/plugins/defaults/memory/graph/__tests__/conversation-graph-memory-v2-routing.test.ts +77 -0
  141. package/src/plugins/defaults/memory/graph/conversation-graph-memory.ts +24 -6
  142. package/src/plugins/defaults/memory/hooks/user-prompt-submit.ts +53 -5
  143. package/src/plugins/defaults/memory/memory-retrospective-job.ts +4 -4
  144. package/src/plugins/defaults/memory/v3/__tests__/injection.test.ts +18 -0
  145. package/src/plugins/defaults/memory/v3/__tests__/shadow-plugin.test.ts +66 -1
  146. package/src/plugins/defaults/memory/v3/injector.ts +8 -0
  147. package/src/plugins/defaults/memory/v3/shadow-plugin.ts +23 -9
  148. package/src/plugins/defaults/memory/worker-control.ts +1 -0
  149. package/src/plugins/defaults/worker-entrypoints.ts +3 -0
  150. package/src/plugins/mtime-cache.ts +17 -0
  151. package/src/prompts/templates/system-sections.ts +0 -7
  152. package/src/providers/__tests__/context-overflow-error.test.ts +24 -0
  153. package/src/providers/__tests__/retry-callsite.test.ts +20 -0
  154. package/src/providers/openai/chat-completions-provider.ts +11 -1
  155. package/src/providers/speech-to-text/__tests__/deepgram-flux-realtime.test.ts +11 -6
  156. package/src/providers/speech-to-text/deepgram-flux-realtime.ts +9 -49
  157. package/src/routes/control.ts +1 -0
  158. package/src/routes/route-host-client.ts +1 -0
  159. package/src/runtime/AGENTS.md +16 -17
  160. package/src/runtime/agent-wake.ts +15 -12
  161. package/src/runtime/routes/__tests__/conversation-list-routes.test.ts +170 -1
  162. package/src/runtime/routes/__tests__/schedule-routes-disarm-reason.test.ts +215 -0
  163. package/src/runtime/routes/conversation-list-routes.ts +54 -22
  164. package/src/runtime/routes/conversation-management-routes.ts +2 -3
  165. package/src/runtime/routes/conversation-routes.ts +11 -13
  166. package/src/runtime/routes/documents-routes.ts +3 -222
  167. package/src/runtime/routes/playground/__tests__/inject-failures.test.ts +2 -0
  168. package/src/runtime/routes/playground/__tests__/reset-circuit.test.ts +3 -0
  169. package/src/runtime/routes/playground/inject-failures.ts +2 -2
  170. package/src/runtime/routes/playground/reset-circuit.ts +1 -1
  171. package/src/runtime/routes/schedule-routes.ts +94 -7
  172. package/src/runtime/routes/workspace-routes.ts +0 -9
  173. package/src/runtime/routes/workspace-utils.ts +3 -13
  174. package/src/runtime/services/conversation-serializer.ts +7 -2
  175. package/src/schedule/__tests__/plugin-schedule-declarations.test.ts +68 -5
  176. package/src/schedule/__tests__/plugin-schedule-reconciler.test.ts +81 -0
  177. package/src/schedule/plugin-schedule-availability.ts +58 -0
  178. package/src/schedule/plugin-schedule-declarations.ts +23 -27
  179. package/src/schedule/plugin-schedule-reconciler.ts +12 -3
  180. package/src/schedule/schedule-store.ts +5 -1
  181. package/src/schedule/scheduler.ts +9 -4
  182. package/src/schedule/worker-control.ts +1 -0
  183. package/src/subagent/__tests__/consult-prompt.test.ts +26 -15
  184. package/src/subagent/consult-context.ts +11 -11
  185. package/src/subagent/consult-prompt.ts +26 -35
  186. package/src/subagent/manager.ts +20 -37
  187. package/src/subagent/notify.ts +7 -1
  188. package/src/subagent/types.ts +15 -13
  189. package/src/tools/__tests__/tool-input-schemas.test.ts +7 -7
  190. package/src/tools/acp/spawn.ts +6 -4
  191. package/src/tools/filesystem/read.ts +27 -10
  192. package/src/tools/host-filesystem/read.ts +27 -15
  193. package/src/tools/shared/filesystem/file-ops-service.ts +63 -35
  194. package/src/tools/shared/filesystem/legacy-read-args.ts +22 -0
  195. package/src/tools/shared/filesystem/types.ts +5 -5
  196. package/src/tools/skills/scaffold-managed.ts +25 -7
  197. package/src/tools/subagent/spawn.ts +28 -88
  198. package/src/tools/ui-surface/surface-shape-docs.ts +1 -1
  199. package/src/util/__tests__/worker-process-command.test.ts +37 -0
  200. package/src/util/logger.ts +16 -0
  201. package/src/util/worker-process.ts +37 -4
  202. package/src/windows-compiled-cli.ts +32 -0
  203. package/src/windows-compiled-entry.ts +4 -0
  204. package/src/windows-compiled-logger.ts +6 -0
  205. package/src/windows-compiled-worker-entry.ts +29 -0
  206. package/src/__tests__/document-workspace-file.test.ts +0 -467
  207. package/src/daemon/interactive-turn-sender.ts +0 -59
  208. package/src/subagent/__tests__/consult-transcript.test.ts +0 -184
  209. package/src/subagent/consult-transcript.ts +0 -90
@@ -4024,6 +4024,59 @@ describe("Slack channel chronological rendering — multi-thread", () => {
4024
4024
  expect(allText).not.toContain("private guardian-only context");
4025
4025
  });
4026
4026
 
4027
+ // Slack builds provider history from rows rather than `this.messages`, so a
4028
+ // guardian card dropped only at `Conversation.loadFromDb` would still reach
4029
+ // the model here -- the channel the incident happened on.
4030
+ test("loadSlackChronologicalContext drops guardian card rows", () => {
4031
+ const caps: ChannelCapabilities = {
4032
+ channel: "slack",
4033
+ dashboardCapable: false,
4034
+ supportsDynamicUi: false,
4035
+ supportsVoiceInput: false,
4036
+ chatType: "channel",
4037
+ };
4038
+ const cardRow: MessageRow = {
4039
+ id: "m-card",
4040
+ conversationId: "conv-1",
4041
+ role: "assistant",
4042
+ content: [
4043
+ {
4044
+ type: "ui_surface",
4045
+ surfaceId: "tool-approval-req-1",
4046
+ surfaceType: "card",
4047
+ title: "Tool Approval",
4048
+ data: {},
4049
+ display: "inline",
4050
+ },
4051
+ { type: "text", text: "Tool Approval card body" },
4052
+ ],
4053
+ createdAt: 1700000035_000,
4054
+ metadata: null,
4055
+ clientMessageId: null,
4056
+ finalized: 1,
4057
+ } as MessageRow;
4058
+ const rows: MessageRow[] = [
4059
+ userRow({
4060
+ id: "asked",
4061
+ createdAt: 1700000030_000,
4062
+ text: "please run it",
4063
+ slackMeta: buildSlackMeta({ channelTs: T2, displayName: "carol" }),
4064
+ }),
4065
+ cardRow,
4066
+ ];
4067
+
4068
+ const result = loadSlackChronologicalContext("conv-1", caps, {
4069
+ loader: () => rows,
4070
+ trustClass: "guardian",
4071
+ });
4072
+
4073
+ expect(result).not.toBeNull();
4074
+ const renderedText = JSON.stringify(result!.messages);
4075
+ expect(renderedText).toContain("please run it");
4076
+ expect(renderedText).not.toContain("Tool Approval card body");
4077
+ expect(renderedText).not.toContain("tool-approval-req-1");
4078
+ });
4079
+
4027
4080
  test("loadSlackChronologicalContext preserves summary and filters by Slack watermark", () => {
4028
4081
  const caps: ChannelCapabilities = {
4029
4082
  channel: "slack",
@@ -62,6 +62,9 @@ mock.module("../persistence/conversation-crud.js", () => ({
62
62
  getLastUserTimestampBefore: () => 0,
63
63
  reserveMessage: mock(async () => ({ id: "msg-reserve" })),
64
64
  updateMessageContent: mock(() => {}),
65
+ // The drain records the snapshot-stream anchor after each echo. Mocked so a
66
+ // sink that stamps seqs (the hub) cannot send the real recorder to SQLite.
67
+ recordConversationPersistedSeq: () => {},
65
68
  }));
66
69
 
67
70
  mock.module("../persistence/conversation-queries.js", () => ({
@@ -43,6 +43,7 @@ function makeContext(sent: AssistantEvent[] = []): Conversation & {
43
43
  return asConversation({
44
44
  conversationId: "conv-1",
45
45
  sendToClient: (msg: AssistantEvent) => sent.push(msg),
46
+ emit: (msg: AssistantEvent) => sent.push(msg),
46
47
  pendingSurfaceActions: new Map<string, { surfaceType: SurfaceType }>(),
47
48
  lastSurfaceAction: new Map<
48
49
  string,
@@ -69,6 +69,7 @@ function makeContext(
69
69
  return {
70
70
  conversationId,
71
71
  sendToClient: (msg: AssistantEvent) => sent.push(msg),
72
+ emit: (msg: AssistantEvent) => sent.push(msg),
72
73
  pendingSurfaceActions: new Map<string, { surfaceType: SurfaceType }>(),
73
74
  lastSurfaceAction: new Map<
74
75
  string,
@@ -97,6 +97,7 @@ function buildMockContext(
97
97
  } as Conversation["currentTurnAuthContext"])
98
98
  : undefined,
99
99
  sendToClient: () => {},
100
+ emit: () => {},
100
101
  pendingSurfaceActions: new Map(),
101
102
  lastSurfaceAction: new Map(),
102
103
  surfaceState: new Map(),
@@ -29,7 +29,7 @@ function makeContext(
29
29
  channelCapabilities:
30
30
  options.channelCapabilities &&
31
31
  mockChannelCapabilities(options.channelCapabilities),
32
- sendToClient: (msg) => sent.push(msg),
32
+ emit: (msg) => sent.push(msg),
33
33
  pendingSurfaceActions: new Map<string, { surfaceType: SurfaceType }>(),
34
34
  lastSurfaceAction: new Map<
35
35
  string,
@@ -57,7 +57,7 @@ import { asConversation } from "./helpers/mock-conversation.js";
57
57
  function makeContext(sent: AssistantEvent[] = []): Conversation {
58
58
  return asConversation({
59
59
  conversationId: "conv-persist-1",
60
- sendToClient: (msg) => sent.push(msg),
60
+ emit: (msg) => sent.push(msg),
61
61
  pendingSurfaceActions: new Map<string, { surfaceType: SurfaceType }>(),
62
62
  lastSurfaceAction: new Map<
63
63
  string,
@@ -124,13 +124,17 @@ type EnqueueResult = { queued: boolean; requestId: string; rejected?: boolean };
124
124
 
125
125
  function makeContext(
126
126
  enqueueResult: EnqueueResult = { queued: false, requestId: "req-1" },
127
- overrides: { trustContext?: TrustContext; queueDepth?: number } = {},
127
+ overrides: {
128
+ trustContext?: TrustContext;
129
+ queueDepth?: number;
130
+ emit?: (msg: AssistantEvent) => void;
131
+ } = {},
128
132
  ): Conversation & { enqueuedContents: string[] } {
129
133
  const enqueuedContents: string[] = [];
130
134
  return asConversation({
131
135
  conversationId: CONVERSATION_ID,
132
136
  trustContext: overrides.trustContext ?? GUARDIAN_TRUST,
133
- sendToClient: () => {},
137
+ emit: overrides.emit ?? (() => {}),
134
138
  pendingSurfaceActions: new Map<string, { surfaceType: SurfaceType }>(),
135
139
  lastSurfaceAction: new Map<
136
140
  string,
@@ -885,11 +889,12 @@ describe("surface completion when the persisted write does not land", () => {
885
889
  test("a ui_dismiss completion whose write throws stays retryable", async () => {
886
890
  const surfaceId = "surface-dismiss-persist-throws-1";
887
891
  seedSurfaceRow(surfaceId, "choice");
888
- const ctx = makeContext();
889
892
  const sentToClient: AssistantEvent[] = [];
890
- ctx.sendToClient = (msg) => {
891
- sentToClient.push(msg);
892
- };
893
+ const ctx = makeContext(undefined, {
894
+ emit: (msg) => {
895
+ sentToClient.push(msg);
896
+ },
897
+ });
893
898
  seedAnsweredSurface(ctx, surfaceId);
894
899
  persistError = new Error("database is locked");
895
900
 
@@ -919,11 +924,12 @@ describe("surface completion when the persisted write does not land", () => {
919
924
  test("a ui_dismiss completion that lands clears the live state", async () => {
920
925
  const surfaceId = "surface-dismiss-persist-lands-1";
921
926
  seedSurfaceRow(surfaceId, "choice");
922
- const ctx = makeContext();
923
927
  const sentToClient: AssistantEvent[] = [];
924
- ctx.sendToClient = (msg) => {
925
- sentToClient.push(msg);
926
- };
928
+ const ctx = makeContext(undefined, {
929
+ emit: (msg) => {
930
+ sentToClient.push(msg);
931
+ },
932
+ });
927
933
  seedAnsweredSurface(ctx, surfaceId);
928
934
 
929
935
  const result = await surfaceProxyResolver(ctx, "ui_dismiss", {
@@ -942,11 +948,12 @@ describe("surface completion when the persisted write does not land", () => {
942
948
  test("a ui_dismiss with no recorded action clears the live state even when the write throws", async () => {
943
949
  const surfaceId = "surface-dismiss-no-action-1";
944
950
  seedSurfaceRow(surfaceId, "card");
945
- const ctx = makeContext();
946
951
  const sentToClient: AssistantEvent[] = [];
947
- ctx.sendToClient = (msg) => {
948
- sentToClient.push(msg);
949
- };
952
+ const ctx = makeContext(undefined, {
953
+ emit: (msg) => {
954
+ sentToClient.push(msg);
955
+ },
956
+ });
950
957
  ctx.pendingSurfaceActions.set(surfaceId, { surfaceType: "card" });
951
958
  ctx.surfaceState.set(surfaceId, { surfaceType: "card", data: {} });
952
959
  persistError = new Error("database is locked");
@@ -22,6 +22,7 @@ function makeQueuedContext(): Conversation {
22
22
  return asConversation({
23
23
  conversationId: CONV_ID,
24
24
  sendToClient: () => {},
25
+ emit: () => {},
25
26
  pendingSurfaceActions: new Map<string, { surfaceType: SurfaceType }>(),
26
27
  lastSurfaceAction: new Map<
27
28
  string,
@@ -65,6 +65,7 @@ function createMockContext(
65
65
  })
66
66
  : undefined,
67
67
  sendToClient: (msg: AssistantEvent) => sentMessages.push(msg),
68
+ emit: (msg: AssistantEvent) => sentMessages.push(msg),
68
69
  pendingSurfaceActions: new Map(),
69
70
  lastSurfaceAction: new Map(),
70
71
  surfaceState: new Map(),
@@ -53,6 +53,7 @@ function createMockContext(
53
53
  })
54
54
  : undefined,
55
55
  sendToClient: (msg: AssistantEvent) => sentMessages.push(msg),
56
+ emit: (msg: AssistantEvent) => sentMessages.push(msg),
56
57
  pendingSurfaceActions: new Map(),
57
58
  lastSurfaceAction: new Map(),
58
59
  surfaceState: new Map(),
@@ -26,7 +26,7 @@ function makeContext(opts?: { sent?: AssistantEvent[] }): Conversation & {
26
26
 
27
27
  return asConversation({
28
28
  conversationId: "test-session",
29
- sendToClient: (msg) => sent.push(msg),
29
+ emit: (msg) => sent.push(msg),
30
30
  pendingSurfaceActions: new Map<string, { surfaceType: SurfaceType }>(),
31
31
  lastSurfaceAction: new Map<
32
32
  string,
@@ -49,7 +49,7 @@ function makeContext(): Conversation & {
49
49
 
50
50
  return asConversation({
51
51
  conversationId: "test-convo",
52
- sendToClient: (msg) => sentMessages.push(msg),
52
+ emit: (msg) => sentMessages.push(msg),
53
53
  pendingSurfaceActions: new Map<string, { surfaceType: SurfaceType }>(),
54
54
  lastSurfaceAction: new Map<
55
55
  string,
@@ -26,7 +26,7 @@ function makeContext(
26
26
  conversationId: "session-1",
27
27
  channelCapabilities:
28
28
  channelCapabilities && mockChannelCapabilities(channelCapabilities),
29
- sendToClient: (msg) => sent.push(msg),
29
+ emit: (msg) => sent.push(msg),
30
30
  pendingSurfaceActions: new Map<string, { surfaceType: SurfaceType }>(),
31
31
  lastSurfaceAction: new Map<
32
32
  string,
@@ -94,7 +94,7 @@ function makeCtx(overrides: Partial<Conversation> = {}): Conversation {
94
94
  workingDir: "/tmp/test",
95
95
  getTurnActorPrincipalId: () => undefined,
96
96
  abortController: null,
97
- sendToClient: mock(() => {}),
97
+ emit: mock(() => {}),
98
98
  pendingSurfaceActions: new Map(),
99
99
  lastSurfaceAction: new Map(),
100
100
  surfaceState: new Map(),
@@ -101,7 +101,7 @@ function makeCtx(overrides: Partial<Conversation> = {}): Conversation {
101
101
  workingDir: "/tmp/test",
102
102
  getTurnActorPrincipalId: () => undefined,
103
103
  abortController: null,
104
- sendToClient: mock(() => {}),
104
+ emit: mock(() => {}),
105
105
  pendingSurfaceActions: new Map(),
106
106
  lastSurfaceAction: new Map(),
107
107
  surfaceState: new Map(),
@@ -97,6 +97,7 @@ function buildMockContext(
97
97
  } as Conversation["currentTurnAuthContext"])
98
98
  : undefined,
99
99
  sendToClient: () => {},
100
+ emit: () => {},
100
101
  pendingSurfaceActions: new Map(),
101
102
  lastSurfaceAction: new Map(),
102
103
  surfaceState: new Map(),
@@ -3,8 +3,6 @@
3
3
  * the Library and the per-conversation assets list converge on every surface.
4
4
  * Read routes and rejected writes must stay silent.
5
5
  */
6
- import { mkdirSync, rmSync, writeFileSync } from "node:fs";
7
- import { join } from "node:path";
8
6
  import { afterAll, beforeEach, describe, expect, test } from "bun:test";
9
7
 
10
8
  import type { AssistantEventEnvelope } from "../api/index.js";
@@ -24,9 +22,6 @@ import { resetDbForTesting } from "./db-test-helpers.js";
24
22
 
25
23
  await initializeDb();
26
24
 
27
- const workspaceDir = process.env.VELLUM_WORKSPACE_DIR!;
28
- const notesDir = join(workspaceDir, "sync-notes");
29
-
30
25
  const CONVERSATION_ID = "conv-doc-sync";
31
26
  const OTHER_CONVERSATION_ID = "conv-doc-sync-other";
32
27
  const MISSING_CONVERSATION_ID = "conv-doc-sync-missing";
@@ -110,15 +105,6 @@ async function saveViaRoute(
110
105
  });
111
106
  }
112
107
 
113
- async function openWorkspaceFile(
114
- path: string,
115
- conversationId = CONVERSATION_ID,
116
- ): Promise<unknown> {
117
- return await invoke("documentForWorkspaceFile", {
118
- body: { path, conversationId },
119
- });
120
- }
121
-
122
108
  async function linkViaRoute(
123
109
  surfaceId: string,
124
110
  conversationId: string,
@@ -136,12 +122,9 @@ beforeEach(() => {
136
122
  db.run("DELETE FROM conversations");
137
123
  createConversation({ id: CONVERSATION_ID });
138
124
  createConversation({ id: OTHER_CONVERSATION_ID });
139
- rmSync(notesDir, { recursive: true, force: true });
140
- mkdirSync(notesDir, { recursive: true });
141
125
  });
142
126
 
143
127
  afterAll(() => {
144
- rmSync(notesDir, { recursive: true, force: true });
145
128
  resetDbForTesting();
146
129
  });
147
130
 
@@ -220,67 +203,9 @@ describe("document write routes publish documents:list", () => {
220
203
  expect(events).toHaveLength(1);
221
204
  expect("originClientId" in events[0].message).toBe(false);
222
205
  });
223
-
224
- test("binding a workspace file publishes without an origin so the caller is not suppressed", async () => {
225
- // `viewer-store.loadWorkspaceFileDocument` creates the row and invalidates
226
- // nothing, so the client that opened the file is the one that most needs
227
- // the invalidation.
228
- writeFileSync(join(notesDir, "plan.md"), "# Plan");
229
-
230
- const events = await captureSyncEvents(() =>
231
- invoke("documentForWorkspaceFile", {
232
- body: { path: "sync-notes/plan.md", conversationId: CONVERSATION_ID },
233
- headers: { "x-vellum-client-id": "client-abc" },
234
- }),
235
- );
236
-
237
- expect(events).toHaveLength(1);
238
- expect("originClientId" in events[0].message).toBe(false);
239
- });
240
-
241
- test("binding a workspace file to a new document publishes", async () => {
242
- writeFileSync(join(notesDir, "plan.md"), "# Plan");
243
-
244
- const events = await captureSyncEvents(() =>
245
- openWorkspaceFile("sync-notes/plan.md"),
246
- );
247
- expectDocumentsChanged(events);
248
- });
249
-
250
- test("reopening the file from another conversation publishes the new link", async () => {
251
- writeFileSync(join(notesDir, "plan.md"), "# Plan");
252
- await openWorkspaceFile("sync-notes/plan.md");
253
-
254
- const events = await captureSyncEvents(() =>
255
- openWorkspaceFile("sync-notes/plan.md", OTHER_CONVERSATION_ID),
256
- );
257
- expectDocumentsChanged(events);
258
- });
259
-
260
- test("refreshing a document whose file changed on disk publishes", async () => {
261
- const filePath = join(notesDir, "plan.md");
262
- writeFileSync(filePath, "# Plan");
263
- await openWorkspaceFile("sync-notes/plan.md");
264
- writeFileSync(filePath, "# Plan\n\nedited outside the editor");
265
-
266
- const events = await captureSyncEvents(() =>
267
- openWorkspaceFile("sync-notes/plan.md"),
268
- );
269
- expectDocumentsChanged(events);
270
- });
271
206
  });
272
207
 
273
208
  describe("document routes that change nothing the list shows stay silent", () => {
274
- test("reopening an unchanged, already-linked file publishes nothing", async () => {
275
- writeFileSync(join(notesDir, "plan.md"), "# Plan");
276
- await openWorkspaceFile("sync-notes/plan.md");
277
-
278
- const events = await captureSyncEvents(() =>
279
- openWorkspaceFile("sync-notes/plan.md"),
280
- );
281
- expect(events).toEqual([]);
282
- });
283
-
284
209
  test("a save rejected by validation publishes nothing", async () => {
285
210
  const events = await captureSyncEvents(async () => {
286
211
  await expect(saveViaRoute({ title: undefined })).rejects.toThrow(
@@ -10,10 +10,11 @@ import { tmpdir } from "node:os";
10
10
  import { join } from "node:path";
11
11
  import { afterEach, describe, expect, test } from "bun:test";
12
12
 
13
+ import { THRESHOLD_CHARS } from "../context/post-turn-tool-result-truncation.js";
13
14
  import {
14
- DEFAULT_READ_LINE_LIMIT,
15
15
  FileSystemOps,
16
16
  type PathPolicy,
17
+ READ_CHAR_BUDGET,
17
18
  } from "../tools/shared/filesystem/file-ops-service.js";
18
19
  import { sandboxPolicy } from "../tools/shared/filesystem/path-policy.js";
19
20
 
@@ -111,34 +112,28 @@ describe("FileSystemOps.readFileSafe", () => {
111
112
  expect(result.error.code).toBe("PATH_OUT_OF_BOUNDS");
112
113
  });
113
114
 
114
- test("respects offset and limit", async () => {
115
+ test("respects startIndex and maxChars", async () => {
115
116
  const dir = makeTempDir();
116
117
  writeFileSync(join(dir, "lines.txt"), "a\nb\nc\nd\ne\n");
117
118
  const ops = new FileSystemOps(sandboxPolicyFor(dir));
118
119
 
119
120
  const result = await ops.readFileSafe({
120
121
  path: "lines.txt",
121
- offset: 2,
122
- limit: 2,
122
+ startIndex: 2,
123
+ maxChars: 3,
123
124
  });
124
125
  expect(result.ok).toBe(true);
125
126
  if (!result.ok) {
126
127
  return;
127
128
  }
128
- // Assert on the numbered lines rather than bare letters: the truncation
129
- // notice is prose, so a bare `toContain("d")` matches its wording.
130
129
  const [body] = result.value.content.split("\n\n[Truncated:");
131
- expect(body).toContain(" 2 b");
132
- expect(body).toContain(" 3 c");
133
- expect(body).not.toContain(" 1 a");
134
- expect(body).not.toContain(" 4 d");
130
+ expect(body).toBe("b\nc");
135
131
  });
136
132
 
137
- test("caps an unbounded read at the default line limit and says so", async () => {
133
+ test("caps an unbounded read at the character budget and says so", async () => {
138
134
  const dir = makeTempDir();
139
- const total = DEFAULT_READ_LINE_LIMIT + 500;
140
- const lines = Array.from({ length: total }, (_, i) => `line${i + 1}`);
141
- writeFileSync(join(dir, "big.txt"), lines.join("\n"));
135
+ const total = READ_CHAR_BUDGET + 500;
136
+ writeFileSync(join(dir, "big.txt"), "x".repeat(total));
142
137
  const ops = new FileSystemOps(sandboxPolicyFor(dir));
143
138
 
144
139
  const result = await ops.readFileSafe({ path: "big.txt" });
@@ -146,36 +141,74 @@ describe("FileSystemOps.readFileSafe", () => {
146
141
  if (!result.ok) {
147
142
  return;
148
143
  }
149
- const content = result.value.content;
150
- expect(content).toContain(` ${DEFAULT_READ_LINE_LIMIT} line2000`);
151
- expect(content).not.toContain("line2001");
152
- expect(content).toContain(
153
- `[Truncated: showing through line ${DEFAULT_READ_LINE_LIMIT} of ${total}. Read on with offset=${DEFAULT_READ_LINE_LIMIT + 1}`,
144
+ const [body] = result.value.content.split("\n\n[Truncated:");
145
+ expect(body).toHaveLength(READ_CHAR_BUDGET);
146
+ expect(result.value.content).toContain(
147
+ `[Truncated: characters 0-${READ_CHAR_BUDGET} of ${total}. Read on with start_index=${READ_CHAR_BUDGET}.]`,
154
148
  );
155
149
  });
156
150
 
157
- test("an explicit limit is honored rather than replaced by the default", async () => {
151
+ test("a maxChars above the budget is clamped to it", async () => {
158
152
  const dir = makeTempDir();
159
- const lines = Array.from(
160
- { length: DEFAULT_READ_LINE_LIMIT + 500 },
161
- (_, i) => `line${i + 1}`,
162
- );
163
- writeFileSync(join(dir, "big.txt"), lines.join("\n"));
153
+ const total = READ_CHAR_BUDGET + 500;
154
+ writeFileSync(join(dir, "big.txt"), "x".repeat(total));
164
155
  const ops = new FileSystemOps(sandboxPolicyFor(dir));
165
156
 
166
157
  const result = await ops.readFileSafe({
167
158
  path: "big.txt",
168
- limit: DEFAULT_READ_LINE_LIMIT + 500,
159
+ maxChars: total,
169
160
  });
170
161
  expect(result.ok).toBe(true);
171
162
  if (!result.ok) {
172
163
  return;
173
164
  }
174
- expect(result.value.content).toContain("line2500");
175
- expect(result.value.content).not.toContain("[Truncated:");
165
+ const [body] = result.value.content.split("\n\n[Truncated:");
166
+ expect(body).toHaveLength(READ_CHAR_BUDGET);
167
+ });
168
+
169
+ test("a maxChars below the budget is honored as given", async () => {
170
+ const dir = makeTempDir();
171
+ writeFileSync(join(dir, "big.txt"), "x".repeat(500));
172
+ const ops = new FileSystemOps(sandboxPolicyFor(dir));
173
+
174
+ const result = await ops.readFileSafe({ path: "big.txt", maxChars: 10 });
175
+ expect(result.ok).toBe(true);
176
+ if (!result.ok) {
177
+ return;
178
+ }
179
+ const [body] = result.value.content.split("\n\n[Truncated:");
180
+ expect(body).toHaveLength(10);
181
+ expect(result.value.content).toContain(
182
+ "[Truncated: characters 0-10 of 500. Read on with start_index=10.]",
183
+ );
184
+ });
185
+
186
+ test("paging with the offset from a notice returns the next window", async () => {
187
+ const dir = makeTempDir();
188
+ writeFileSync(join(dir, "big.txt"), "abcdefghij");
189
+ const ops = new FileSystemOps(sandboxPolicyFor(dir));
190
+
191
+ const first = await ops.readFileSafe({ path: "big.txt", maxChars: 4 });
192
+ expect(first.ok).toBe(true);
193
+ if (!first.ok) {
194
+ return;
195
+ }
196
+ expect(first.value.content).toContain("start_index=4");
197
+
198
+ const second = await ops.readFileSafe({
199
+ path: "big.txt",
200
+ startIndex: 4,
201
+ maxChars: 4,
202
+ });
203
+ expect(second.ok).toBe(true);
204
+ if (!second.ok) {
205
+ return;
206
+ }
207
+ const [body] = second.value.content.split("\n\n[Truncated:");
208
+ expect(body).toBe("efgh");
176
209
  });
177
210
 
178
- test("a read that reaches the last line carries no truncation notice", async () => {
211
+ test("a read that reaches the end carries no truncation notice", async () => {
179
212
  const dir = makeTempDir();
180
213
  writeFileSync(join(dir, "small.txt"), "a\nb\nc");
181
214
  const ops = new FileSystemOps(sandboxPolicyFor(dir));
@@ -193,13 +226,113 @@ describe("FileSystemOps.readFileSafe", () => {
193
226
  writeFileSync(join(dir, "small.txt"), "a\nb\nc");
194
227
  const ops = new FileSystemOps(sandboxPolicyFor(dir));
195
228
 
196
- const result = await ops.readFileSafe({ path: "small.txt", offset: 100 });
229
+ const result = await ops.readFileSafe({
230
+ path: "small.txt",
231
+ startIndex: 100,
232
+ });
197
233
  expect(result.ok).toBe(true);
198
234
  if (!result.ok) {
199
235
  return;
200
236
  }
201
237
  expect(result.value.content).not.toContain("[Truncated:");
202
238
  });
239
+
240
+ test("a window boundary inside a surrogate pair does not corrupt it", async () => {
241
+ const dir = makeTempDir();
242
+ // "ab" + a 2-unit emoji + "cd": a maxChars of 3 would split the pair.
243
+ writeFileSync(join(dir, "emoji.txt"), `ab\u{1F600}cd`);
244
+ const ops = new FileSystemOps(sandboxPolicyFor(dir));
245
+
246
+ const result = await ops.readFileSafe({ path: "emoji.txt", maxChars: 3 });
247
+ expect(result.ok).toBe(true);
248
+ if (!result.ok) {
249
+ return;
250
+ }
251
+ const [body] = result.value.content.split("\n\n[Truncated:");
252
+ expect(body).toBe("ab");
253
+ expect(body).not.toContain("\uFFFD");
254
+ });
255
+
256
+ test("paging across a surrogate pair reassembles the whole file", async () => {
257
+ const dir = makeTempDir();
258
+ const original = `ab\u{1F600}cd`;
259
+ writeFileSync(join(dir, "emoji.txt"), original);
260
+ const ops = new FileSystemOps(sandboxPolicyFor(dir));
261
+
262
+ let assembled = "";
263
+ let startIndex = 0;
264
+ for (let i = 0; i < 10; i++) {
265
+ const result = await ops.readFileSafe({
266
+ path: "emoji.txt",
267
+ startIndex,
268
+ maxChars: 3,
269
+ });
270
+ expect(result.ok).toBe(true);
271
+ if (!result.ok) {
272
+ return;
273
+ }
274
+ const [body] = result.value.content.split("\n\n[Truncated:");
275
+ assembled += body;
276
+ const match = /start_index=(\d+)/.exec(result.value.content);
277
+ if (match?.[1] === undefined) {
278
+ break;
279
+ }
280
+ startIndex = Number(match[1]);
281
+ }
282
+ expect(assembled).toBe(original);
283
+ });
284
+
285
+ test("a lone-low-surrogate startIndex backs up onto the whole pair", async () => {
286
+ const dir = makeTempDir();
287
+ writeFileSync(join(dir, "emoji.txt"), `ab\u{1F600}cd`);
288
+ const ops = new FileSystemOps(sandboxPolicyFor(dir));
289
+
290
+ // Index 3 (0-indexed) is the low half of the emoji.
291
+ const result = await ops.readFileSafe({
292
+ path: "emoji.txt",
293
+ startIndex: 3,
294
+ maxChars: 2,
295
+ });
296
+ expect(result.ok).toBe(true);
297
+ if (!result.ok) {
298
+ return;
299
+ }
300
+ const [body] = result.value.content.split("\n\n[Truncated:");
301
+ expect(body).toBe(`\u{1F600}`);
302
+ expect(body).not.toContain("\uFFFD");
303
+ });
304
+
305
+ test("legacy offset/limit are rejected rather than silently ignored", async () => {
306
+ const { fileReadTool } = await import("../tools/filesystem/read.js");
307
+ const dir = makeTempDir();
308
+ writeFileSync(join(dir, "big.txt"), "x".repeat(500));
309
+
310
+ const result = await fileReadTool.execute(
311
+ { path: join(dir, "big.txt"), offset: 2001, limit: 2000 },
312
+ { workingDir: dir } as never,
313
+ );
314
+ expect(result.isError).toBe(true);
315
+ expect(result.content).toContain("no longer takes");
316
+ expect(result.content).toContain("start_index");
317
+ });
318
+
319
+ test("legacy fields alongside current ones do not trigger the guard", async () => {
320
+ const { fileReadTool } = await import("../tools/filesystem/read.js");
321
+ const dir = makeTempDir();
322
+ writeFileSync(join(dir, "big.txt"), "abcdefghij");
323
+
324
+ const result = await fileReadTool.execute(
325
+ { path: join(dir, "big.txt"), offset: 1, max_chars: 4 },
326
+ { workingDir: dir } as never,
327
+ );
328
+ expect(result.isError).toBe(false);
329
+ const [body] = result.content.split("\n\n[Truncated:");
330
+ expect(body).toBe("abcd");
331
+ });
332
+
333
+ test("the read budget stays under the tool-result spool threshold", () => {
334
+ expect(READ_CHAR_BUDGET).toBeLessThan(THRESHOLD_CHARS);
335
+ });
203
336
  });
204
337
 
205
338
  // ---------------------------------------------------------------------------