@vellumai/assistant 0.11.2-dev.202608051734.355dff7 → 0.11.2-dev.202608051933.deffc0f

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 (60) hide show
  1. package/ARCHITECTURE.md +4 -2
  2. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/ingress.ts +10 -0
  3. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/ingress.ts +10 -0
  4. package/node_modules/@vellumai/service-contracts/src/ingress.ts +10 -0
  5. package/openapi.yaml +22 -0
  6. package/package.json +1 -1
  7. package/src/__tests__/agent-loop-override-profile.test.ts +38 -48
  8. package/src/__tests__/assistant-feature-flags-integration.test.ts +0 -10
  9. package/src/__tests__/conversation-tool-setup-app-refresh.test.ts +1 -0
  10. package/src/__tests__/conversation-tool-setup-attribution.test.ts +61 -0
  11. package/src/__tests__/cu-unified-flow.test.ts +2 -6
  12. package/src/__tests__/file-ops-service.test.ts +75 -4
  13. package/src/__tests__/host-bash-proxy.test.ts +10 -5
  14. package/src/__tests__/host-browser-proxy.test.ts +2 -2
  15. package/src/__tests__/host-cu-proxy.test.ts +3 -7
  16. package/src/__tests__/host-file-proxy.test.ts +3 -7
  17. package/src/__tests__/host-file-read-tool.test.ts +5 -1
  18. package/src/__tests__/host-transfer-proxy-targeted.test.ts +6 -4
  19. package/src/__tests__/llm-resolver-override-or-default.test.ts +141 -0
  20. package/src/__tests__/media-stream-server-integration.test.ts +36 -0
  21. package/src/__tests__/stt-language-catalog-parity.test.ts +38 -0
  22. package/src/__tests__/subagent-tool-gate-mode.test.ts +1 -0
  23. package/src/__tests__/subagent-tools.test.ts +48 -92
  24. package/src/__tests__/tool-result-spool.test.ts +74 -18
  25. package/src/agent/loop.ts +6 -3
  26. package/src/calls/media-stream-server.ts +9 -1
  27. package/src/config/bundled-skills/settings/TOOLS.json +2 -2
  28. package/src/config/bundled-skills/settings/tools/voice-config-update.ts +1 -0
  29. package/src/config/bundled-skills/subagent/SKILL.md +3 -3
  30. package/src/config/bundled-skills/subagent/TOOLS.json +1 -1
  31. package/src/config/feature-flag-registry.json +0 -16
  32. package/src/config/llm-resolver.ts +41 -12
  33. package/src/config/schemas/stt.ts +10 -4
  34. package/src/context/post-turn-tool-result-truncation.ts +32 -16
  35. package/src/context/tool-result-spool.ts +32 -18
  36. package/src/daemon/conversation-tool-setup.ts +1 -1
  37. package/src/persistence/conversation-attention-store.ts +30 -10
  38. package/src/persistence/conversation-queries.ts +74 -1
  39. package/src/platform/managed-speech.test.ts +20 -0
  40. package/src/platform/managed-speech.ts +9 -0
  41. package/src/providers/speech-to-text/__tests__/resolve.test.ts +164 -6
  42. package/src/providers/speech-to-text/deepgram.ts +1 -0
  43. package/src/providers/speech-to-text/resolve.ts +80 -10
  44. package/src/providers/speech-to-text/vellum-managed.ts +7 -1
  45. package/src/providers/speech-to-text/vellum-speech-relay-connection.ts +3 -1
  46. package/src/runtime/auth/__tests__/guard-tests.test.ts +2 -0
  47. package/src/runtime/auth/__tests__/scopes.test.ts +20 -0
  48. package/src/runtime/auth/same-actor.ts +38 -7
  49. package/src/runtime/auth/scopes.ts +12 -0
  50. package/src/runtime/auth/types.ts +2 -0
  51. package/src/runtime/routes/__tests__/conversation-list-routes.test.ts +279 -1
  52. package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +14 -10
  53. package/src/runtime/routes/conversation-list-routes.ts +25 -0
  54. package/src/runtime/routes/conversation-query-routes.ts +0 -50
  55. package/src/stt/daemon-batch-transcriber.ts +8 -6
  56. package/src/tools/filesystem/read.ts +2 -2
  57. package/src/tools/host-filesystem/read.ts +14 -6
  58. package/src/tools/shared/filesystem/file-ops-service.ts +36 -3
  59. package/src/tools/subagent/spawn.ts +26 -78
  60. package/src/tools/types.ts +5 -3
