@vellumai/assistant 0.12.2-staging.6 → 0.12.2-staging.7

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 (120) hide show
  1. package/docs/architecture/memory.md +11 -2
  2. package/docs/desktop-browser-cli.md +4 -2
  3. package/node_modules/@vellumai/environments/src/shell.test.ts +21 -0
  4. package/node_modules/@vellumai/environments/src/shell.ts +24 -0
  5. package/node_modules/@vellumai/gateway-client/src/inbound-contract.ts +8 -2
  6. package/openapi.yaml +6 -2
  7. package/package.json +2 -2
  8. package/scripts/postinstall.ts +33 -0
  9. package/scripts/smoke-desktop-browser-cli.ts +1 -0
  10. package/src/__tests__/agent-loop.test.ts +124 -0
  11. package/src/__tests__/approval-interception-trust-gates.test.ts +40 -0
  12. package/src/__tests__/channel-approval.test.ts +9 -14
  13. package/src/__tests__/conversation-agent-loop.test.ts +25 -0
  14. package/src/__tests__/db-conversation-tool-surface.test.ts +144 -0
  15. package/src/__tests__/plugin-import-boundary-guard.test.ts +0 -1
  16. package/src/__tests__/run-conversation-turn-persistence.test.ts +138 -1
  17. package/src/__tests__/script-proxy-certs.test.ts +1 -1
  18. package/src/__tests__/subagent-tool-gate-mode.test.ts +169 -0
  19. package/src/__tests__/terminal-tools.test.ts +8 -0
  20. package/src/__tests__/unicode.test.ts +36 -0
  21. package/src/agent/loop.ts +19 -0
  22. package/src/api/events/desktop-activity-changed.ts +10 -0
  23. package/src/api/index.ts +6 -0
  24. package/src/approvals/approval-primitive.ts +5 -2
  25. package/src/approvals/scoped-approval-grants.ts +6 -2
  26. package/src/daemon/__tests__/conversation-tool-setup.test.ts +43 -0
  27. package/src/daemon/conversation-agent-loop.ts +2 -0
  28. package/src/daemon/conversation-tool-setup.ts +57 -1
  29. package/src/daemon/conversation.ts +17 -0
  30. package/src/daemon/daemon-control.ts +2 -6
  31. package/src/daemon/orphan-reaper.ts +4 -3
  32. package/src/daemon/tool-setup-types.ts +6 -0
  33. package/src/daemon/wake-conversation-ops.ts +38 -15
  34. package/src/desktop/desktop-automation-lease.test.ts +143 -0
  35. package/src/desktop/desktop-automation-lease.ts +39 -3
  36. package/src/messaging/provider-message-metadata.ts +3 -3
  37. package/src/notifications/__tests__/copy-composer.test.ts +70 -0
  38. package/src/notifications/copy-composer.ts +11 -3
  39. package/src/persistence/conversation-plugin-facade.ts +13 -0
  40. package/src/persistence/conversation-tool-surface.ts +86 -0
  41. package/src/persistence/migrations/378-create-conversation-tool-surfaces.test.ts +78 -0
  42. package/src/persistence/migrations/378-create-conversation-tool-surfaces.ts +29 -0
  43. package/src/persistence/schema/conversation-tool-surfaces.ts +22 -0
  44. package/src/persistence/schema/index.ts +1 -0
  45. package/src/persistence/steps.ts +2 -0
  46. package/src/plugin-api/conversation-turn.ts +31 -7
  47. package/src/plugin-api/index.ts +9 -1
  48. package/src/plugin-api/plugin-channel-turn-trust.test.ts +133 -0
  49. package/src/plugin-api/plugin-channel-turn-trust.ts +71 -0
  50. package/src/plugins/defaults/memory/AGENTS.md +14 -2
  51. package/src/plugins/defaults/memory/__tests__/buffer-file.test.ts +320 -0
  52. package/src/plugins/defaults/memory/__tests__/buffer-format.test.ts +43 -0
  53. package/src/plugins/defaults/memory/__tests__/fixtures/buffer-appender.ts +17 -0
  54. package/src/plugins/defaults/memory/__tests__/memory-retrospective-job.test.ts +46 -0
  55. package/src/plugins/defaults/memory/__tests__/memory-run-evidence.test.ts +161 -0
  56. package/src/plugins/defaults/memory/buffer-file.ts +354 -0
  57. package/src/plugins/defaults/memory/buffer-format.ts +40 -0
  58. package/src/plugins/defaults/memory/context-search/agent-runner.ts +1 -2
  59. package/src/plugins/defaults/memory/context-search/format.ts +2 -1
  60. package/src/plugins/defaults/memory/context-search/sources/memory-v2.ts +2 -1
  61. package/src/plugins/defaults/memory/context-search/sources/workspace.ts +2 -1
  62. package/src/plugins/defaults/memory/graph/capability-seed.ts +1 -2
  63. package/src/plugins/defaults/memory/graph/tool-handlers.ts +1 -42
  64. package/src/plugins/defaults/memory/host-utils.ts +0 -10
  65. package/src/plugins/defaults/memory/injectors.ts +4 -3
  66. package/src/plugins/defaults/memory/memory-retrospective-job.ts +55 -181
  67. package/src/plugins/defaults/memory/memory-run-evidence.ts +213 -0
  68. package/src/plugins/defaults/memory/substrate/__tests__/consolidation-job.test.ts +407 -99
  69. package/src/plugins/defaults/memory/substrate/__tests__/consolidation-prompt-flag-gating-guard.test.ts +10 -0
  70. package/src/plugins/defaults/memory/substrate/__tests__/prompts-consolidation.test.ts +107 -7
  71. package/src/plugins/defaults/memory/substrate/consolidation-job.ts +307 -86
  72. package/src/plugins/defaults/memory/substrate/consolidation-tool-surface.ts +34 -0
  73. package/src/plugins/defaults/memory/substrate/page-index.ts +2 -1
  74. package/src/plugins/defaults/memory/substrate/prompts/consolidation.ts +89 -49
  75. package/src/plugins/defaults/memory/substrate/sweep-job.ts +1 -1
  76. package/src/plugins/defaults/memory/tools.ts +1 -1
  77. package/src/plugins/defaults/memory/v1/graph/consolidation.ts +2 -2
  78. package/src/plugins/defaults/memory/v1/graph/extraction.ts +2 -1
  79. package/src/plugins/defaults/memory/v1/graph/retriever.ts +1 -1
  80. package/src/plugins/defaults/memory/v2/__tests__/migration.test.ts +5 -0
  81. package/src/plugins/defaults/memory/v2/__tests__/reranker.test.ts +5 -2
  82. package/src/plugins/defaults/memory/v2/reranker.ts +2 -1
  83. package/src/plugins/defaults/memory/v3/__tests__/injection.test.ts +81 -1
  84. package/src/plugins/defaults/memory/v3/__tests__/orchestrate.test.ts +87 -0
  85. package/src/plugins/defaults/memory/v3/__tests__/shadow-plugin.test.ts +21 -0
  86. package/src/plugins/defaults/memory/v3/card.ts +2 -1
  87. package/src/plugins/defaults/memory/v3/injector.ts +212 -178
  88. package/src/plugins/defaults/memory/v3/orchestrate.ts +86 -22
  89. package/src/plugins/defaults/memory/v3/pool-select.ts +10 -7
  90. package/src/plugins/defaults/memory/v3/sections.ts +2 -1
  91. package/src/plugins/defaults/memory/v3/shadow-plugin.ts +10 -1
  92. package/src/plugins/defaults/tool-result-truncate/terminal.ts +1 -46
  93. package/src/runtime/AGENTS.md +1 -1
  94. package/src/runtime/__tests__/agent-wake.test.ts +86 -1
  95. package/src/runtime/agent-wake.ts +20 -4
  96. package/src/runtime/guardian-action-service.ts +2 -17
  97. package/src/runtime/guardian-reply-router.ts +1 -8
  98. package/src/runtime/routes/channel-route-shared.ts +1 -9
  99. package/src/runtime/routes/desktop-setup-routes.test.ts +2 -2
  100. package/src/runtime/routes/desktop-setup-routes.ts +7 -3
  101. package/src/runtime/routes/guardian-approval-interception.ts +24 -0
  102. package/src/runtime/routes/inbound-message-handler.ts +2 -3
  103. package/src/runtime/routes/inbound-stages/background-dispatch.test.ts +1 -1
  104. package/src/runtime/routes/inbound-stages/background-dispatch.ts +7 -4
  105. package/src/schedule/run-script.ts +2 -2
  106. package/src/tools/host-terminal/host-shell.ts +12 -6
  107. package/src/tools/shared/filesystem/file-ops-service.ts +1 -31
  108. package/src/tools/shared/shell-output.test.ts +10 -0
  109. package/src/tools/shared/shell-output.ts +14 -2
  110. package/src/tools/skills/sandbox-runner.ts +13 -2
  111. package/src/tools/skills/scaffold-managed.ts +2 -1
  112. package/src/tools/terminal/__tests__/safe-env.test.ts +29 -0
  113. package/src/tools/terminal/safe-env.ts +30 -1
  114. package/src/tools/terminal/sanitized-bash.ts +15 -2
  115. package/src/tools/terminal/shell-launch.test.ts +162 -0
  116. package/src/tools/terminal/shell.test.ts +29 -0
  117. package/src/tools/terminal/shell.ts +13 -7
  118. package/src/util/host-process.test.ts +17 -1
  119. package/src/util/host-process.ts +24 -0
  120. package/src/util/unicode.ts +29 -0
