@vellumai/assistant 0.12.2-staging.5 → 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/Dockerfile +7 -7
- 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 +157 -55
- package/package.json +5 -4
- package/scripts/bundled-plugin-packages.ts +154 -0
- package/scripts/generate-bundled-plugin-packages.ts +29 -0
- package/scripts/postinstall.ts +33 -0
- package/scripts/smoke-desktop-browser-cli.ts +1 -0
- package/scripts/test.ts +15 -13
- 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__/managed-store.test.ts +121 -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__/scaffold-managed-skill-tool.test.ts +88 -0
- 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/calls/__tests__/voice-control-protocol.test.ts +24 -2
- package/src/calls/__tests__/voice-session-bridge.test.ts +22 -2
- package/src/calls/voice-control-protocol.ts +13 -4
- package/src/calls/voice-session-bridge.ts +27 -6
- package/src/cli/commands/__tests__/plugins.test.ts +66 -0
- package/src/cli/commands/plugins.ts +50 -18
- package/src/cli/lib/__tests__/install-from-github.test.ts +67 -0
- package/src/cli/lib/__tests__/local-plugin-upgrade.test.ts +169 -0
- package/src/cli/lib/__tests__/plugin-catalog-cache.test.ts +57 -0
- package/src/cli/lib/__tests__/plugin-catalog-platform.test.ts +14 -0
- package/src/cli/lib/__tests__/plugin-catalog-resolve.test.ts +27 -1
- package/src/cli/lib/__tests__/plugin-details.test.ts +9 -2
- package/src/cli/lib/__tests__/plugin-marketplace.test.ts +20 -0
- package/src/cli/lib/__tests__/plugins-install-offline.test.ts +43 -0
- package/src/cli/lib/__tests__/search-plugins.test.ts +31 -0
- package/src/cli/lib/bundled-plugin-packages.json +4 -0
- package/src/cli/lib/bundled-plugin-packages.ts +87 -0
- package/src/cli/lib/diff-plugin.ts +1 -1
- package/src/cli/lib/inspect-plugin.ts +80 -12
- package/src/cli/lib/install-from-github.ts +133 -76
- package/src/cli/lib/plugin-catalog-cache.ts +22 -3
- package/src/cli/lib/plugin-catalog-local.ts +13 -3
- package/src/cli/lib/plugin-catalog-platform.ts +6 -1
- package/src/cli/lib/plugin-catalog-resolve.ts +20 -0
- package/src/cli/lib/plugin-details.ts +12 -0
- package/src/cli/lib/plugin-marketplace.ts +121 -21
- package/src/cli/lib/plugin-pin-history.ts +5 -2
- package/src/cli/lib/search-plugins.ts +58 -16
- package/src/cli/lib/upgrade-plugin.ts +48 -3
- package/src/config/bundled-skills/skill-management/SKILL.md +1 -1
- package/src/config/bundled-skills/skill-management/TOOLS.json +6 -6
- 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 +61 -23
- 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/live-voice/__tests__/live-voice-vad.test.ts +624 -3
- package/src/live-voice/__tests__/session-controls.test.ts +18 -0
- package/src/live-voice/live-voice-session.ts +575 -53
- package/src/live-voice/session-controls.ts +7 -3
- package/src/messaging/provider-message-metadata.ts +3 -3
- package/src/monitoring/plugin-auto-update.ts +6 -0
- 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-retrospective-prompt.test.ts +5 -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-retrospective-prompt.ts +1 -1
- 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/__tests__/plugins-routes.test.ts +102 -0
- 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/runtime/routes/plugins-routes.ts +69 -49
- package/src/schedule/run-script.ts +2 -2
- package/src/skills/managed-store.ts +98 -32
- 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/find-similar-skills.test.ts +3 -0
- package/src/tools/skills/resolve-execute-invocation.ts +24 -0
- package/src/tools/skills/sandbox-runner.ts +13 -2
- package/src/tools/skills/scaffold-managed.ts +18 -14
- 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
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
import { writeFileSync } from "node:fs";
|
|
4
|
+
import { join, resolve } from "node:path";
|
|
5
|
+
|
|
6
|
+
import { buildBundledPluginPackages } from "./bundled-plugin-packages.js";
|
|
7
|
+
|
|
8
|
+
const repoRoot = resolve(import.meta.dir, "../..");
|
|
9
|
+
const output = {
|
|
10
|
+
version: 1,
|
|
11
|
+
packages: buildBundledPluginPackages(repoRoot),
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
if (process.argv.includes("--check")) {
|
|
15
|
+
console.log("✓ Local marketplace packages are valid");
|
|
16
|
+
} else {
|
|
17
|
+
writeFileSync(
|
|
18
|
+
join(
|
|
19
|
+
repoRoot,
|
|
20
|
+
"assistant",
|
|
21
|
+
"src",
|
|
22
|
+
"cli",
|
|
23
|
+
"lib",
|
|
24
|
+
"bundled-plugin-packages.json",
|
|
25
|
+
),
|
|
26
|
+
`${JSON.stringify(output, null, 2)}\n`,
|
|
27
|
+
);
|
|
28
|
+
console.log("✓ Generated bundled local plugin packages");
|
|
29
|
+
}
|
|
@@ -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",
|
package/scripts/test.ts
CHANGED
|
@@ -62,26 +62,28 @@ const EXPERIMENTAL_FILES = new Set([
|
|
|
62
62
|
const KNOWN_BROKEN_FILES = new Set<string>([]);
|
|
63
63
|
|
|
64
64
|
// ---------------------------------------------------------------------------
|
|
65
|
-
//
|
|
65
|
+
// Bundled catalog sync
|
|
66
66
|
// ---------------------------------------------------------------------------
|
|
67
67
|
|
|
68
|
-
// Ensure
|
|
69
|
-
//
|
|
70
|
-
//
|
|
71
|
-
|
|
72
|
-
// (e.g. worktrees) postinstall never fires, so the bundled copy can be missing
|
|
73
|
-
// and feature-flag-registry-bundled.test.ts fails. Running the sync here is
|
|
74
|
-
// idempotent and cheap (two file copies).
|
|
75
|
-
function syncFeatureFlagRegistry(): void {
|
|
68
|
+
// Ensure generated catalog inputs exist before tests. Locally, postinstall may
|
|
69
|
+
// not run in worktrees that share node_modules, so the static JSON imports can
|
|
70
|
+
// otherwise be missing.
|
|
71
|
+
function syncBundledCatalog(): void {
|
|
76
72
|
const syncScript = join("..", "meta", "sync-bundled-copies.ts");
|
|
77
73
|
if (!existsSync(syncScript)) {
|
|
78
74
|
return;
|
|
79
75
|
}
|
|
80
|
-
Bun.spawnSync(["bun", "run", "
|
|
81
|
-
cwd:
|
|
76
|
+
const result = Bun.spawnSync(["bun", "run", "sync:bundled-catalog"], {
|
|
77
|
+
cwd: process.cwd(),
|
|
82
78
|
stdout: "ignore",
|
|
83
|
-
stderr: "
|
|
79
|
+
stderr: "pipe",
|
|
80
|
+
windowsHide: true,
|
|
84
81
|
});
|
|
82
|
+
if (result.exitCode !== 0) {
|
|
83
|
+
throw new Error(
|
|
84
|
+
`Failed to generate bundled catalog inputs: ${result.stderr.toString()}`,
|
|
85
|
+
);
|
|
86
|
+
}
|
|
85
87
|
}
|
|
86
88
|
|
|
87
89
|
// ---------------------------------------------------------------------------
|
|
@@ -514,7 +516,7 @@ async function buildMigratedFixture(outWorkspaceDir: string): Promise<void> {
|
|
|
514
516
|
// ---------------------------------------------------------------------------
|
|
515
517
|
|
|
516
518
|
async function main(): Promise<void> {
|
|
517
|
-
|
|
519
|
+
syncBundledCatalog();
|
|
518
520
|
|
|
519
521
|
let testFiles = collectTestFiles();
|
|
520
522
|
if (testFiles.length === 0) {
|
|
@@ -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
|
+
});
|
|
@@ -332,6 +332,127 @@ describe("createManagedSkill", () => {
|
|
|
332
332
|
expect(content).toContain("Overwritten");
|
|
333
333
|
});
|
|
334
334
|
|
|
335
|
+
test("an overwrite keeps the frontmatter the call omits and replaces the body", () => {
|
|
336
|
+
createManagedSkill({
|
|
337
|
+
id: "kept",
|
|
338
|
+
name: "Kept",
|
|
339
|
+
description: "V1",
|
|
340
|
+
bodyMarkdown: "Old body.",
|
|
341
|
+
emoji: "📊",
|
|
342
|
+
includes: ["csv-basics"],
|
|
343
|
+
activationHints: ["user asks for the weekly report"],
|
|
344
|
+
avoidWhen: ["the report is monthly"],
|
|
345
|
+
category: "productivity",
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
const result = createManagedSkill({
|
|
349
|
+
id: "kept",
|
|
350
|
+
name: "Kept",
|
|
351
|
+
description: "V2",
|
|
352
|
+
bodyMarkdown: "New body.",
|
|
353
|
+
overwrite: true,
|
|
354
|
+
});
|
|
355
|
+
expect(result.created).toBe(true);
|
|
356
|
+
|
|
357
|
+
const catalog = loadSkillCatalog(undefined, [join(TEST_DIR, "skills")]);
|
|
358
|
+
const skill = catalog.find((s) => s.id === "kept")!;
|
|
359
|
+
expect(skill.description).toBe("V2");
|
|
360
|
+
expect(skill.emoji).toBe("📊");
|
|
361
|
+
expect(skill.includes).toEqual(["csv-basics"]);
|
|
362
|
+
expect(skill.activationHints).toEqual(["user asks for the weekly report"]);
|
|
363
|
+
expect(skill.avoidWhen).toEqual(["the report is monthly"]);
|
|
364
|
+
expect(skill.category).toBe("productivity");
|
|
365
|
+
expect(readFileSync(result.path, "utf-8")).toContain("New body.");
|
|
366
|
+
expect(readFileSync(result.path, "utf-8")).not.toContain("Old body.");
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
test("an overwrite carries through frontmatter the tool does not own", () => {
|
|
370
|
+
const dir = join(TEST_DIR, "skills", "hand-edited");
|
|
371
|
+
mkdirSync(dir, { recursive: true });
|
|
372
|
+
writeFileSync(
|
|
373
|
+
join(dir, "SKILL.md"),
|
|
374
|
+
[
|
|
375
|
+
"---",
|
|
376
|
+
'name: "Hand Edited"',
|
|
377
|
+
'description: "Has fields the tool never writes"',
|
|
378
|
+
"metadata:",
|
|
379
|
+
" custom: kept",
|
|
380
|
+
" vellum:",
|
|
381
|
+
' display-name: "Hand Edited Skill"',
|
|
382
|
+
" platforms:",
|
|
383
|
+
" - macos",
|
|
384
|
+
" always-candidate: true",
|
|
385
|
+
' emoji: "📊"',
|
|
386
|
+
"---",
|
|
387
|
+
"",
|
|
388
|
+
"Old body.",
|
|
389
|
+
"",
|
|
390
|
+
].join("\n"),
|
|
391
|
+
);
|
|
392
|
+
|
|
393
|
+
const result = createManagedSkill({
|
|
394
|
+
id: "hand-edited",
|
|
395
|
+
name: "Hand Edited",
|
|
396
|
+
description: "V2",
|
|
397
|
+
bodyMarkdown: "New body.",
|
|
398
|
+
overwrite: true,
|
|
399
|
+
category: "productivity",
|
|
400
|
+
});
|
|
401
|
+
expect(result.created).toBe(true);
|
|
402
|
+
|
|
403
|
+
const fm = parseYaml(
|
|
404
|
+
readFileSync(result.path, "utf-8").match(/^---\n([\s\S]*?)\n---/)![1],
|
|
405
|
+
);
|
|
406
|
+
expect(fm.metadata.custom).toBe("kept");
|
|
407
|
+
expect(fm.metadata.vellum["display-name"]).toBe("Hand Edited Skill");
|
|
408
|
+
expect(fm.metadata.vellum.platforms).toEqual(["macos"]);
|
|
409
|
+
expect(fm.metadata.vellum["always-candidate"]).toBe(true);
|
|
410
|
+
expect(fm.metadata.vellum.emoji).toBe("📊");
|
|
411
|
+
expect(fm.metadata.vellum.category).toBe("productivity");
|
|
412
|
+
const skill = loadSkillCatalog(undefined, [join(TEST_DIR, "skills")]).find(
|
|
413
|
+
(s) => s.id === "hand-edited",
|
|
414
|
+
)!;
|
|
415
|
+
expect(skill.displayName).toBe("Hand Edited Skill");
|
|
416
|
+
expect(skill.platforms).toEqual(["macos"]);
|
|
417
|
+
expect(skill.alwaysCandidate).toBe(true);
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
test("an overwrite replaces a field it passes and clears one passed empty", () => {
|
|
421
|
+
createManagedSkill({
|
|
422
|
+
id: "patched",
|
|
423
|
+
name: "Patched",
|
|
424
|
+
description: "V1",
|
|
425
|
+
bodyMarkdown: "Body.",
|
|
426
|
+
emoji: "📊",
|
|
427
|
+
includes: ["csv-basics"],
|
|
428
|
+
activationHints: ["old hint"],
|
|
429
|
+
avoidWhen: ["the report is monthly"],
|
|
430
|
+
category: "productivity",
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
createManagedSkill({
|
|
434
|
+
id: "patched",
|
|
435
|
+
name: "Patched",
|
|
436
|
+
description: "V2",
|
|
437
|
+
bodyMarkdown: "Body.",
|
|
438
|
+
overwrite: true,
|
|
439
|
+
emoji: "🚀",
|
|
440
|
+
activationHints: ["new hint"],
|
|
441
|
+
avoidWhen: [],
|
|
442
|
+
category: "",
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
const catalog = loadSkillCatalog(undefined, [join(TEST_DIR, "skills")]);
|
|
446
|
+
const skill = catalog.find((s) => s.id === "patched")!;
|
|
447
|
+
expect(skill.emoji).toBe("🚀");
|
|
448
|
+
expect(skill.activationHints).toEqual(["new hint"]);
|
|
449
|
+
// Omitted: kept.
|
|
450
|
+
expect(skill.includes).toEqual(["csv-basics"]);
|
|
451
|
+
// Passed empty: cleared.
|
|
452
|
+
expect(skill.avoidWhen).toBeUndefined();
|
|
453
|
+
expect(skill.category).toBeUndefined();
|
|
454
|
+
});
|
|
455
|
+
|
|
335
456
|
test("writes category to frontmatter and round-trips through catalog load", () => {
|
|
336
457
|
createManagedSkill({
|
|
337
458
|
id: "categorized-skill",
|
|
@@ -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",
|