package/ARCHITECTURE.md CHANGED
@@ -67,8 +67,10 @@ All HTTP API requests use a single `Authorization: Bearer <jwt>` header for auth
67
67
  | -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------- |
68
68
  | `actor_client_v1` | `chat.{read,write}`, `approval.{read,write}`, `settings.{read,write}`, `attachments.{read,write}`, `calls.{read,write}`, `feature_flags.{read,write}` | Desktop, CLI clients |
69
69
  | `gateway_ingress_v1` | `ingress.write`, `internal.write` | Gateway channel inbound + webhook forwarding |
70
- | `gateway_service_v1` | `settings.read`, `settings.write`, `internal.write` | Gateway service-to-daemon calls |
71
- | `internal_v1` | `internal.all` | Internal service connections |
70
+ | `gateway_service_v1` | `chat.{read,write}`, `settings.{read,write}`, `attachments.{read,write}`, `internal.write` | Gateway service-to-daemon calls |
71
+ | `local_v1` | `local.all` | Local (loopback) conversation sessions |
72
+ | `speech_relay_v1` | `speech.relay` | Daemon dial of the gateway speech relay only |
73
+ | `ui_page_v1` | `settings.read` | Served UI pages |
72
74
 
73
75
  **Identity lifecycle:**
74
76
 
@@ -20,6 +20,16 @@ export function normalizePublicBaseUrl(value: unknown): string | undefined {
20
20
  return normalized.length > 0 ? normalized : undefined;
21
21
  }
22
22
 