@@ -22,6 +22,10 @@ import {
22
22
  import { supportsChannelReaction } from "../messaging/providers/index.js";
23
23
  import type { PermissionPrompter } from "../permissions/prompter.js";
24
24
  import type { SecretPrompter } from "../permissions/secret-prompter.js";
25
+ import {
26
+ hashConversationToolSurface,
27
+ recordConversationToolSurface,
28
+ } from "../persistence/conversation-tool-surface.js";
25
29
  import { getBindingByConversation } from "../persistence/external-conversation-store.js";
26
30
  import { getAllDefaultPluginNames } from "../plugins/defaults/main.js";
27
31
  import { isActivationSession } from "../plugins/defaults/memory/activation-session-store.js";
@@ -943,6 +947,50 @@ export function canSpawnSubagentsForTurn(ctx: Conversation): boolean {
943
947
  );
944
948
  }
945
949
 
950
+ /**
951
+ * Build the agent loop's `onToolsSent` observer for a conversation: record
952
+ * the tool array each provider call sends so a later fork wake can replay it
953
+ * (`recordConversationToolSurface`). Only the loop's send boundary sees the
954
+ * sent array. The resolver is also consulted out of band (the token count
955
+ * behind `/compact` and `/clean`, compaction estimates), where a read outside
956
+ * any turn resolves a clientless surface that would overwrite the one the
957
+ * conversation's turns actually send.
958
+ *
959
+ * Arrays that are not the conversation's own surface are skipped: a replaying
960
+ * wake sends its source's array, an empty array is a tools-disabled call (a
961
+ * fork replaying it could never call `remember`), and disk-pressure cleanup
962
+ * mode narrows the wire to cleanup tools. Best-effort: a failed write is
963
+ * logged and the array still counts as recorded, so a persistent failure logs
964
+ * once per distinct surface rather than once per provider call.
965
+ */
966
+ export function createWireToolSurfaceRecorder(
967
+ ctx: Conversation,
968
+ ): (tools: ToolDefinition[]) => void {
969
+ return (tools) => {
970
+ if (
971
+ !ctx.conversationId ||
972
+ ctx.wireToolReplay ||
973
+ tools.length === 0 ||
974
+ ctx.diskPressureCleanupModeActive === true
975
+ ) {
976
+ return;
977
+ }
978
+ try {
979
+ ctx.recordedToolSurfaceHash = recordConversationToolSurface(
980
+ ctx.conversationId,
981
+ tools,
982
+ ctx.recordedToolSurfaceHash,
983
+ );
984
+ } catch (err) {
985
+ log.warn(
986
+ { err, conversationId: ctx.conversationId },
987
+ "failed to record the conversation's wire tool surface; continuing",
988
+ );
989
+ ctx.recordedToolSurfaceHash = hashConversationToolSurface(tools);
990
+ }
991
+ };
992
+ }
993
+
946
994
  /**
947
995
  * Build a resolveTools callback that merges base tool definitions with
948
996
  * dynamically projected skill tools on each agent turn. Also updates
@@ -1237,6 +1285,14 @@ export function createResolveToolsCallback(
1237
1285
 
1238
1286
  ctx.allowedToolNames = turnAllowed;
1239
1287
 
1240
- return applyActivityField(allBaseDefs);
1288
+ // A wake replaying its source's recorded surface sends that array
1289
+ // verbatim: the wire tool block is the first tier of the provider cache
1290
+ // prefix (tools → system → messages), so only the same bytes read the
1291
+ // source's cached prefix instead of rewriting it. Execution is unaffected:
1292
+ // `allowedToolNames` above and the executor's allowlist gate still decide
1293
+ // what may run.
1294
+ return ctx.wireToolReplay
1295
+ ? [...ctx.wireToolReplay]
1296
+ : applyActivityField(allBaseDefs);
1241
1297
  };
1242
1298
  }
@@ -181,6 +181,7 @@ import {
181
181
  canSpawnSubagentsForTurn,
182
182
  createResolveToolsCallback,
183
183
  createToolExecutor,
184
+ createWireToolSurfaceRecorder,
184
185
  } from "./conversation-tool-setup.js";
185
186
  import { canonicalizeTimeZone } from "./date-context.js";
186
187
  import { HostAppControlProxy } from "./host-app-control-proxy.js";
@@ -452,6 +453,21 @@ export class Conversation {
452
453
  * @internal
453
454
  */
