@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
|
@@ -87,8 +87,17 @@ graph LR
|
|
|
87
87
|
nesting existed still parse, since an unindented body line that is not itself
|
|
88
88
|
entry-shaped is read as a continuation.
|
|
89
89
|
- **Consolidation** (`substrate/consolidation-job.ts`) is a background
|
|
90
|
-
agent conversation that files buffer entries into concept pages
|
|
91
|
-
the aggregate views
|
|
90
|
+
agent conversation that files buffer entries into concept pages and
|
|
91
|
+
rewrites the aggregate views. The agent never writes the buffer: the job
|
|
92
|
+
snapshots `buffer.md`, hands the run its pass's entries verbatim in the
|
|
93
|
+
prompt, and after the run removes exactly those entries itself through
|
|
94
|
+
`buffer-file.ts` (the module that also owns the append path, so the two
|
|
95
|
+
writers of the buffer share one protocol). Removal happens only when the
|
|
96
|
+
run's persisted messages hold a page-writing tool call with a non-error
|
|
97
|
+
result, the same evidence bar the retrospective's cursor advance uses; a
|
|
98
|
+
run that wrote nothing, failed, or timed out leaves the buffer intact for
|
|
99
|
+
the next pass. Entries deferred past the per-run cap and entries appended
|
|
100
|
+
while the run was in flight are therefore never lost. Scheduling
|
|
92
101
|
(`maybeEnqueueGraphMaintenanceJobs` in `jobs-worker.ts`):
|
|
93
102
|
- interval-based (`memory.v2.consolidation_interval_hours`, default 8h),
|
|
94
103
|
skipped below `MIN_BUFFER_LINES_FOR_CONSOLIDATION` (10) **unless** the
|
|
@@ -18,7 +18,7 @@ flowchart LR
|
|
|
18
18
|
|
|
19
19
|
Chrome and its dock launcher share the managed profile and loopback debug port. For Chrome reopened from the dock, the profile singleton lock identifies a candidate PID, validated against the installed executable, profile and loopback debug arguments. Discovery checks `/proc` socket ownership against that browser PID, refuses redirects and validates the returned browser WebSocket endpoint. The connection stays within the container. No CDP endpoint or token is exposed to the renderer.
|
|
20
20
|
|
|
21
|
-
The desktop client bypasses personal-browser discovery, extension reconnect waits and backend fallback. It reuses the existing AX snapshot, DOM element resolution, mouse, keyboard, extraction and credential-fill implementations. Operation-scoped clients borrow the lease's connection; disposing one does not release the lease. `detach`, `close`, cancellation, errors and idle expiry release control. Only `tabs close` closes a Chrome tab.
|
|
21
|
+
The desktop client bypasses personal-browser discovery, extension reconnect waits and backend fallback. It reuses the existing AX snapshot, DOM element resolution, mouse, keyboard, extraction and credential-fill implementations. Operation-scoped clients borrow the lease's connection; disposing one does not release the lease. `detach`, `close`, turn completion, cancellation, errors and idle expiry release control. Turn completion clears the activity indicator immediately and queues browser cleanup before the next automation session; it leaves Chrome and the current page open for the user. Only `tabs close` closes a Chrome tab.
|
|
22
22
|
|
|
23
23
|
`assistant browser --virtual-desktop screenshot --output /tmp/desktop-page.jpg` captures a color page image through CDP `Page.captureScreenshot`. It excludes the browser toolbar and desktop dock. `snapshot` returns semantic page structure. Agents should use these interfaces and let the manager start the desktop, including when older memory notes describe manual Xvnc, `xdotool` or scratch XWD conversion scripts.
|
|
24
24
|
|
|
@@ -26,8 +26,10 @@ Tab IDs are ephemeral numeric aliases for this managed browser's CDP target IDs.
|
|
|
26
26
|
|
|
27
27
|
CDP mouse events use page viewport CSS coordinates. Before dispatching them, the client animates a purple arrow overlay to the same point. The overlay is excluded from accessibility and hit testing. It appears in the existing desktop stream and is removed on release. It does not move the OS pointer, appear in browser toolbar UI or visualize every programmatic DOM operation. Native dialogs and other applications are outside the browser CLI; users can interact with them directly in the expanded desktop. The picture-in-picture preview is view-only. Direct user interaction does not pause automation.
|
|
28
28
|
|
|
29
|
-
The client records key and mouse presses before dispatch. On release it opens a fresh bounded cleanup connection, attaches to the same live targets, releases uncertain held input and removes overlays. A failed cleanup preserves state and the automation slot for retry. Dispatched actions are never automatically retried. Closed targets need no input cleanup. Browser-process loss disposes the client, and later requests discover the replacement process.
|
|
29
|
+
The client records key and mouse presses before dispatch. On release it opens a fresh bounded cleanup connection, attaches to the same live targets, releases uncertain held input and removes overlays. A failed cleanup preserves state and the automation slot for retry. Turn-triggered cancellation retries cleanup automatically until it succeeds or ownership changes. Dispatched actions are never automatically retried. Closed targets need no input cleanup. Browser-process loss disposes the client, and later requests discover the replacement process.
|
|
30
30
|
|
|
31
31
|
`--use-active-tab` and personal browser targeting are rejected with `--virtual-desktop`. Download waiting is unsupported. Browser operations are bounded to two minutes and share the desktop lease's action budget and idle expiry.
|
|
32
32
|
|
|
33
33
|
Validation: focused client tests exercise shared snapshot/click behavior, namespace isolation, stale references, target changes, cancellation and uncertain-input cleanup. Lease tests cover browser ownership, cancellation and cleanup independently of native input. The Linux smoke script exercises real Chrome, the CLI and visible pointer feedback.
|
|
34
|
+
|
|
35
|
+
The desktop header icon pulses in the assistant's avatar accent color while a browser automation lease is active, including between browser commands. It shares the progress indicator's accent and neutral fallback. Reduced-motion clients show a solid accent. Setup status exposes the optional `automationActive` field; `desktop_activity_changed` events refresh it on acquisition and cancellation or release. Installation progress retains its `assistant:self:desktop` sync invalidations. The indicator reads status without starting installation, and reconnects refetch the current lease state.
|
|
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test";
|
|
|
2
2
|
|
|
3
3
|
import {
|
|
4
4
|
buildShellInvocation,
|
|
5
|
+
buildShellSpawnFlags,
|
|
5
6
|
pathListDelimiter,
|
|
6
7
|
prependUniquePathEntries,
|
|
7
8
|
} from "./shell.js";
|
|
@@ -38,6 +39,26 @@ describe("buildShellInvocation", () => {
|
|
|
38
39
|
});
|
|
39
40
|
});
|
|
40
41
|
|
|
42
|
+
describe("buildShellSpawnFlags", () => {
|
|
43
|
+
test("creates a POSIX process group and hides Windows consoles", () => {
|
|
44
|
+
expect(buildShellSpawnFlags("linux")).toEqual({
|
|
45
|
+
detached: true,
|
|
46
|
+
windowsHide: true,
|
|
47
|
+
});
|
|
48
|
+
expect(buildShellSpawnFlags("darwin")).toEqual({
|
|
49
|
+
detached: true,
|
|
50
|
+
windowsHide: true,
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("does not detach Windows children that use piped stdio", () => {
|
|
55
|
+
expect(buildShellSpawnFlags("win32")).toEqual({
|
|
56
|
+
detached: false,
|
|
57
|
+
windowsHide: true,
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
41
62
|
describe("path list handling", () => {
|
|
42
63
|
test("uses the platform delimiter", () => {
|
|
43
64
|
expect(pathListDelimiter("win32")).toBe(";");
|
|
@@ -5,6 +5,11 @@ export interface ShellInvocation {
|
|
|
5
5
|
args: string[];
|
|
6
6
|
}
|
|
7
7
|
|
|
8
|
+
export interface ShellSpawnFlags {
|
|
9
|
+
detached: boolean;
|
|
10
|
+
windowsHide: true;
|
|
11
|
+
}
|
|
12
|
+
|
|
8
13
|
const WINDOWS_UTF8_PREAMBLE =
|
|
9
14
|
"try { [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 } catch {}; " +
|
|
10
15
|
"$OutputEncoding = [System.Text.Encoding]::UTF8; " +
|
|
@@ -43,6 +48,25 @@ export function buildShellInvocation(
|
|
|
43
48
|
return { command: "bash", args: ["-c", "--", command] };
|
|
44
49
|
}
|
|
45
50
|
|
|
51
|
+
/**
|
|
52
|
+
* Spawn flags for assistant-owned shell children (sandbox bash, local
|
|
53
|
+
* host_bash fallback, sanitized CLI bash, skill runners, scheduled scripts).
|
|
54
|
+
*
|
|
55
|
+
* POSIX uses a new process group so timeout/abort can SIGKILL the tree via
|
|
56
|
+
* `-pid`. Windows process trees are torn down with `taskkill /T`, which does
|
|
57
|
+
* not need a detached process. Combining `DETACHED_PROCESS`,
|
|
58
|
+
* `CREATE_NO_WINDOW`, and piped stdio on Windows can emit `close` with exit
|
|
59
|
+
* 0 and empty pipes without running the encoded command.
|
|
60
|
+
*/
|
|
61
|
+
export function buildShellSpawnFlags(
|
|
62
|
+
hostPlatform: NodeJS.Platform = process.platform,
|
|
63
|
+
): ShellSpawnFlags {
|
|
64
|
+
return {
|
|
65
|
+
detached: hostPlatform !== "win32",
|
|
66
|
+
windowsHide: true,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
46
70
|
export function pathListDelimiter(
|
|
47
71
|
hostPlatform: NodeJS.Platform = process.platform,
|
|
48
72
|
): string {
|
|
@@ -83,8 +83,14 @@ export const SourceMetadataSchema = z
|
|
|
83
83
|
clientTimezone: z.string().optional(),
|
|
84
84
|
/** Channel command intent (e.g. Telegram /start). */
|
|
85
85
|
commandIntent: CommandIntentSchema.optional(),
|
|
86
|
-
/**
|
|
87
|
-
|
|
86
|
+
/**
|
|
87
|
+
* Whether the message addresses the assistant by name, an @-mention on
|
|
88
|
+
* the platforms that have one. Stated by the channel's normalizer only
|
|
89
|
+
* where it proved the answer; absent means "not established". Read to
|
|
90
|
+
* decide whether a reply is expected before any text exists, never to
|
|
91
|
+
* admit or deny: admission is the gate's job, upstream of this.
|
|
92
|
+
*/
|
|
93
|
+
botMentioned: z.boolean().optional(),
|
|
88
94
|
/**
|
|
89
95
|
* Slack-specific: the `edited.ts` float-string from a `message_changed`
|
|
90
96
|
* event. Used by the daemon to reject out-of-order edit deliveries whose
|
package/openapi.yaml
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
openapi: 3.1.0
|
|
4
4
|
info:
|
|
5
5
|
title: Vellum Assistant API
|
|
6
|
-
version: 0.12.
|
|
6
|
+
version: 0.12.1
|
|
7
7
|
description: Auto-generated OpenAPI specification for the Vellum Assistant runtime HTTP server.
|
|
8
8
|
servers:
|
|
9
9
|
- url: http://127.0.0.1:7821
|
|
@@ -5042,7 +5042,7 @@ paths:
|
|
|
5042
5042
|
type: string
|
|
5043
5043
|
required:
|
|
5044
5044
|
- type
|
|
5045
|
-
|
|
5045
|
+
botMentioned:
|
|
5046
5046
|
type: boolean
|
|
5047
5047
|
slackEditedTs:
|
|
5048
5048
|
type: string
|
|
@@ -11941,6 +11941,8 @@ paths:
|
|
|
11941
11941
|
- ready
|
|
11942
11942
|
- failed
|
|
11943
11943
|
- unsupported
|
|
11944
|
+
automationActive:
|
|
11945
|
+
type: boolean
|
|
11944
11946
|
stage:
|
|
11945
11947
|
type: string
|
|
11946
11948
|
enum:
|
|
@@ -11971,6 +11973,8 @@ paths:
|
|
|
11971
11973
|
- ready
|
|
11972
11974
|
- failed
|
|
11973
11975
|
- unsupported
|
|
11976
|
+
automationActive:
|
|
11977
|
+
type: boolean
|
|
11974
11978
|
stage:
|
|
11975
11979
|
type: string
|
|
11976
11980
|
enum:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vellumai/assistant",
|
|
3
|
-
"version": "0.12.2-staging.
|
|
3
|
+
"version": "0.12.2-staging.7",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
"test:stable": "EXCLUDE_EXPERIMENTAL=true bun run scripts/test.ts",
|
|
35
35
|
"test:bench": "find src -type f -name '*.benchmark.test.ts' -print0 | xargs -0 -P 1 -I {} bun test {}",
|
|
36
36
|
"test:filesystem-tools": "bash scripts/test-filesystem-tools.sh",
|
|
37
|
-
"postinstall": "
|
|
37
|
+
"postinstall": "bun run scripts/postinstall.ts"
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
40
|
"@agentclientprotocol/sdk": "0.25.0",
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
4
|
+
|
|
5
|
+
const repoRoot = resolve(import.meta.dir, "../..");
|
|
6
|
+
|
|
7
|
+
// Repository setup is best-effort, including installs outside a Git checkout.
|
|
8
|
+
function run(command: string, args: string[]): boolean {
|
|
9
|
+
return (
|
|
10
|
+
spawnSync(command, args, {
|
|
11
|
+
cwd: repoRoot,
|
|
12
|
+
stdio: ["ignore", "inherit", "ignore"],
|
|
13
|
+
windowsHide: true,
|
|
14
|
+
}).status === 0
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
if (!run("git", ["config", "core.hooksPath"])) {
|
|
19
|
+
run("git", ["config", "core.hooksPath", ".githooks"]);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const syncScript = join(repoRoot, "meta", "sync-bundled-copies.ts");
|
|
23
|
+
if (existsSync(syncScript) && run(process.execPath, ["run", syncScript])) {
|
|
24
|
+
run(process.execPath, [
|
|
25
|
+
"run",
|
|
26
|
+
join(
|
|
27
|
+
repoRoot,
|
|
28
|
+
"assistant",
|
|
29
|
+
"scripts",
|
|
30
|
+
"generate-bundled-plugin-packages.ts",
|
|
31
|
+
),
|
|
32
|
+
]);
|
|
33
|
+
}
|
|
@@ -42,6 +42,7 @@ const manager = new DesktopSessionManager({
|
|
|
42
42
|
renderWallpaper: async () => null,
|
|
43
43
|
});
|
|
44
44
|
const control = new DesktopAutomationLease({
|
|
45
|
+
notify: async () => {},
|
|
45
46
|
enabled: () => true,
|
|
46
47
|
ready: () =>
|
|
47
48
|
!coldInstall || desktopDependencyInstaller.getStatus().state === "ready",
|
|
@@ -1977,6 +1977,130 @@ describe("AgentLoop", () => {
|
|
|
1977
1977
|
expect(calls[0].tools).not.toEqual(dummyTools);
|
|
1978
1978
|
});
|
|
1979
1979
|
|
|
1980
|
+
test("onToolsSent observes the exact tool array of every provider call", async () => {
|
|
1981
|
+
const perCall: ToolDefinition[][] = [
|
|
1982
|
+
[
|
|
1983
|
+
{
|
|
1984
|
+
name: "first",
|
|
1985
|
+
description: "First",
|
|
1986
|
+
input_schema: { type: "object" },
|
|
1987
|
+
},
|
|
1988
|
+
],
|
|
1989
|
+
[
|
|
1990
|
+
{
|
|
1991
|
+
name: "second",
|
|
1992
|
+
description: "Second",
|
|
1993
|
+
input_schema: { type: "object" },
|
|
1994
|
+
},
|
|
1995
|
+
],
|
|
1996
|
+
];
|
|
1997
|
+
let resolveCount = 0;
|
|
1998
|
+
const sent: ToolDefinition[][] = [];
|
|
1999
|
+
|
|
2000
|
+
const { provider, calls } = createMockProvider([
|
|
2001
|
+
toolUseResponse("t1", "first", {}),
|
|
2002
|
+
textResponse("Done"),
|
|
2003
|
+
]);
|
|
2004
|
+
const loop = new AgentLoop({
|
|
2005
|
+
provider: provider,
|
|
2006
|
+
systemPrompt: "system",
|
|
2007
|
+
conversationId: "test-conversation",
|
|
2008
|
+
toolExecutor: async () => ({ content: "result", isError: false }),
|
|
2009
|
+
resolveTools: () => perCall[resolveCount++]!,
|
|
2010
|
+
onToolsSent: (tools) => {
|
|
2011
|
+
sent.push(tools);
|
|
2012
|
+
},
|
|
2013
|
+
});
|
|
2014
|
+
await loop.run({
|
|
2015
|
+
requestId: "test-request",
|
|
2016
|
+
messages: [userMessage],
|
|
2017
|
+
onEvent: () => {},
|
|
2018
|
+
trust: { sourceChannel: "vellum", trustClass: "unknown" },
|
|
2019
|
+
});
|
|
2020
|
+
|
|
2021
|
+
// One observation per provider call, each the array that call sent.
|
|
2022
|
+
expect(sent).toHaveLength(2);
|
|
2023
|
+
expect(calls[0].tools).toEqual(sent[0]);
|
|
2024
|
+
expect(calls[1].tools).toEqual(sent[1]);
|
|
2025
|
+
expect(sent.map((tools) => tools.map((t) => t.name))).toEqual([
|
|
2026
|
+
["first"],
|
|
2027
|
+
["second"],
|
|
2028
|
+
]);
|
|
2029
|
+
});
|
|
2030
|
+
|
|
2031
|
+
test("onToolsSent sees the provider-native web_search tool the loop appends", async () => {
|
|
2032
|
+
const dynamicTools: ToolDefinition[] = [
|
|
2033
|
+
{
|
|
2034
|
+
name: "dynamic_tool",
|
|
2035
|
+
description: "Dynamic",
|
|
2036
|
+
input_schema: { type: "object" },
|
|
2037
|
+
},
|
|
2038
|
+
];
|
|
2039
|
+
const sent: ToolDefinition[][] = [];
|
|
2040
|
+
|
|
2041
|
+
const { provider, calls } = createMockProvider([textResponse("Hi")]);
|
|
2042
|
+
Object.assign(provider, { supportsNativeWebSearch: true });
|
|
2043
|
+
const loop = new AgentLoop({
|
|
2044
|
+
provider: provider,
|
|
2045
|
+
systemPrompt: "system",
|
|
2046
|
+
conversationId: "test-conversation",
|
|
2047
|
+
config: { enableNativeWebSearch: true },
|
|
2048
|
+
resolveTools: () => dynamicTools,
|
|
2049
|
+
onToolsSent: (tools) => {
|
|
2050
|
+
sent.push(tools);
|
|
2051
|
+
},
|
|
2052
|
+
});
|
|
2053
|
+
await loop.run({
|
|
2054
|
+
requestId: "test-request",
|
|
2055
|
+
messages: [userMessage],
|
|
2056
|
+
onEvent: () => {},
|
|
2057
|
+
trust: { sourceChannel: "vellum", trustClass: "unknown" },
|
|
2058
|
+
});
|
|
2059
|
+
|
|
2060
|
+
// The resolver never saw web_search; the observer sees the wire array.
|
|
2061
|
+
expect(sent).toHaveLength(1);
|
|
2062
|
+
expect(sent[0]!.map((t) => t.name)).toEqual(["dynamic_tool", "web_search"]);
|
|
2063
|
+
expect(calls[0].tools).toEqual(sent[0]);
|
|
2064
|
+
});
|
|
2065
|
+
|
|
2066
|
+
test("onToolsSent does not fire for a call cancelled before the request leaves", async () => {
|
|
2067
|
+
// A pre-model-call hook runs after tool resolution and before the send;
|
|
2068
|
+
// aborting there is the window Codex flagged: tools resolved, never sent.
|
|
2069
|
+
const controller = new AbortController();
|
|
2070
|
+
registerPlugin({
|
|
2071
|
+
manifest: { name: "abort-before-send", version: "0.0.1" },
|
|
2072
|
+
hooks: {
|
|
2073
|
+
"pre-model-call": async (ctx) => {
|
|
2074
|
+
controller.abort();
|
|
2075
|
+
return ctx;
|
|
2076
|
+
},
|
|
2077
|
+
},
|
|
2078
|
+
});
|
|
2079
|
+
const sent: ToolDefinition[][] = [];
|
|
2080
|
+
const { provider } = createMockProvider([textResponse("never")]);
|
|
2081
|
+
const loop = new AgentLoop({
|
|
2082
|
+
provider: provider,
|
|
2083
|
+
systemPrompt: "system",
|
|
2084
|
+
conversationId: "test-conversation",
|
|
2085
|
+
resolveTools: () => dummyTools,
|
|
2086
|
+
onToolsSent: (tools) => {
|
|
2087
|
+
sent.push(tools);
|
|
2088
|
+
},
|
|
2089
|
+
});
|
|
2090
|
+
|
|
2091
|
+
await loop
|
|
2092
|
+
.run({
|
|
2093
|
+
requestId: "test-request",
|
|
2094
|
+
messages: [userMessage],
|
|
2095
|
+
onEvent: () => {},
|
|
2096
|
+
trust: { sourceChannel: "vellum", trustClass: "unknown" },
|
|
2097
|
+
signal: controller.signal,
|
|
2098
|
+
})
|
|
2099
|
+
.catch(() => {});
|
|
2100
|
+
|
|
2101
|
+
expect(sent).toEqual([]);
|
|
2102
|
+
});
|
|
2103
|
+
|
|
1980
2104
|
// 28. Tool list can change between turns
|
|
1981
2105
|
test("resolveTools can return different tools on each turn", async () => {
|
|
1982
2106
|
const toolsPerTurn: ToolDefinition[][] = [
|
|
@@ -149,6 +149,46 @@ describe("approval interception trust-class gates", () => {
|
|
|
149
149
|
expect(sessionMock).toHaveBeenCalled();
|
|
150
150
|
});
|
|
151
151
|
|
|
152
|
+
test("guardian apr: callback naming no approval action is consumed as a stale button, never routed as text", async () => {
|
|
153
|
+
_anchorPrincipalId = "guardian-principal-1";
|
|
154
|
+
const sessionMock = registerPendingInteraction(
|
|
155
|
+
"req-guardian-retired-1",
|
|
156
|
+
CONVERSATION_ID,
|
|
157
|
+
TOOL_NAME,
|
|
158
|
+
TOOL_INPUT,
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
// The channel normalizers copy callback data into the message content, so
|
|
162
|
+
// a button press arrives with both set to the same string.
|
|
163
|
+
const callbackData = "apr:req-guardian-retired-1:approve_always";
|
|
164
|
+
const result = await handleApprovalInterception({
|
|
165
|
+
conversationId: CONVERSATION_ID,
|
|
166
|
+
callbackData,
|
|
167
|
+
content: callbackData,
|
|
168
|
+
conversationExternalId: REQUESTER_CHAT,
|
|
169
|
+
sourceChannel: "telegram",
|
|
170
|
+
actorExternalId: "guardian-user-1",
|
|
171
|
+
replyCallbackUrl: "https://gateway.test/deliver",
|
|
172
|
+
trustCtx: {
|
|
173
|
+
sourceChannel: "telegram",
|
|
174
|
+
trustClass: "guardian",
|
|
175
|
+
requesterExternalUserId: "guardian-user-1",
|
|
176
|
+
guardianExternalUserId: "guardian-user-1",
|
|
177
|
+
guardianPrincipalId: "guardian-principal-1",
|
|
178
|
+
},
|
|
179
|
+
assistantId: ASSISTANT_ID,
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
expect(result.handled).toBe(true);
|
|
183
|
+
expect(result.type).toBe("stale_ignored");
|
|
184
|
+
// No decision, and no reply: the text parsers never saw the callback.
|
|
185
|
+
expect(sessionMock).not.toHaveBeenCalled();
|
|
186
|
+
expect(deliverSpy).not.toHaveBeenCalled();
|
|
187
|
+
expect(pendingInteractions.getByConversation(CONVERSATION_ID)).toHaveLength(
|
|
188
|
+
1,
|
|
189
|
+
);
|
|
190
|
+
});
|
|
191
|
+
|
|
152
192
|
test("guardian apr: callback with a principal NOT matching the anchor is rejected before any decision", async () => {
|
|
153
193
|
_anchorPrincipalId = "the-real-guardian-principal";
|
|
154
194
|
const sessionMock = registerPendingInteraction(
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
|
|
3
|
-
import type { ApprovalAction } from "../runtime/channel-approval-types.js";
|
|
4
3
|
import { parseCallbackData } from "../runtime/routes/channel-route-shared.js";
|
|
5
4
|
|
|
6
5
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
@@ -19,19 +18,15 @@ describe("parseCallbackData", () => {
|
|
|
19
18
|
expect(result!.source).toBe("button");
|
|
20
19
|
});
|
|
21
20
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
expect(result!.action).toBe(expectedAction as ApprovalAction);
|
|
32
|
-
expect(result!.requestId).toBe("req-123");
|
|
33
|
-
},
|
|
34
|
-
);
|
|
21
|
+
// The parser never maps one action id onto another: an id outside the
|
|
22
|
+
// approval vocabulary is no action, whatever it resembles.
|
|
23
|
+
test.each([
|
|
24
|
+
"apr:req-123:approve_10m",
|
|
25
|
+
"apr:req-123:approve_conversation",
|
|
26
|
+
"apr:req-123:approve_always",
|
|
27
|
+
])('returns null for the unrecognized action id in "%s"', (data) => {
|
|
28
|
+
expect(parseCallbackData(data)).toBeNull();
|
|
29
|
+
});
|
|
35
30
|
|
|
36
31
|
test("every channel's button press attributes as the button modality", () => {
|
|
37
32
|
for (const channel of ["slack", "telegram", "whatsapp", "discord"]) {
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
queueConversationNotice,
|
|
19
19
|
resetConversationNoticesForTests,
|
|
20
20
|
} from "../daemon/conversation-notices.js";
|
|
21
|
+
import { desktopAutomationLease } from "../desktop/desktop-automation-lease.js";
|
|
21
22
|
import { getConversationDirName } from "../persistence/conversation-directories.js";
|
|
22
23
|
import type { UserPromptSubmitContext } from "../plugin-api/types.js";
|
|
23
24
|
import { resetPluginRegistryAndRegisterDefaults } from "../plugins/defaults/index.js";
|
|
@@ -1663,6 +1664,30 @@ describe("session-agent-loop", () => {
|
|
|
1663
1664
|
});
|
|
1664
1665
|
});
|
|
1665
1666
|
|
|
1667
|
+
test("releases desktop control before a completed turn accepts another message", async () => {
|
|
1668
|
+
const events: AssistantEvent[] = [];
|
|
1669
|
+
const ctx = makeCtx();
|
|
1670
|
+
const release = spyOn(desktopAutomationLease, "releaseForConversation");
|
|
1671
|
+
const setProcessing = ctx.setProcessing.bind(ctx);
|
|
1672
|
+
ctx.setProcessing = (processing) => {
|
|
1673
|
+
if (!processing) {
|
|
1674
|
+
expect(release).toHaveBeenCalledWith(ctx.conversationId);
|
|
1675
|
+
}
|
|
1676
|
+
setProcessing(processing);
|
|
1677
|
+
};
|
|
1678
|
+
try {
|
|
1679
|
+
await runAgentLoopImpl(ctx, "hello", "msg-1", (event) =>
|
|
1680
|
+
events.push(event),
|
|
1681
|
+
);
|
|
1682
|
+
expect(events.some((event) => event.type === "message_complete")).toBe(
|
|
1683
|
+
true,
|
|
1684
|
+
);
|
|
1685
|
+
expect(release).toHaveBeenCalledTimes(1);
|
|
1686
|
+
} finally {
|
|
1687
|
+
release.mockRestore();
|
|
1688
|
+
}
|
|
1689
|
+
});
|
|
1690
|
+
|
|
1666
1691
|
describe("tool execution errors via agent loop", () => {
|
|
1667
1692
|
test("error events from agent loop are classified and emitted", async () => {
|
|
1668
1693
|
const events: AssistantEvent[] = [];
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the per-conversation wire tool-surface record
|
|
3
|
+
* (`persistence/conversation-tool-surface.ts`): the resolver records the
|
|
4
|
+
* tools array a live turn sends, fork wakes read it back verbatim, and an
|
|
5
|
+
* unchanged surface is never rewritten.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { Database } from "bun:sqlite";
|
|
9
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
10
|
+
|
|
11
|
+
import { drizzle } from "drizzle-orm/bun-sqlite";
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
getConversationToolSurface,
|
|
15
|
+
hashConversationToolSurface,
|
|
16
|
+
recordConversationToolSurface,
|
|
17
|
+
} from "../persistence/conversation-tool-surface.js";
|
|
18
|
+
import { clearStoredDb, setStoredDb } from "../persistence/db-singleton.js";
|
|
19
|
+
import { migrateCreateConversationToolSurfaces } from "../persistence/migrations/378-create-conversation-tool-surfaces.js";
|
|
20
|
+
import * as schema from "../persistence/schema/index.js";
|
|
21
|
+
import type { ToolDefinition } from "../providers/types.js";
|
|
22
|
+
|
|
23
|
+
let sqlite: Database;
|
|
24
|
+
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
sqlite = new Database(":memory:");
|
|
27
|
+
sqlite.run("PRAGMA foreign_keys = ON");
|
|
28
|
+
sqlite.run(/*sql*/ `CREATE TABLE conversations (id TEXT PRIMARY KEY)`);
|
|
29
|
+
sqlite.query(`INSERT INTO conversations (id) VALUES (?)`).run("conv-1");
|
|
30
|
+
const db = drizzle(sqlite, { schema });
|
|
31
|
+
migrateCreateConversationToolSurfaces(db);
|
|
32
|
+
setStoredDb("main", db, () => sqlite.close());
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
afterEach(() => {
|
|
36
|
+
clearStoredDb("main");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const TOOLS: ToolDefinition[] = [
|
|
40
|
+
{
|
|
41
|
+
name: "remember",
|
|
42
|
+
description: "Save a fact",
|
|
43
|
+
input_schema: { type: "object", properties: { content: {} } },
|
|
44
|
+
},
|
|
45
|
+
{ name: "bell_jingle", description: "Ring", input_schema: {} },
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
function storedRow(): { tools_hash: string; updated_at: number } | null {
|
|
49
|
+
return sqlite
|
|
50
|
+
.query(
|
|
51
|
+
`SELECT tools_hash, updated_at FROM conversation_tool_surfaces WHERE conversation_id = ?`,
|
|
52
|
+
)
|
|
53
|
+
.get("conv-1") as { tools_hash: string; updated_at: number } | null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
describe("recordConversationToolSurface", () => {
|
|
57
|
+
test("stores the array and returns its content hash", () => {
|
|
58
|
+
const hash = recordConversationToolSurface("conv-1", TOOLS);
|
|
59
|
+
|
|
60
|
+
expect(hash).toBe(hashConversationToolSurface(TOOLS));
|
|
61
|
+
expect(storedRow()?.tools_hash).toBe(hash);
|
|
62
|
+
expect(getConversationToolSurface("conv-1")).toEqual(TOOLS);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("replays the exact array, including key order and extra fields", () => {
|
|
66
|
+
const serverTool = {
|
|
67
|
+
type: "web_search_20250305",
|
|
68
|
+
name: "web_search",
|
|
69
|
+
max_uses: 5,
|
|
70
|
+
} as unknown as ToolDefinition;
|
|
71
|
+
const tools = [...TOOLS, serverTool];
|
|
72
|
+
|
|
73
|
+
recordConversationToolSurface("conv-1", tools);
|
|
74
|
+
|
|
75
|
+
expect(JSON.stringify(getConversationToolSurface("conv-1"))).toBe(
|
|
76
|
+
JSON.stringify(tools),
|
|
77
|
+
);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("an unchanged surface with a known hash skips the write", () => {
|
|
81
|
+
const hash = recordConversationToolSurface("conv-1", TOOLS);
|
|
82
|
+
const before = storedRow()!.updated_at;
|
|
83
|
+
sqlite
|
|
84
|
+
.query(
|
|
85
|
+
`UPDATE conversation_tool_surfaces SET updated_at = ? WHERE conversation_id = ?`,
|
|
86
|
+
)
|
|
87
|
+
.run(before - 1000, "conv-1");
|
|
88
|
+
|
|
89
|
+
recordConversationToolSurface("conv-1", TOOLS, hash);
|
|
90
|
+
|
|
91
|
+
expect(storedRow()!.updated_at).toBe(before - 1000);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("an unchanged surface with no known hash compares against the stored row before writing", () => {
|
|
95
|
+
recordConversationToolSurface("conv-1", TOOLS);
|
|
96
|
+
const marker = 42;
|
|
97
|
+
sqlite
|
|
98
|
+
.query(
|
|
99
|
+
`UPDATE conversation_tool_surfaces SET updated_at = ? WHERE conversation_id = ?`,
|
|
100
|
+
)
|
|
101
|
+
.run(marker, "conv-1");
|
|
102
|
+
|
|
103
|
+
// A freshly loaded conversation knows no hash yet; the stored row matches,
|
|
104
|
+
// so nothing is rewritten.
|
|
105
|
+
recordConversationToolSurface("conv-1", TOOLS, undefined);
|
|
106
|
+
|
|
107
|
+
expect(storedRow()!.updated_at).toBe(marker);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("a changed surface overwrites the stored one", () => {
|
|
111
|
+
const first = recordConversationToolSurface("conv-1", TOOLS);
|
|
112
|
+
const changed = TOOLS.slice(0, 1);
|
|
113
|
+
|
|
114
|
+
const second = recordConversationToolSurface("conv-1", changed, first);
|
|
115
|
+
|
|
116
|
+
expect(second).not.toBe(first);
|
|
117
|
+
expect(storedRow()?.tools_hash).toBe(second);
|
|
118
|
+
expect(getConversationToolSurface("conv-1")).toEqual(changed);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe("getConversationToolSurface", () => {
|
|
123
|
+
test("returns null when no turn has recorded a surface", () => {
|
|
124
|
+
expect(getConversationToolSurface("conv-1")).toBeNull();
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("returns null for an unreadable stored payload", () => {
|
|
128
|
+
sqlite
|
|
129
|
+
.query(
|
|
130
|
+
/*sql*/ `INSERT INTO conversation_tool_surfaces (conversation_id, tools_json, tools_hash, updated_at) VALUES (?, ?, ?, ?)`,
|
|
131
|
+
)
|
|
132
|
+
.run("conv-1", "not json", "h", 1);
|
|
133
|
+
|
|
134
|
+
expect(getConversationToolSurface("conv-1")).toBeNull();
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("the row cascades with its conversation", () => {
|
|
138
|
+
recordConversationToolSurface("conv-1", TOOLS);
|
|
139
|
+
|
|
140
|
+
sqlite.query(`DELETE FROM conversations WHERE id = ?`).run("conv-1");
|
|
141
|
+
|
|
142
|
+
expect(getConversationToolSurface("conv-1")).toBeNull();
|
|
143
|
+
});
|
|
144
|
+
});
|
|
@@ -248,7 +248,6 @@ const BASELINE: Record<string, readonly string[]> = {
|
|
|
248
248
|
"../../../util/sqlite-retry.js",
|
|
249
249
|
"../../../util/strip-comment-lines.js",
|
|
250
250
|
"../../../util/truncate.js",
|
|
251
|
-
"../../../util/unicode.js",
|
|
252
251
|
"../../../util/worker-compute.js",
|
|
253
252
|
"../../../util/worker-memory.js",
|
|
254
253
|
"../../../util/worker-process.js",
|