23
+ /**
24
+ * Application close code the gateway's velay bridge sends to proxied
25
+ * WebSockets when the tunnel itself is lost (velay disconnect, gateway
26
+ * shutdown). A dedicated code because the natural 1001 (going away) cannot
27
+ * be sent through the JS `close()` API (the bridge would remap it to a
28
+ * misleading 4001), and Bun's WebSocket client drops close reasons, so the
29
+ * code is the only signal that survives the relay to the daemon.
30
+ */
31
+ export const GATEWAY_TUNNEL_LOST_WS_CLOSE_CODE = 4801;
32
+
23
33
  export function normalizeHttpPublicBaseUrl(value: unknown): string | undefined {
24
34
  if (typeof value !== "string") return undefined;
25
35
  const trimmed = value.trim();
@@ -20,6 +20,16 @@ export function normalizePublicBaseUrl(value: unknown): string | undefined {
20
20
  return normalized.length > 0 ? normalized : undefined;
21
21
  }
22
22
 
23
+ /**
24
+ * Application close code the gateway's velay bridge sends to proxied
25
+ * WebSockets when the tunnel itself is lost (velay disconnect, gateway
26
+ * shutdown). A dedicated code because the natural 1001 (going away) cannot
27
+ * be sent through the JS `close()` API (the bridge would remap it to a
28
+ * misleading 4001), and Bun's WebSocket client drops close reasons, so the
29
+ * code is the only signal that survives the relay to the daemon.
30
+ */
31
+ export const GATEWAY_TUNNEL_LOST_WS_CLOSE_CODE = 4801;
32
+
23
33
  export function normalizeHttpPublicBaseUrl(value: unknown): string | undefined {
24
34
  if (typeof value !== "string") return undefined;
25
35
  const trimmed = value.trim();
@@ -20,6 +20,16 @@ export function normalizePublicBaseUrl(value: unknown): string | undefined {
20
20
  return normalized.length > 0 ? normalized : undefined;
21
21
  }
22
22
 
23
+ /**
24
+ * Application close code the gateway's velay bridge sends to proxied
25
+ * WebSockets when the tunnel itself is lost (velay disconnect, gateway
26
+ * shutdown). A dedicated code because the natural 1001 (going away) cannot
27
+ * be sent through the JS `close()` API (the bridge would remap it to a
28
+ * misleading 4001), and Bun's WebSocket client drops close reasons, so the
29
+ * code is the only signal that survives the relay to the daemon.
30
+ */
31
+ export const GATEWAY_TUNNEL_LOST_WS_CLOSE_CODE = 4801;
32
+
23
33
  export function normalizeHttpPublicBaseUrl(value: unknown): string | undefined {
24
34
  if (typeof value !== "string") return undefined;
25
35
  const trimmed = value.trim();
package/openapi.yaml CHANGED
@@ -9565,6 +9565,28 @@ paths:
9565
9565
  required:
9566
9566
  - ok
9567
9567
  additionalProperties: false
9568
+ /v1/conversations/unread-count:
9569
+ get:
9570
+ operationId: conversations_unreadcount_get
9571
+ summary: Unread conversation count
9572
+ description:
9573
+ "Count of foreground conversations whose latest assistant message is unseen. Matches the sidebar's unread
9574
+ indicators: archived rows and non-surfaced background/scheduled rows are excluded."
9575
+ tags:
9576
+ - conversations
9577
+ responses:
9578
+ "200":
9579
+ description: Successful response
9580
+ content:
9581
+ application/json:
9582
+ schema:
9583
+ type: object
9584
+ properties:
9585
+ count:
9586
+ type: number
9587
+ required:
9588
+ - count
9589
+ additionalProperties: false
9568
9590
  /v1/conversations/wake:
9569
9591
  post:
9570
9592
  operationId: conversations_wake_post
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.11.2-dev.202608051734.355dff7",
3
+ "version": "0.11.2-dev.202608051933.deffc0f",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,16 +1,17 @@
1
1
  /**
2
2
  * Verifies that the per-turn `overrideProfile` plumbed into `AgentLoop.run()`
3
3
  * surfaces on every `SendMessageOptions.config` the loop emits, and that
4
- * `SubagentManager.spawn()` propagates an inherited `overrideProfile` from
5
- * its `SubagentConfig` into the subagent's `runAgentLoop()` call.
4
+ * `SubagentManager.spawn()` propagates an `overrideProfile` set on its
5
+ * `SubagentConfig` into the subagent's `runAgentLoop()` call.
6
6
  *
7
- * Together these two assertions establish the PR 6 contract: a parent
8
- * conversation that pins an inference profile sees that profile applied to
9
- * every LLM call within its agent-loop turn, and any subagent spawned during
10
- * that turn inherits the same profile automatically.
7
+ * Together these establish where a pinned profile does and does not travel: it
8
+ * applies to every LLM call within the pinning conversation's own turn, and it
9
+ * stops at the spawn boundary. `executeSubagentSpawn` sets `overrideProfile`
10
+ * on the `SubagentConfig` only for a profile named explicitly on the spawn, so
11
+ * the propagation above carries a caller's choice rather than a parent's pin.
11
12
  *
12
- * Default behavior (no `overrideProfile` set) must remain unchanged — the
13
- * field is omitted from `providerConfig` rather than carrying `undefined`.
13
+ * With no `overrideProfile` set, the field is omitted from `providerConfig`
14
+ * rather than carrying `undefined`.
14
15
  */
15
16
 
16
17
  import { describe, expect, mock, test } from "bun:test";
@@ -183,7 +184,7 @@ describe("AgentLoop.run — overrideProfile plumbing", () => {
183
184
  });
184
185
  });
185
186
 
186
- // ── Subagent inheritance ─────────────────────────────────────────────────
187
+ // ── Subagent profile forwarding ──────────────────────────────────────────
187
188
 
188
189
  // Capture the SubagentManager → Conversation handshake so we can verify the
189
190
  // `overrideProfile` from `SubagentConfig` is forwarded into the spawned
@@ -329,7 +330,7 @@ seedLlmConfig();
329
330
 
330
331
  import { SubagentManager } from "../subagent/manager.js";
331
332
 
332
- describe("SubagentManager.spawn — overrideProfile inheritance", () => {
333
+ describe("SubagentManager.spawn: forwards a configured overrideProfile", () => {
333
334
  test("forwards overrideProfile from SubagentConfig into runAgentLoop", async () => {
334
335
  capturedRunAgentLoopOptions.length = 0;
335
336
 
@@ -344,9 +345,9 @@ describe("SubagentManager.spawn — overrideProfile inheritance", () => {
344
345
  () => {},
345
346
  );
346
347
 
347
- // The spawned subagent's runAgentLoop must receive both the
348
- // `subagentSpawn` callSite (existing behavior) and the inherited
349
- // `overrideProfile` (new behavior).
348
+ // The spawned subagent's runAgentLoop receives the `subagentSpawn`
349
+ // callSite and whatever `overrideProfile` the config carries, which
350
+ // `executeSubagentSpawn` sets only for an explicitly named profile.
350
351
  expect(capturedRunAgentLoopOptions).toHaveLength(1);
351
352
  const captured = capturedRunAgentLoopOptions[0];
352
353
  expect(captured.callSite).toBe("subagentSpawn");
@@ -399,21 +400,18 @@ describe("SubagentManager.spawn — overrideProfile inheritance", () => {
399
400
  });
400
401
  });
401
402
 
402
- // ── Nested subagent spawn — context.overrideProfile preferred ────────────
403
+ // ── Nested subagent spawn: no profile crosses the boundary ───────────────
403
404
 
404
- // Verify the third-level inheritance contract: when a subagent's agent loop
405
- // is running with `currentTurnOverrideProfile`, the executor closure plumbs
406
- // that value into `ToolContext.overrideProfile`. `executeSubagentSpawn` must
407
- // then prefer `context.overrideProfile` over a row read against the in-flight
408
- // subagent's own conversationId — that row never has `inferenceProfile` set,
409
- // and `getConversationOverrideProfile` short-circuits for background
410
- // conversations regardless. Without preferring the in-memory context, the
411
- // inheritance chain breaks at the second nesting level.
405
+ // When a subagent's agent loop is running under a turn profile, the executor
406
+ // closure plumbs that value into `ToolContext.overrideProfile`. That value
407
+ // describes the spawning turn, so `executeSubagentSpawn` leaves it behind: a
408
+ // child resolves the `subagentSpawn` call site on its own, and a forwarded
409
+ // override would both re-price the child and file its spend as a pin nobody
410
+ // set. These tests cover the nesting levels where a profile could otherwise
411
+ // compound down a chain of children.
412
412
 
413
413
  mock.module("../persistence/conversation-crud.js", () => ({
414
414
  setConversationProcessingStartedAt: () => {},
415
- // Always return undefined for the row read so the test fails fast unless
416
- // executeSubagentSpawn reads from context.overrideProfile first.
417
415
  getConversationOverrideProfile: () => undefined,
418
416
  reserveMessage: mock(async () => ({ id: "msg-reserve" })),
419
417
  }));
@@ -421,8 +419,8 @@ mock.module("../persistence/conversation-crud.js", () => ({
421
419
  import { getSubagentManager } from "../subagent/index.js";
422
420
  import { executeSubagentSpawn } from "../tools/subagent/spawn.js";
423
421
 
424
- describe("executeSubagentSpawn — nested inheritance via context.overrideProfile", () => {
425
- test("forwards context.overrideProfile to SubagentConfig (third-level inheritance)", async () => {
422
+ describe("executeSubagentSpawn: the spawn boundary carries no profile", () => {
423
+ test("a subagent spawning a subagent does not pass its own turn profile down", async () => {
426
424
  const manager = getSubagentManager();
427
425
  const originalSpawn = manager.spawn.bind(manager);
428
426
  let capturedConfig: Record<string, unknown> | undefined;
@@ -432,11 +430,11 @@ describe("executeSubagentSpawn — nested inheritance via context.overrideProfil
432
430
  };
433
431
 
434
432
  try {
435
- // Simulate the second-level spawn: tool invocation occurs inside the
436
- // first subagent's tool context, where `overrideProfile` was populated
437
- // by `runAgentLoopImpl` from its `currentTurnOverrideProfile` snapshot.
438
- // The first subagent's own conversation row has no `inferenceProfile`,
439
- // so the row-read fallback would otherwise return undefined.
433
+ // The second-level spawn: this tool call happens inside the first
434
+ // subagent's tool context, where `overrideProfile` was populated by
435
+ // `runAgentLoopImpl` from its own turn snapshot. That snapshot describes
436
+ // the parent's turn, so it stops here rather than compounding down a
437
+ // chain of nested children.
440
438
  const result = await executeSubagentSpawn(
441
439
  { label: "nested", objective: "do nested work" },
442
440
  {
@@ -450,16 +448,13 @@ describe("executeSubagentSpawn — nested inheritance via context.overrideProfil
450
448
 
451
449
  expect(result.isError).toBe(false);
452
450
  expect(capturedConfig).toBeDefined();
453
- // The forwarded SubagentConfig must carry the in-memory override so
454
- // SubagentManager.spawn forwards it into the nested subagent's
455
- // runAgentLoop options — preserving inheritance across the chain.
456
- expect(capturedConfig!.overrideProfile).toBe("fast");
451
+ expect("overrideProfile" in capturedConfig!).toBe(false);
457
452
  } finally {
458
453
  manager.spawn = originalSpawn;
459
454
  }
460
455
  });
461
456
 
462
- test("forwards the invoker's resolved default profile when neither context nor row carries an override", async () => {
457
+ test("no override anywhere leaves the child on the subagentSpawn default", async () => {
463
458
  const manager = getSubagentManager();
464
459
  const originalSpawn = manager.spawn.bind(manager);
465
460
  let capturedConfig: Record<string, unknown> | undefined;
@@ -481,22 +476,17 @@ describe("executeSubagentSpawn — nested inheritance via context.overrideProfil
481
476
  );
482
477
 
483
478
  expect(capturedConfig).toBeDefined();
484
- // With no explicit override anywhere, the invoker-default tier forwards
485
- // the invoking call site's resolved default profile — here `mainAgent`
486
- // with no active profile or pin, which resolves the catalog `balanced`
487
- // default — so the subagent matches its invoker's model selection.
488
- expect(capturedConfig!.overrideProfile).toBe("balanced");
479
+ expect("overrideProfile" in capturedConfig!).toBe(false);
489
480
  } finally {
490
481
  manager.spawn = originalSpawn;
491
482
  }
492
483
  });
493
484
 
494
- test("an explicit subagentSpawn call-site pin suppresses the invoker-default inheritance", async () => {
495
- // With an active profile the invoker-default tier resolves ("fast"), so
496
- // absent a pin it is forwarded as the inherited override; with an
497
- // explicit llm.callSites.subagentSpawn profile the heuristic must yield —
498
- // a forwarded override would outrank the pin under single-winner
499
- // resolution, overriding the user's explicit call-site choice.
485
+ test("a workspace active profile does not reach the child, pinned or not", async () => {
486
+ // The child resolves `llm.callSites.subagentSpawn` on its own, so the
487
+ // spawn must forward nothing either way: a forwarded override would
488
+ // outrank an explicit call-site pin under single-winner resolution and
489
+ // overturn the operator's choice.
500
490
  const manager = getSubagentManager();
501
491
  const originalSpawn = manager.spawn.bind(manager);
502
492
  let capturedConfig: Record<string, unknown> | undefined;
@@ -517,7 +507,7 @@ describe("executeSubagentSpawn — nested inheritance via context.overrideProfil
517
507
  { label: "nested", objective: "do nested work" },
518
508
  baseContext,
519
509
  );
520
- expect(capturedConfig!.overrideProfile).toBe("fast");
510
+ expect("overrideProfile" in capturedConfig!).toBe(false);
521
511
 
522
512
  seedLlmConfig({
523
513
  activeProfile: "fast",
@@ -80,16 +80,6 @@ describe("isAssistantFeatureFlagEnabled", () => {
80
80
  });
81
81
  });
82
82
 
83
- describe("subagent flags default to disabled", () => {
84
- for (const flagKey of ["subagent-profile-isolation", "subagent-loop-guard"]) {
85
- test(`${flagKey} resolves to false with no override`, () => {
86
- const config = {} as any;
87
-
88
- expect(isAssistantFeatureFlagEnabled(flagKey, config)).toBe(false);
89
- });
90
- }
91
- });
92
-
93
83
  describe("isAssistantFeatureFlagEnabled with skillFlagKey", () => {
94
84
  test("resolves skill flag via canonical path", () => {
95
85
  setOverridesForTesting({ [DECLARED_FLAG_KEY]: false });
@@ -92,6 +92,7 @@ function makeCtx(overrides: Partial<Conversation> = {}): Conversation {
92
92
  conversationId: "conv-test",
93
93
  currentRequestId: "req-1",
94
94
  workingDir: "/tmp/test",
95
+ getTurnActorPrincipalId: () => undefined,
95
96
  abortController: null,
96
97
  sendToClient: mock(() => {}),
97
98
  pendingSurfaceActions: new Map(),
@@ -99,6 +99,7 @@ function makeCtx(overrides: Partial<Conversation> = {}): Conversation {
99
99
  conversationId: "conv-test",
100
100
  currentRequestId: "req-1",
101
101
  workingDir: "/tmp/test",
102
+ getTurnActorPrincipalId: () => undefined,
102
103
  abortController: null,
103
104
  sendToClient: mock(() => {}),
104
105
  pendingSurfaceActions: new Map(),
@@ -402,3 +403,63 @@ describe("createToolExecutor channel-permission coordinate threading", () => {
402
403
  expect(bindingLookups).toEqual([]);
403
404
  });
404
405
  });
406
+
407
+ describe("createToolExecutor source-actor threading", () => {
408
+ test("submits the turn's actor principal, not the trust context's guardian", async () => {
409
+ // `ToolContext.sourceActorPrincipalId` is compared against the principal a
410
+ // client registered with on its SSE stream, so it has to be the turn's
411
+ // actor. Reading `trustContext.guardianPrincipalId` instead meant any turn
412
+ // whose trust resolution degraded submitted nothing, and the same-actor
413
+ // gate rejected it as a user mismatch.
414
+ const { executor, calls } = makeCapturingExecutor();
415
+ await makeToolFn(
416
+ executor,
417
+ makeCtx({
418
+ getTurnActorPrincipalId: () => "actor-1",
419
+ currentTurnTrustContext: {
420
+ sourceChannel: "vellum",
421
+ trustClass: "guardian",
422
+ guardianPrincipalId: "guardian-1",
423
+ },
424
+ }),
425
+ )("file_read", { path: "/tmp/a" });
426
+
427
+ expect(calls[0].context.sourceActorPrincipalId).toBe("actor-1");
428
+ });
429
+
430
+ test("carries the actor through even when trust resolved without a guardian principal", async () => {
431
+ // The regression: degraded trust resolution (unreachable gateway, binding
432
+ // drift) or a service-principal turn leaves `guardianPrincipalId` unset
433
+ // while the turn's actor is perfectly well known.
434
+ const { executor, calls } = makeCapturingExecutor();
435
+ await makeToolFn(
436
+ executor,
437
+ makeCtx({
438
+ getTurnActorPrincipalId: () => "actor-1",
439
+ currentTurnTrustContext: {
440
+ sourceChannel: "vellum",
441
+ trustClass: "unknown",
442
+ },
443
+ }),
444
+ )("file_read", { path: "/tmp/a" });
445
+
446
+ expect(calls[0].context.sourceActorPrincipalId).toBe("actor-1");
447
+ });
448
+
449
+ test("submits nothing when the turn has no actor identity at all", async () => {
450
+ const { executor, calls } = makeCapturingExecutor();
451
+ await makeToolFn(
452
+ executor,
453
+ makeCtx({
454
+ getTurnActorPrincipalId: () => undefined,
455
+ currentTurnTrustContext: {
456
+ sourceChannel: "vellum",
457
+ trustClass: "guardian",
458
+ guardianPrincipalId: "guardian-1",
459
+ },
460
+ }),
461
+ )("file_read", { path: "/tmp/a" });
462
+
463
+ expect(calls[0].context.sourceActorPrincipalId).toBeUndefined();
464
+ });
465
+ });
@@ -705,9 +705,7 @@ describe("surfaceProxyResolver — CU tool routing", () => {
705
705
  });
706
706
 
707
707
  expect(result.isError).toBe(true);
708
- expect(result.content).toContain(
709
- "Submitting actor does not match the target client's actor",
710
- );
708
+ expect(result.content).toContain("signed in as a different user");
711
709
  // No state mutation, no dispatch.
712
710
  expect(proxy.stepCount).toBe(0);
713
711
  expect(proxy.actionHistory).toHaveLength(0);
@@ -734,9 +732,7 @@ describe("surfaceProxyResolver — CU tool routing", () => {
734
732
  });
735
733
 
736
734
  expect(result.isError).toBe(true);
737
- expect(result.content).toContain(
738
- "Submitting actor does not match the target client's actor",
739
- );
735
+ expect(result.content).toContain("no authenticated actor");
740
736
  expect(sentMessages).toHaveLength(0);
741
737
  });
742
738
 
@@ -11,6 +11,7 @@ import { join } from "node:path";
11
11
  import { afterEach, describe, expect, test } from "bun:test";
12
12
 
13
13
  import {
14
+ DEFAULT_READ_LINE_LIMIT,
14
15
  FileSystemOps,
15
16
  type PathPolicy,
16
17
  } from "../tools/shared/filesystem/file-ops-service.js";
@@ -124,10 +125,80 @@ describe("FileSystemOps.readFileSafe", () => {
124
125
  if (!result.ok) {
125
126
  return;
126
127
  }
127
- expect(result.value.content).toContain("b");
128
- expect(result.value.content).toContain("c");
129
- expect(result.value.content).not.toContain(" 1");
130
- expect(result.value.content).not.toContain("d");
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
+ 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");
135
+ });
136
+
137
+ test("caps an unbounded read at the default line limit and says so", async () => {
138
+ 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"));
142
+ const ops = new FileSystemOps(sandboxPolicyFor(dir));
143
+
144
+ const result = await ops.readFileSafe({ path: "big.txt" });
145
+ expect(result.ok).toBe(true);
146
+ if (!result.ok) {
147
+ return;
148
+ }
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}`,
154
+ );
155
+ });
156
+
157
+ test("an explicit limit is honored rather than replaced by the default", async () => {
158
+ 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"));
164
+ const ops = new FileSystemOps(sandboxPolicyFor(dir));
165
+
166
+ const result = await ops.readFileSafe({
167
+ path: "big.txt",
168
+ limit: DEFAULT_READ_LINE_LIMIT + 500,
169
+ });
170
+ expect(result.ok).toBe(true);
171
+ if (!result.ok) {
172
+ return;
173
+ }
174
+ expect(result.value.content).toContain("line2500");
175
+ expect(result.value.content).not.toContain("[Truncated:");
176
+ });
177
+
178
+ test("a read that reaches the last line carries no truncation notice", async () => {
179
+ const dir = makeTempDir();
180
+ writeFileSync(join(dir, "small.txt"), "a\nb\nc");
181
+ const ops = new FileSystemOps(sandboxPolicyFor(dir));
182
+
183
+ const result = await ops.readFileSafe({ path: "small.txt" });
184
+ expect(result.ok).toBe(true);
185
+ if (!result.ok) {
186
+ return;
187
+ }
188
+ expect(result.value.content).not.toContain("[Truncated:");
189
+ });
190
+
191
+ test("paging past the end is not reported as truncation", async () => {
192
+ const dir = makeTempDir();
193
+ writeFileSync(join(dir, "small.txt"), "a\nb\nc");
194
+ const ops = new FileSystemOps(sandboxPolicyFor(dir));
195
+
196
+ const result = await ops.readFileSafe({ path: "small.txt", offset: 100 });
197
+ expect(result.ok).toBe(true);
198
+ if (!result.ok) {
199
+ return;
200
+ }
201
+ expect(result.value.content).not.toContain("[Truncated:");
131
202
  });
132
203
  });
133
204
 
@@ -742,8 +742,11 @@ describe("HostBashProxy", () => {
742
742
  });
743
743
 
744
744
  describe("same-user binding (sourceActorPrincipalId)", () => {
745
- const SAME_USER_REJECTION =
746
- "Submitting actor does not match the target client's actor for this request. The targeted client's authenticated user must submit the result.";
745
+ // The proxy path names which comparison failed, so each rejection asserts
746
+ // its own reason — a turn with no actor, a client registered without one,
747
+ // and two genuinely different users are three different problems.
748
+ const CROSS_USER_REJECTION =
749
+ "Submitting actor does not match the target client's actor for this request. The target client is signed in as a different user — run `assistant clients list` to see which connected clients you own.";
747
750
 
748
751
  test("same-user targeted request succeeds", async () => {
749
752
  setup();
@@ -784,7 +787,7 @@ describe("HostBashProxy", () => {
784
787
  );
785
788
 
786
789
  expect(result.isError).toBe(true);
787
- expect(result.content).toBe(SAME_USER_REJECTION);
790
+ expect(result.content).toBe(CROSS_USER_REJECTION);
788
791
  // No broadcast and no pending registration
789
792
  expect(sentMessages).toHaveLength(0);
790
793
  });
@@ -807,7 +810,9 @@ describe("HostBashProxy", () => {
807
810
  );
808
811
 
809
812
  expect(result.isError).toBe(true);
810
- expect(result.content).toBe(SAME_USER_REJECTION);
813
+ expect(result.content).toContain(
814
+ "registered without an authenticated user",
815
+ );
811
816
  expect(sentMessages).toHaveLength(0);
812
817
  });
813
818
 
@@ -823,7 +828,7 @@ describe("HostBashProxy", () => {
823
828
  );
824
829
 
825
830
  expect(result.isError).toBe(true);
826
- expect(result.content).toBe(SAME_USER_REJECTION);
831
+ expect(result.content).toContain("no authenticated actor");
827
832
  expect(sentMessages).toHaveLength(0);
828
833
  });
829
834
 
@@ -867,7 +867,7 @@ describe("HostBrowserProxy", () => {
867
867
  );
868
868
 
869
869
  expect(result.isError).toBe(true);
870
- expect(result.content).toContain("Submitting actor does not match");
870
+ expect(result.content).toContain("signed in as a different user");
871
871
  expect(getPublishedMessages()).toHaveLength(0);
872
872
  });
873
873
 
@@ -1054,7 +1054,7 @@ describe("HostBrowserProxy", () => {
1054
1054
  );
1055
1055
 
1056
1056
  expect(result.isError).toBe(true);
1057
- expect(result.content).toContain("Submitting actor does not match");
1057
+ expect(result.content).toContain("signed in as a different user");
1058
1058
  expect(result.content).not.toContain("extension_required");
1059
1059
  expect(getPublishedMessages()).toHaveLength(0);
1060
1060
  });
@@ -1416,9 +1416,7 @@ describe("HostCuProxy", () => {
1416
1416
  );
1417
1417
 
1418
1418
  expect(result.isError).toBe(true);
1419
- expect(result.content).toContain(
1420
- "Submitting actor does not match the target client's actor",
1421
- );
1419
+ expect(result.content).toContain("signed in as a different user");
1422
1420
  expect(sentMessages).toHaveLength(0);
1423
1421
  });
1424
1422
 
@@ -1444,9 +1442,7 @@ describe("HostCuProxy", () => {
1444
1442
  );
1445
1443
 
1446
1444
  expect(result.isError).toBe(true);
1447
- expect(result.content).toContain(
1448
- "Submitting actor does not match the target client's actor",
1449
- );
1445
+ expect(result.content).toContain("no authenticated actor");
1450
1446
  expect(sentMessages).toHaveLength(0);
1451
1447
  });
1452
1448
 
@@ -1473,7 +1469,7 @@ describe("HostCuProxy", () => {
1473
1469
 
1474
1470
  expect(result.isError).toBe(true);
1475
1471
  expect(result.content).toContain(
1476
- "Submitting actor does not match the target client's actor",
1472
+ "registered without an authenticated user",
1477
1473
  );
1478
1474
  expect(sentMessages).toHaveLength(0);
1479
1475
  });
@@ -693,9 +693,7 @@ describe("HostFileProxy", () => {
693
693
  );
694
694
 
695
695
  expect(result.isError).toBe(true);
696
- expect(result.content).toContain(
697
- "Submitting actor does not match the target client's actor",
698
- );
696
+ expect(result.content).toContain("signed in as a different user");
699
697
  // No host_file_request was broadcast.
700
698
  expect(sentMessages).toHaveLength(0);
701
699
  });
@@ -720,7 +718,7 @@ describe("HostFileProxy", () => {
720
718
 
721
719
  expect(result.isError).toBe(true);
722
720
  expect(result.content).toContain(
723
- "Submitting actor does not match the target client's actor",
721
+ "registered without an authenticated user",
724
722
  );
725
723
  expect(sentMessages).toHaveLength(0);
726
724
  });
@@ -744,9 +742,7 @@ describe("HostFileProxy", () => {
744
742
  );
745
743
 
746
744
  expect(result.isError).toBe(true);
747
- expect(result.content).toContain(
748
- "Submitting actor does not match the target client's actor",
749
- );
745
+ expect(result.content).toContain("no authenticated actor");
750
746
  expect(sentMessages).toHaveLength(0);
751
747
  });
752
748
 
@@ -27,6 +27,7 @@ mock.module("../daemon/host-file-proxy.js", () => ({
27
27
  }));
28
28
 
29
29
  import { hostFileReadTool } from "../tools/host-filesystem/read.js";
30
+ import { DEFAULT_READ_LINE_LIMIT } from "../tools/shared/filesystem/file-ops-service.js";
30
31
  import type { ToolContext } from "../tools/types.js";
31
32
 
32
33
  const testDirs: string[] = [];
@@ -226,13 +227,16 @@ describe("host_file_read image support", () => {
226
227
 
227
228
  expect(result.isError).toBe(false);
228
229
  expect(result.contentBlocks).toHaveLength(1);
230
+ // The proxied request carries the resolved default window, so a host read
231
+ // is bounded by the same limit as a local one rather than streaming a
232
+ // whole file across the bridge.
229
233
  expect(requests).toEqual([
230
234
  {
231
235
  input: {
232
236
  operation: "read",
233
237
  path: "/host/screenshot.png",
234
238
  offset: undefined,
235
- limit: undefined,
239
+ limit: DEFAULT_READ_LINE_LIMIT,
236
240
  },
237
241
  conversationId: "test-conversation",
238
242
  signal: undefined,