@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.
- package/docs/architecture/memory.md +11 -2
- package/docs/desktop-browser-cli.md +4 -2
- package/node_modules/@vellumai/environments/src/shell.test.ts +21 -0
- package/node_modules/@vellumai/environments/src/shell.ts +24 -0
- package/node_modules/@vellumai/gateway-client/src/inbound-contract.ts +8 -2
- package/openapi.yaml +6 -2
- package/package.json +2 -2
- package/scripts/postinstall.ts +33 -0
- package/scripts/smoke-desktop-browser-cli.ts +1 -0
- package/src/__tests__/agent-loop.test.ts +124 -0
- package/src/__tests__/approval-interception-trust-gates.test.ts +40 -0
- package/src/__tests__/channel-approval.test.ts +9 -14
- package/src/__tests__/conversation-agent-loop.test.ts +25 -0
- package/src/__tests__/db-conversation-tool-surface.test.ts +144 -0
- package/src/__tests__/plugin-import-boundary-guard.test.ts +0 -1
- package/src/__tests__/run-conversation-turn-persistence.test.ts +138 -1
- package/src/__tests__/script-proxy-certs.test.ts +1 -1
- package/src/__tests__/subagent-tool-gate-mode.test.ts +169 -0
- package/src/__tests__/terminal-tools.test.ts +8 -0
- package/src/__tests__/unicode.test.ts +36 -0
- package/src/agent/loop.ts +19 -0
- package/src/api/events/desktop-activity-changed.ts +10 -0
- package/src/api/index.ts +6 -0
- package/src/approvals/approval-primitive.ts +5 -2
- package/src/approvals/scoped-approval-grants.ts +6 -2
- package/src/daemon/__tests__/conversation-tool-setup.test.ts +43 -0
- package/src/daemon/conversation-agent-loop.ts +2 -0
- package/src/daemon/conversation-tool-setup.ts +57 -1
- package/src/daemon/conversation.ts +17 -0
- package/src/daemon/daemon-control.ts +2 -6
- package/src/daemon/orphan-reaper.ts +4 -3
- package/src/daemon/tool-setup-types.ts +6 -0
- package/src/daemon/wake-conversation-ops.ts +38 -15
- package/src/desktop/desktop-automation-lease.test.ts +143 -0
- package/src/desktop/desktop-automation-lease.ts +39 -3
- package/src/messaging/provider-message-metadata.ts +3 -3
- package/src/notifications/__tests__/copy-composer.test.ts +70 -0
- package/src/notifications/copy-composer.ts +11 -3
- package/src/persistence/conversation-plugin-facade.ts +13 -0
- package/src/persistence/conversation-tool-surface.ts +86 -0
- package/src/persistence/migrations/378-create-conversation-tool-surfaces.test.ts +78 -0
- package/src/persistence/migrations/378-create-conversation-tool-surfaces.ts +29 -0
- package/src/persistence/schema/conversation-tool-surfaces.ts +22 -0
- package/src/persistence/schema/index.ts +1 -0
- package/src/persistence/steps.ts +2 -0
- package/src/plugin-api/conversation-turn.ts +31 -7
- package/src/plugin-api/index.ts +9 -1
- package/src/plugin-api/plugin-channel-turn-trust.test.ts +133 -0
- package/src/plugin-api/plugin-channel-turn-trust.ts +71 -0
- package/src/plugins/defaults/memory/AGENTS.md +14 -2
- package/src/plugins/defaults/memory/__tests__/buffer-file.test.ts +320 -0
- package/src/plugins/defaults/memory/__tests__/buffer-format.test.ts +43 -0
- package/src/plugins/defaults/memory/__tests__/fixtures/buffer-appender.ts +17 -0
- package/src/plugins/defaults/memory/__tests__/memory-retrospective-job.test.ts +46 -0
- package/src/plugins/defaults/memory/__tests__/memory-run-evidence.test.ts +161 -0
- package/src/plugins/defaults/memory/buffer-file.ts +354 -0
- package/src/plugins/defaults/memory/buffer-format.ts +40 -0
- package/src/plugins/defaults/memory/context-search/agent-runner.ts +1 -2
- package/src/plugins/defaults/memory/context-search/format.ts +2 -1
- package/src/plugins/defaults/memory/context-search/sources/memory-v2.ts +2 -1
- package/src/plugins/defaults/memory/context-search/sources/workspace.ts +2 -1
- package/src/plugins/defaults/memory/graph/capability-seed.ts +1 -2
- package/src/plugins/defaults/memory/graph/tool-handlers.ts +1 -42
- package/src/plugins/defaults/memory/host-utils.ts +0 -10
- package/src/plugins/defaults/memory/injectors.ts +4 -3
- package/src/plugins/defaults/memory/memory-retrospective-job.ts +55 -181
- package/src/plugins/defaults/memory/memory-run-evidence.ts +213 -0
- package/src/plugins/defaults/memory/substrate/__tests__/consolidation-job.test.ts +407 -99
- package/src/plugins/defaults/memory/substrate/__tests__/consolidation-prompt-flag-gating-guard.test.ts +10 -0
- package/src/plugins/defaults/memory/substrate/__tests__/prompts-consolidation.test.ts +107 -7
- package/src/plugins/defaults/memory/substrate/consolidation-job.ts +307 -86
- package/src/plugins/defaults/memory/substrate/consolidation-tool-surface.ts +34 -0
- package/src/plugins/defaults/memory/substrate/page-index.ts +2 -1
- package/src/plugins/defaults/memory/substrate/prompts/consolidation.ts +89 -49
- package/src/plugins/defaults/memory/substrate/sweep-job.ts +1 -1
- package/src/plugins/defaults/memory/tools.ts +1 -1
- package/src/plugins/defaults/memory/v1/graph/consolidation.ts +2 -2
- package/src/plugins/defaults/memory/v1/graph/extraction.ts +2 -1
- package/src/plugins/defaults/memory/v1/graph/retriever.ts +1 -1
- package/src/plugins/defaults/memory/v2/__tests__/migration.test.ts +5 -0
- package/src/plugins/defaults/memory/v2/__tests__/reranker.test.ts +5 -2
- package/src/plugins/defaults/memory/v2/reranker.ts +2 -1
- package/src/plugins/defaults/memory/v3/__tests__/injection.test.ts +81 -1
- package/src/plugins/defaults/memory/v3/__tests__/orchestrate.test.ts +87 -0
- package/src/plugins/defaults/memory/v3/__tests__/shadow-plugin.test.ts +21 -0
- package/src/plugins/defaults/memory/v3/card.ts +2 -1
- package/src/plugins/defaults/memory/v3/injector.ts +212 -178
- package/src/plugins/defaults/memory/v3/orchestrate.ts +86 -22
- package/src/plugins/defaults/memory/v3/pool-select.ts +10 -7
- package/src/plugins/defaults/memory/v3/sections.ts +2 -1
- package/src/plugins/defaults/memory/v3/shadow-plugin.ts +10 -1
- package/src/plugins/defaults/tool-result-truncate/terminal.ts +1 -46
- package/src/runtime/AGENTS.md +1 -1
- package/src/runtime/__tests__/agent-wake.test.ts +86 -1
- package/src/runtime/agent-wake.ts +20 -4
- package/src/runtime/guardian-action-service.ts +2 -17
- package/src/runtime/guardian-reply-router.ts +1 -8
- package/src/runtime/routes/channel-route-shared.ts +1 -9
- package/src/runtime/routes/desktop-setup-routes.test.ts +2 -2
- package/src/runtime/routes/desktop-setup-routes.ts +7 -3
- package/src/runtime/routes/guardian-approval-interception.ts +24 -0
- package/src/runtime/routes/inbound-message-handler.ts +2 -3
- package/src/runtime/routes/inbound-stages/background-dispatch.test.ts +1 -1
- package/src/runtime/routes/inbound-stages/background-dispatch.ts +7 -4
- package/src/schedule/run-script.ts +2 -2
- package/src/tools/host-terminal/host-shell.ts +12 -6
- package/src/tools/shared/filesystem/file-ops-service.ts +1 -31
- package/src/tools/shared/shell-output.test.ts +10 -0
- package/src/tools/shared/shell-output.ts +14 -2
- package/src/tools/skills/sandbox-runner.ts +13 -2
- package/src/tools/skills/scaffold-managed.ts +2 -1
- package/src/tools/terminal/__tests__/safe-env.test.ts +29 -0
- package/src/tools/terminal/safe-env.ts +30 -1
- package/src/tools/terminal/sanitized-bash.ts +15 -2
- package/src/tools/terminal/shell-launch.test.ts +162 -0
- package/src/tools/terminal/shell.test.ts +29 -0
- package/src/tools/terminal/shell.ts +13 -7
- package/src/util/host-process.test.ts +17 -1
- package/src/util/host-process.ts +24 -0
- 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
|
-
|
|
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 =
|
|
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(
|
|
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
|
-
*
|
|
5
|
-
* timeout/abort, group-kill with `process.kill(-pgid, SIGKILL)` (the bash
|
|
6
|
-
* host_bash tools, and the skill sandbox runner).
|
|
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
|
-
|
|
374
|
-
|
|
375
|
-
|
|
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
|
-
|
|
88
|
-
|
|
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
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
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
|
|
350
|
-
//
|
|
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 } =
|