454
455
  toolContextPin?: WakeToolContextPin;
456
+ /**
457
+ * Tool definitions sent verbatim in place of the resolved wire array, for
458
+ * a wake replaying its source conversation's recorded surface
459
+ * (`recordConversationToolSurface`). Set and restored alongside the
460
+ * allowlist by `scopeWakeAllowedTools`; read only where the resolver returns
461
+ * the wire array, so it never widens what may execute.
462
+ * @internal
463
+ */
464
+ wireToolReplay?: readonly ToolDefinition[];
465
+ /**
466
+ * Hash of the wire tool array last recorded for this conversation in this
467
+ * process, so an unchanged surface is not rewritten on every provider call.
468
+ * @internal
469
+ */
470
+ recordedToolSurfaceHash?: string;
455
471
  /** @internal */ readonly skillProjectionState = new Map<string, string>();
456
472
  /** @internal */ readonly skillProjectionCache: SkillProjectionCache = {};
457
473
  /** @internal */ usageStats: UsageStats = {
@@ -1038,6 +1054,7 @@ export class Conversation {
1038
1054
  tools: toolDefs.length > 0 ? toolDefs : undefined,
1039
1055
  toolExecutor: toolDefs.length > 0 ? toolExecutor : undefined,
1040
1056
  resolveTools,
1057
+ onToolsSent: createWireToolSurfaceRecorder(this),
1041
1058
  resolveConversationDir: () => {
1042
1059
  const conv = getConversation(this.conversationId);
1043
1060
  if (!conv) {
@@ -258,15 +258,11 @@ async function getDaemonStatus(): Promise<{
258
258
  return { running: true, pid };
259
259
  }
260
260
 
261
- function getStartupLockPath(): string {
262
- return getDaemonStartupLockPath();
263
- }
264
-
265
261
  /** Attempt to acquire a startup lock. Returns true on success. Stale locks
266
262
  * (older than STARTUP_LOCK_STALE_MS) are forcibly removed to prevent
267
263
  * permanent deadlocks from a crashed caller. */
268
264
  function acquireStartupLock(): boolean {
269
- const lockPath = getStartupLockPath();
265
+ const lockPath = getDaemonStartupLockPath();
270
266
  try {
271
267
  // Ensure the root directory exists before attempting the lock file write.
272
268
  // On a first-time run, getRootDir() may not exist yet, and writeFileSync
@@ -293,7 +289,7 @@ function acquireStartupLock(): boolean {
293
289
 
294
290
  function releaseStartupLock(): void {
295
291
  try {
296
- unlinkSync(getStartupLockPath());
292
+ unlinkSync(getDaemonStartupLockPath());
297
293
  } catch {
298
294
  /* already removed */
299
295
  }
@@ -1,9 +1,10 @@
1
1
  /**
2
2
  * Periodic reaper for orphaned subprocesses that reparent to the daemon.
3
3
  *
4
- * Tools run commands in their own process group (`detached: true`) and, on
5
- * timeout/abort, group-kill with `process.kill(-pgid, SIGKILL)` (the bash and
6
- * host_bash tools, and the skill sandbox runner). The
4
+ * POSIX tools run commands in their own process group (`detached: true`) and,
5
+ * on timeout/abort, group-kill with `process.kill(-pgid, SIGKILL)` (the bash
6
+ * and host_bash tools, and the skill sandbox runner). Windows piped shell
7
+ * children stay attached and are torn down with `taskkill /T`. The
7
8
  * immediate child is reaped by Bun/libuv, but its descendants — e.g. git's
8
9
  * transport helpers or a skill runner's `bun` process — were never spawned by
9
10
  * the daemon, so when the group dies they reparent to PID 1. When the daemon
@@ -59,6 +59,12 @@ export interface SubagentToolStats {
59
59
  * parity for desktop/web sources; channel-routed sources resolve every tool
60
60
  * gate identically under `hasNoClient: true` with or without them.)
61
61
  *
62
+ * A wake that also carries its source's recorded wire array
63
+ * (`Conversation.wireToolReplay`, set by `scopeWakeAllowedTools`) sends that
64
+ * array verbatim, so the pin then shapes only the execution-side inventory
65
+ * (`allowedToolNames`); the pin alone carries wire parity for a source with
66
+ * no recorded surface.
67
+ *
62
68
  * Tool-definition resolution ONLY. The executor callback and host-proxy
63
69
  * attachment paths never read the pin, so it cannot make a host tool
64
70
  * runnable: in execution gate mode every non-allowlisted call is rejected
@@ -22,7 +22,7 @@ import {
22
22
  import { syncMessageToDisk } from "../persistence/conversation-disk-view.js";
23
23
  import { backfillMessageIdOnLogs } from "../persistence/llm-request-log-store.js";
24
24
  import { resolveMediaSourceData } from "../providers/media-resolve.js";
25
- import type { Message } from "../providers/types.js";
25
+ import type { Message, ToolDefinition } from "../providers/types.js";
26
26
  import { broadcastMessage } from "../runtime/assistant-event-hub.js";
27
27
  import { publishConversationMessagesChanged } from "../runtime/sync/resource-sync-events.js";
28
28
  import type { CompletedBackgroundTool } from "../tools/background-tool-registry.js";
@@ -350,38 +350,60 @@ export async function persistWakeTriggerMessage(
350
350
  }
351
351
  }
352
352
 
353
+ /**
354
+ * How a wake's tool allowlist is applied beyond the allowlist itself. Every
355
+ * field is set on the conversation together with the allowlist and restored
356
+ * with it.
357
+ */
358
+ export interface WakeToolScopeOptions {
359
+ /** How the allowlist is enforced; see {@link SubagentToolGateMode}. */
360
+ gateMode?: SubagentToolGateMode;
361
+ /**
362
+ * Freezes the client-context inputs for tool-definition resolution
363
+ * (execution-gate-mode cache-parity wakes); see {@link WakeToolContextPin}.
364
+ * Its `requestOrigin`, when set, is stamped onto the conversation's per-turn
365
+ * origin so the permission checker's origin-scoped auto-grants fire.
366
+ */
367
+ toolContextPin?: WakeToolContextPin;
368
+ /**
369
+ * Skills whose bundled tools join the turn's active set
370
+ * (`allowedToolNames`) so they are callable without a prior `skill_load`.
371
+ */
372
+ preactivateSkillIds?: readonly string[];
373
+ /**
374
+ * Sent verbatim as the wake's wire tool array in place of the resolved one
375
+ * (the source conversation's recorded surface, replayed for provider
376
+ * prompt-cache parity). Changes nothing about what may execute.
377
+ */
378
+ wireToolDefinitions?: readonly ToolDefinition[];
379
+ }
380
+
353
381
  /**
354
382
  * Temporarily restrict the tools visible/executable during a wake by
355
383
  * reusing the conversation's subagent allowlist slot. Returns a restore
356
384
  * callback that reinstates the previous allowlist so the wake can release
357
385
  * the scope before any queued user turn is drained.
358
- *
359
- * `gateMode` controls how the allowlist is enforced — see
360
- * {@link SubagentToolGateMode}. `toolContextPin`, when provided
361
- * (execution-gate-mode cache-parity wakes), freezes the client-context
362
- * inputs for tool-definition resolution — see {@link WakeToolContextPin}.
363
- * Its `requestOrigin`, when set, is stamped onto the conversation's per-turn
364
- * origin so the permission checker's origin-scoped auto-grants fire for the
365
- * wake. `preactivateSkillIds`, when non-empty, activates those skills' bundled
366
- * tools in the turn's active set (`allowedToolNames`) for the wake so they are
367
- * callable without a prior `skill_load`. All are set and restored alongside the
368
- * allowlist.
369
386
  */
370
387
  export function scopeWakeAllowedTools(
371
388
  conversation: Conversation,
372
389
  tools: ReadonlySet<string>,
373
- gateMode: SubagentToolGateMode = "wire",
374
- toolContextPin?: WakeToolContextPin,
375
- preactivateSkillIds?: readonly string[],
390
+ {
391
+ gateMode = "wire",
392
+ toolContextPin,
393
+ preactivateSkillIds,
394
+ wireToolDefinitions,
395
+ }: WakeToolScopeOptions = {},
376
396
  ): () => void {
377
397
  const previous = conversation.subagentAllowedTools;
378
398
  const previousGateMode = conversation.subagentToolGateMode;
379
399
  const previousToolContextPin = conversation.toolContextPin;
380
400
  const previousRequestOrigin = conversation.currentTurnRequestOrigin;
381
401
  const previousPreactivated = conversation.preactivatedSkillIds;
402
+ const previousWireToolReplay = conversation.wireToolReplay;
382
403
  conversation.setSubagentAllowedTools(new Set(tools));
383
404
  conversation.subagentToolGateMode = gateMode;
384
405
  conversation.toolContextPin = toolContextPin;
406
+ conversation.wireToolReplay = wireToolDefinitions;
385
407
  if (toolContextPin?.requestOrigin !== undefined) {
386
408
  conversation.currentTurnRequestOrigin = toolContextPin.requestOrigin;
387
409
  }
@@ -394,5 +416,6 @@ export function scopeWakeAllowedTools(
394
416
  conversation.toolContextPin = previousToolContextPin;
395
417
  conversation.currentTurnRequestOrigin = previousRequestOrigin;
396
418
  conversation.setPreactivatedSkillIds(previousPreactivated);
419
+ conversation.wireToolReplay = previousWireToolReplay;
397
420
  };
398
421
  }
@@ -35,7 +35,9 @@ function fixture() {
35
35
  const started = mock(async () => {});
36
36
  const released = mock(() => {});
37
37
  const releaseBrowser = mock(async () => {});
38
+ const notify = mock(async () => {});
38
39
  const lease = new DesktopAutomationLease({
40
+ notify,
39
41
  enabled: () => enabled,
40
42
  ready: () => ready,
41
43
  ensureReady,
@@ -50,6 +52,7 @@ function fixture() {
50
52
  cleanups.push(lease);
51
53
  return {
52
54
  lease,
55
+ notify,
53
56
  ensureReady,
54
57
  install,
55
58
  failSetup: () => setup.reject(new Error("download failed")),
@@ -128,6 +131,7 @@ test("cancellation stops running and queued browser commands without blocking a
128
131
  const callback = mock(operation);
129
132
  const queued = f.lease.runBrowser(context, callback);
130
133
  abort.abort();
134
+ expect(f.lease.isActive).toBe(false);
131
135
  expect(await running).toBeInstanceOf(Error);
132
136
  expect((await queued).isError).toBe(true);
133
137
  expect(callback).not.toHaveBeenCalled();
@@ -206,3 +210,142 @@ test("disabled, unidentified, cancelled and released browser calls cannot instal
206
210
  await expect(f.lease.runBrowser(context, operation)).rejects.toThrow();
207
211
  expect(f.ensureReady).not.toHaveBeenCalled();
208
212
  });
213
+
214
+ test("activity follows the browser lease and clears before failed cleanup", async () => {
215
+ const f = fixture();
216
+ expect(f.lease.isActive).toBe(false);
217
+ await f.lease.runBrowser(context, operation);
218
+ expect(f.lease.isActive).toBe(true);
219
+ expect(f.notify).toHaveBeenCalledTimes(1);
220
+ await f.lease.runBrowser(context, operation);
221
+ expect(f.notify).toHaveBeenCalledTimes(1);
222
+ f.releaseBrowser.mockRejectedValueOnce(new Error("cleanup failed"));
223
+ await expect(f.lease.runBrowser(context, operation, true)).rejects.toThrow(
224
+ "cleanup failed",
225
+ );
226
+ expect(f.lease.isActive).toBe(false);
227
+ expect(f.notify).toHaveBeenCalledTimes(2);
228
+ await f.lease.runBrowser(context, operation, true);
229
+ expect(f.notify).toHaveBeenCalledTimes(2);
230
+ });
231
+
232
+ test("turn completion releases only its own desktop before cleanup finishes", async () => {
233
+ const f = fixture();
234
+ await f.lease.runBrowser(context, operation);
235
+ f.lease.releaseForConversation("conv-456");
236
+ expect(f.lease.isActive).toBe(true);
237
+ expect(f.notify).toHaveBeenCalledTimes(1);
238
+
239
+ const cleanup = Promise.withResolvers<void>();
240
+ f.releaseBrowser.mockImplementationOnce(() => cleanup.promise);
241
+ f.lease.releaseForConversation(context.conversationId);
242
+ expect(f.lease.isActive).toBe(false);
243
+ expect(f.notify).toHaveBeenCalledTimes(2);
244
+ await Bun.sleep(0);
245
+ expect(f.released).not.toHaveBeenCalled();
246
+
247
+ const callback = mock(operation);
248
+ const next = f.lease.runBrowser(context, callback);
249
+ await Bun.sleep(0);
250
+ expect(callback).not.toHaveBeenCalled();
251
+ cleanup.resolve();
252
+ expect((await next).isError).toBe(false);
253
+ expect(f.released).toHaveBeenCalledTimes(1);
254
+ expect(f.started).toHaveBeenCalledTimes(2);
255
+ expect(f.lease.isActive).toBe(true);
256
+ });
257
+
258
+ test("turn completion cancels running and queued desktop work", async () => {
259
+ const f = fixture();
260
+ const started = Promise.withResolvers<void>();
261
+ const running = f.lease
262
+ .runBrowser(context, async (signal) => {
263
+ started.resolve();
264
+ await new Promise<void>((_resolve, reject) => {
265
+ signal.addEventListener("abort", () => reject(new Error("aborted")), {
266
+ once: true,
267
+ });
268
+ });
269
+ return operation();
270
+ })
271
+ .catch((error: unknown) => error);
272
+ await started.promise;
273
+ const callback = mock(operation);
274
+ const queued = f.lease.runBrowser(context, callback);
275
+ f.lease.releaseForConversation(context.conversationId);
276
+ expect(f.lease.isActive).toBe(false);
277
+ expect(await running).toBeInstanceOf(Error);
278
+ expect((await queued).isError).toBe(true);
279
+ expect(callback).not.toHaveBeenCalled();
280
+ await f.lease.runBrowser(context, callback);
281
+ expect(callback).toHaveBeenCalledTimes(1);
282
+ });
283
+
284
+ test("turn handoff retries failed cleanup without another turn or restart", async () => {
285
+ const f = fixture();
286
+ await f.lease.runBrowser(context, operation);
287
+ f.releaseBrowser.mockRejectedValueOnce(new Error("Chrome cleanup timed out"));
288
+ f.lease.releaseForConversation(context.conversationId);
289
+ expect(f.lease.isActive).toBe(false);
290
+ await Bun.sleep(0);
291
+ expect(f.released).not.toHaveBeenCalled();
292
+ await Bun.sleep(1_100);
293
+ expect(f.released).toHaveBeenCalledTimes(1);
294
+ const nextContext = { ...context, conversationId: "conv-456" };
295
+ expect((await f.lease.runBrowser(nextContext, operation)).isError).toBe(
296
+ false,
297
+ );
298
+ await f.lease.runBrowser(nextContext, operation, true);
299
+ });
300
+
301
+ test("a delayed cleanup retry cannot release a replacement owner", async () => {
302
+ const f = fixture();
303
+ await f.lease.runBrowser(context, operation);
304
+ f.releaseBrowser.mockRejectedValueOnce(new Error("Chrome cleanup timed out"));
305
+ f.lease.releaseForConversation(context.conversationId);
306
+ await f.lease.runBrowser(context, operation, true);
307
+ const nextContext = { ...context, conversationId: "conv-456" };
308
+ await f.lease.runBrowser(nextContext, operation);
309
+ await Bun.sleep(1_100);
310
+ expect(f.lease.isActive).toBe(true);
311
+ expect(f.released).toHaveBeenCalledTimes(1);
312
+ await f.lease.runBrowser(nextContext, operation, true);
313
+ });
314
+
315
+ test("cleanup retries preserve commands queued behind an explicit handoff", async () => {
316
+ const f = fixture();
317
+ await f.lease.runBrowser(context, operation);
318
+ f.releaseBrowser.mockRejectedValueOnce(new Error("Chrome cleanup timed out"));
319
+ f.lease.releaseForConversation(context.conversationId);
320
+ await Bun.sleep(0);
321
+ const cleanup = Promise.withResolvers<void>();
322
+ f.releaseBrowser.mockImplementationOnce(() => cleanup.promise);
323
+ const detached = f.lease.runBrowser(context, operation, true);
324
+ await Bun.sleep(0);
325
+ const callback = mock(operation);
326
+ const next = f.lease.runBrowser(context, callback);
327
+ await Bun.sleep(1_100);
328
+ cleanup.resolve();
329
+ await detached;
330
+ expect((await next).isError).toBe(false);
331
+ expect(callback).toHaveBeenCalledTimes(1);
332
+ expect(f.lease.isActive).toBe(true);
333
+ });
334
+
335
+ test("turn completion cancels commands queued behind an in-progress detach", async () => {
336
+ const f = fixture();
337
+ await f.lease.runBrowser(context, operation);
338
+ const cleanup = Promise.withResolvers<void>();
339
+ f.releaseBrowser.mockImplementationOnce(() => cleanup.promise);
340
+ const detached = f.lease.runBrowser(context, operation, true);
341
+ await Bun.sleep(0);
342
+ const callback = mock(operation);
343
+ const queued = f.lease.runBrowser(context, callback);
344
+ f.lease.releaseForConversation(context.conversationId);
345
+ cleanup.resolve();
346
+ await detached;
347
+ expect((await queued).isError).toBe(true);
348
+ expect(callback).not.toHaveBeenCalled();
349
+ await f.lease.runBrowser(context, callback);
350
+ expect(callback).toHaveBeenCalledTimes(1);
351
+ });
@@ -1,4 +1,5 @@
1
1
  import { getConfig } from "../config/loader.js";
2
+ import { broadcastMessage } from "../runtime/assistant-event-hub.js";
2
3
  import type { ToolContext, ToolExecutionResult } from "../tools/types.js";
3
4
  import { getLogger } from "../util/logger.js";
4
5
  import { desktopDependencyInstaller } from "./desktop-dependencies.js";
@@ -35,14 +36,29 @@ export class DesktopAutomationLease {
35
36
  ready: () => boolean;
36
37
  ensureReady: (signal: AbortSignal) => Promise<void>;
37
38
  manager: () => DesktopSessionManager;
39
+ notify: () => Promise<unknown>;
38
40
  } = {
39
41
  enabled: () => isVirtualDesktopEnabled(getConfig()),
40
42
  ready: () => desktopDependencyInstaller.getStatus().state === "ready",
41
43
  ensureReady: (signal) => desktopDependencyInstaller.ensureReady(signal),
42
44
  manager: getDesktopSessionManager,
45
+ notify: async () =>
46
+ broadcastMessage({ type: "desktop_activity_changed" }),
43
47
  },
44
48
  ) {}
45
49
 
50
+ get isActive(): boolean {
51
+ return this.owner !== null && !this.owner.abort.signal.aborted;
52
+ }
53
+
54
+ private notify(): void {
55
+ void this.deps
56
+ .notify()
57
+ .catch((err) =>
58
+ log.warn({ err }, "Desktop browser activity notification failed"),
59
+ );
60
+ }
61
+
46
62
  private exclusive<T>(run: () => Promise<T>): Promise<T> {
47
63
  const next = this.tail.then(run, run);
48
64
  this.tail = next.catch(() => {});
@@ -84,9 +100,25 @@ export class DesktopAutomationLease {
84
100
  }
85
101
  this.generation += 1;
86
102
  owner.abort.abort();
87
- void this.exclusive(() => this.release()).catch((err) =>
88
- log.warn({ err }, "Desktop browser session cleanup failed"),
89
- );
103
+ this.releaseCancelledOwner(owner);
104
+ }
105
+
106
+ private releaseCancelledOwner(owner: Owner): void {
107
+ void this.exclusive(async () => {
108
+ if (this.owner === owner) {
109
+ await this.release();
110
+ }
111
+ }).catch((err) => {
112
+ log.warn({ err }, "Desktop browser session cleanup failed");
113
+ const retry = setTimeout(() => this.releaseCancelledOwner(owner), 1_000);
114
+ retry.unref?.();
115
+ });
116
+ }
117
+
118
+ releaseForConversation(conversationId: string): void {
119
+ if (this.owner?.conversationId === conversationId) {
120
+ this.cancel(this.owner);
121
+ }
90
122
  }
91
123
 
92
124
  private bindCancellation(owner: Owner, signal?: AbortSignal): void {
@@ -122,6 +154,10 @@ export class DesktopAutomationLease {
122
154
  throw new Error("The desktop is busy or shutting down");
123
155
  }
124
156
  this.owner = owner;
157
+ owner.abort.signal.addEventListener("abort", () => this.notify(), {
158
+ once: true,
159
+ });
160
+ this.notify();
125
161
  this.bindCancellation(owner, context.signal);
126
162
  const cancel = () => this.cancel(owner);
127
163
  this.watchdog = setInterval(() => {
@@ -30,9 +30,9 @@ import { CHANNEL_IDS } from "../channels/types.js";
30
30
  * The schema passes through what it does not name, so a provider carries its
31
31
  * own fields (Slack's file markers and timezone labels) on this same object
32
32
  * and validates them with its own schema. This is how `SourceMetadataSchema`
33
- * already carries `slackBotMentioned` and the email fields on the wire: one
34
- * object per row, no per-provider sub-envelope, and no second copy of
35
- * anything named here.
33
+ * carries `botMentioned` and the email fields on the wire: one object per
34
+ * row, no per-provider sub-envelope, and no second copy of anything named
35
+ * here.
36
36
  */
37
37
 
38
38
  const providerReactionMetadataSchema = z.object({
@@ -503,6 +503,76 @@ describe("composeFallbackCopy plugin schedule templates", () => {
503
503
  });
504
504
  });
505
505
 
506
+ // ── activity.complete rendering ───────────────────────────────────────
507
+
508
+ describe("activity.complete copy", () => {
509
+ function completeSignal(
510
+ contextPayload: Record<string, unknown>,
511
+ ): NotificationSignal {
512
+ return makeSignal({
513
+ sourceEventName: "activity.complete",
514
+ contextPayload,
515
+ });
516
+ }
517
+
518
+ test("a producer-authored title survives the fallback path", () => {
519
+ // The home feed reads the payload title first; the popup must say the
520
+ // same thing when the classifier was unreachable.
521
+ const copy = composeFallbackCopy(
522
+ completeSignal({
523
+ title: "Skill updated: Weekly Report Export",
524
+ summary: "Added the retry after an expired session.",
525
+ }),
526
+ CHANNELS,
527
+ );
528
+ expect(copy.vellum?.title).toBe("Skill updated: Weekly Report Export");
529
+ expect(copy.vellum?.body).toBe("Added the retry after an expired session.");
530
+ });
531
+
532
+ test("the pass-through's requestedTitle is honored when no title is set", () => {
533
+ const copy = composeFallbackCopy(
534
+ completeSignal({
535
+ requestedTitle: "Nightly export finished",
536
+ summary: "Wrote 12 files to the reports folder.",
537
+ }),
538
+ CHANNELS,
539
+ );
540
+ expect(copy.vellum?.title).toBe("Nightly export finished");
541
+ });
542
+
543
+ test("without an authored title the headline is the summary's first sentence", () => {
544
+ const copy = composeFallbackCopy(
545
+ completeSignal({
546
+ summary:
547
+ "Added the retry after an expired session. Dropped the login step.",
548
+ }),
549
+ CHANNELS,
550
+ );
551
+ expect(copy.vellum?.title).toBe(
552
+ "Added the retry after an expired session.",
553
+ );
554
+ });
555
+
556
+ test("an authored title the normalizer rejects falls back to the derived one", () => {
557
+ const copy = composeFallbackCopy(
558
+ completeSignal({
559
+ title: "Skill\nupdated",
560
+ summary: "Added the retry after an expired session.",
561
+ }),
562
+ CHANNELS,
563
+ );
564
+ expect(copy.vellum?.title).toBe(
565
+ "Added the retry after an expired session.",
566
+ );
567
+ });
568
+
569
+ test("neither a title nor a summary still says something happened", () => {
570
+ const copy = composeFallbackCopy(completeSignal({}), CHANNELS);
571
+ expect(copy.vellum?.title).toBe("Activity complete");
572
+ expect(copy.vellum?.body).toBe("An activity has completed");
573
+ });
574
+ });
575
+
506
576
  // ── deriveTitle ───────────────────────────────────────────────────────
507
577
 
508
578
  describe("deriveTitle", () => {
@@ -346,14 +346,22 @@ const TEMPLATES: Partial<Record<NotificationSourceEventName, CopyTemplate>> = {
346
346
 
347
347
  // Titled by what was done rather than by the kind of event: the summary's
348
348
  // first sentence is the outcome ("Finished the fuel-system diagnostic app"),
349
- // which is what a reader scanning the bell wants to see. A summary without
350
- // one still gets a title that says something happened.
349
+ // which is what a reader scanning the bell wants to see. A producer that
350
+ // names its subject in the payload keeps that name here, read in the same
351
+ // order the home feed reads it, so the headline is the same whether or not
352
+ // the classifier ran. A summary with neither still gets a title that says
353
+ // something happened.
351
354
  "activity.complete": (payload) => {
352
355
  const summary = nonEmpty(
353
356
  typeof payload.summary === "string" ? payload.summary : undefined,
354
357
  );
358
+ const authored = normalizeTitle(
359
+ readPayloadString(payload, "title") ??
360
+ readPayloadString(payload, "requestedTitle") ??
361
+ "",
362
+ );
355
363
  return {
356
- title: summary ? deriveTitle(summary) : "Activity complete",
364
+ title: authored || (summary ? deriveTitle(summary) : "Activity complete"),
357
365
  body: summary ?? "An activity has completed",
358
366
  };
359
367
  },
@@ -1,3 +1,4 @@
1
+ import type { ToolDefinition } from "../providers/types.js";
1
2
  import type {
2
3
  AddMessageOptions,
3
4
  ConversationRow,
@@ -52,6 +53,18 @@ export async function getMessages(
52
53
  return fn(conversationId);
53
54
  }
54
55
 
56
+ /**
57
+ * The tool definitions the conversation's most recent live turn sent to the
58
+ * provider, or `null` when none has been recorded.
59
+ */
60
+ export async function getConversationToolSurface(
61
+ conversationId: string,
62
+ ): Promise<ToolDefinition[] | null> {
63
+ const { getConversationToolSurface: fn } =
64
+ await import("./conversation-tool-surface.js");
65
+ return fn(conversationId);
66
+ }
67
+
55
68
  /** Whether the conversation currently has a turn in flight. */
56
69
  export async function isConversationProcessing(id: string): Promise<boolean> {
57
70
  const { isConversationProcessing: fn } =