vellum 0.2.13 → 0.2.14
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/README.md +32 -0
- package/bun.lock +2 -2
- package/docs/skills.md +4 -4
- package/package.json +2 -2
- package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +213 -3
- package/src/__tests__/app-git-history.test.ts +176 -0
- package/src/__tests__/app-git-service.test.ts +169 -0
- package/src/__tests__/assistant-events-sse-hardening.test.ts +315 -0
- package/src/__tests__/browser-skill-baseline-tool-payload.test.ts +8 -8
- package/src/__tests__/browser-skill-endstate.test.ts +6 -6
- package/src/__tests__/call-bridge.test.ts +105 -13
- package/src/__tests__/call-domain.test.ts +163 -0
- package/src/__tests__/call-orchestrator.test.ts +113 -0
- package/src/__tests__/call-routes-http.test.ts +246 -6
- package/src/__tests__/channel-approval-routes.test.ts +438 -0
- package/src/__tests__/channel-approval.test.ts +266 -0
- package/src/__tests__/channel-approvals.test.ts +393 -0
- package/src/__tests__/channel-delivery-store.test.ts +447 -0
- package/src/__tests__/checker.test.ts +607 -1048
- package/src/__tests__/cli.test.ts +1 -56
- package/src/__tests__/config-schema.test.ts +137 -18
- package/src/__tests__/conflict-intent-tokenization.test.ts +141 -0
- package/src/__tests__/conflict-policy.test.ts +121 -0
- package/src/__tests__/conflict-store.test.ts +2 -0
- package/src/__tests__/contacts-tools.test.ts +3 -3
- package/src/__tests__/contradiction-checker.test.ts +99 -1
- package/src/__tests__/credential-security-invariants.test.ts +22 -6
- package/src/__tests__/credential-vault-unit.test.ts +780 -0
- package/src/__tests__/elevenlabs-client.test.ts +62 -0
- package/src/__tests__/ephemeral-permissions.test.ts +73 -23
- package/src/__tests__/filesystem-tools.test.ts +579 -0
- package/src/__tests__/gateway-only-enforcement.test.ts +114 -4
- package/src/__tests__/handlers-add-trust-rule-metadata.test.ts +202 -0
- package/src/__tests__/handlers-cu-observation-blob.test.ts +2 -1
- package/src/__tests__/handlers-ipc-blob-probe.test.ts +2 -1
- package/src/__tests__/handlers-slack-config.test.ts +2 -1
- package/src/__tests__/handlers-telegram-config.test.ts +855 -0
- package/src/__tests__/handlers-twitter-config.test.ts +141 -1
- package/src/__tests__/hooks-runner.test.ts +6 -2
- package/src/__tests__/host-file-edit-tool.test.ts +124 -0
- package/src/__tests__/host-file-read-tool.test.ts +62 -0
- package/src/__tests__/host-file-write-tool.test.ts +59 -0
- package/src/__tests__/host-shell-tool.test.ts +251 -0
- package/src/__tests__/ingress-reconcile.test.ts +581 -0
- package/src/__tests__/ipc-snapshot.test.ts +100 -41
- package/src/__tests__/ipc-validate.test.ts +50 -0
- package/src/__tests__/key-migration.test.ts +23 -0
- package/src/__tests__/memory-regressions.test.ts +99 -0
- package/src/__tests__/memory-retrieval.benchmark.test.ts +1 -1
- package/src/__tests__/oauth-callback-registry.test.ts +11 -4
- package/src/__tests__/playbook-execution.test.ts +502 -0
- package/src/__tests__/playbook-tools.test.ts +4 -6
- package/src/__tests__/public-ingress-urls.test.ts +34 -0
- package/src/__tests__/qdrant-manager.test.ts +267 -0
- package/src/__tests__/recurrence-engine-rruleset.test.ts +97 -0
- package/src/__tests__/recurrence-engine.test.ts +9 -0
- package/src/__tests__/recurrence-types.test.ts +8 -0
- package/src/__tests__/registry.test.ts +1 -1
- package/src/__tests__/runtime-runs.test.ts +1 -25
- package/src/__tests__/schedule-store.test.ts +16 -14
- package/src/__tests__/schedule-tools.test.ts +83 -0
- package/src/__tests__/scheduler-recurrence.test.ts +111 -10
- package/src/__tests__/secret-allowlist.test.ts +18 -17
- package/src/__tests__/secret-ingress-handler.test.ts +11 -0
- package/src/__tests__/secret-scanner.test.ts +43 -0
- package/src/__tests__/session-conflict-gate.test.ts +442 -6
- package/src/__tests__/session-init.benchmark.test.ts +3 -0
- package/src/__tests__/session-process-bridge.test.ts +242 -0
- package/src/__tests__/session-skill-tools.test.ts +1 -1
- package/src/__tests__/shell-identity.test.ts +256 -0
- package/src/__tests__/skill-projection.benchmark.test.ts +11 -1
- package/src/__tests__/subagent-tools.test.ts +637 -54
- package/src/__tests__/task-management-tools.test.ts +936 -0
- package/src/__tests__/task-runner.test.ts +2 -2
- package/src/__tests__/terminal-tools.test.ts +840 -0
- package/src/__tests__/tool-executor-shell-integration.test.ts +301 -0
- package/src/__tests__/tool-executor.test.ts +85 -151
- package/src/__tests__/tool-permission-simulate-handler.test.ts +336 -0
- package/src/__tests__/trust-store.test.ts +27 -453
- package/src/__tests__/twilio-provider.test.ts +153 -3
- package/src/__tests__/twilio-routes-elevenlabs.test.ts +375 -0
- package/src/__tests__/twilio-routes-twiml.test.ts +4 -4
- package/src/__tests__/twilio-routes.test.ts +17 -262
- package/src/__tests__/twitter-auth-handler.test.ts +2 -1
- package/src/__tests__/twitter-cli-error-shaping.test.ts +208 -0
- package/src/__tests__/twitter-cli-routing.test.ts +252 -0
- package/src/__tests__/twitter-oauth-client.test.ts +209 -0
- package/src/__tests__/workspace-policy.test.ts +213 -0
- package/src/calls/call-bridge.ts +92 -19
- package/src/calls/call-domain.ts +157 -5
- package/src/calls/call-orchestrator.ts +93 -7
- package/src/calls/call-store.ts +6 -0
- package/src/calls/elevenlabs-client.ts +8 -0
- package/src/calls/elevenlabs-config.ts +7 -5
- package/src/calls/twilio-provider.ts +91 -0
- package/src/calls/twilio-routes.ts +32 -37
- package/src/calls/types.ts +3 -1
- package/src/calls/voice-quality.ts +29 -7
- package/src/cli/twitter.ts +200 -21
- package/src/cli.ts +1 -20
- package/src/config/bundled-skills/contacts/tools/contact-merge.ts +52 -4
- package/src/config/bundled-skills/contacts/tools/contact-search.ts +55 -4
- package/src/config/bundled-skills/contacts/tools/contact-upsert.ts +61 -4
- package/src/config/bundled-skills/messaging/SKILL.md +17 -2
- package/src/config/bundled-skills/messaging/tools/messaging-reply.ts +4 -1
- package/src/config/bundled-skills/messaging/tools/messaging-send.ts +5 -1
- package/src/config/bundled-skills/messaging/tools/shared.ts +5 -0
- package/src/config/bundled-skills/phone-calls/SKILL.md +142 -34
- package/src/config/bundled-skills/playbooks/tools/playbook-create.ts +95 -6
- package/src/config/bundled-skills/playbooks/tools/playbook-delete.ts +51 -6
- package/src/config/bundled-skills/playbooks/tools/playbook-list.ts +73 -6
- package/src/config/bundled-skills/playbooks/tools/playbook-update.ts +110 -6
- package/src/config/bundled-skills/public-ingress/SKILL.md +22 -5
- package/src/config/bundled-skills/twitter/SKILL.md +103 -17
- package/src/config/defaults.ts +10 -4
- package/src/config/schema.ts +80 -21
- package/src/config/types.ts +1 -0
- package/src/config/vellum-skills/telegram-setup/SKILL.md +56 -61
- package/src/daemon/assistant-attachments.ts +4 -2
- package/src/daemon/handlers/apps.ts +69 -0
- package/src/daemon/handlers/config.ts +543 -24
- package/src/daemon/handlers/index.ts +1 -0
- package/src/daemon/handlers/sessions.ts +22 -6
- package/src/daemon/handlers/shared.ts +2 -1
- package/src/daemon/handlers/skills.ts +5 -20
- package/src/daemon/ipc-contract-inventory.json +28 -0
- package/src/daemon/ipc-contract.ts +168 -10
- package/src/daemon/ipc-validate.ts +17 -0
- package/src/daemon/lifecycle.ts +2 -0
- package/src/daemon/server.ts +78 -72
- package/src/daemon/session-attachments.ts +1 -1
- package/src/daemon/session-conflict-gate.ts +62 -6
- package/src/daemon/session-notifiers.ts +1 -1
- package/src/daemon/session-process.ts +62 -3
- package/src/daemon/session-tool-setup.ts +1 -2
- package/src/daemon/tls-certs.ts +189 -0
- package/src/daemon/video-thumbnail.ts +5 -3
- package/src/hooks/manager.ts +5 -9
- package/src/memory/app-git-service.ts +295 -0
- package/src/memory/app-store.ts +21 -0
- package/src/memory/conflict-intent.ts +47 -4
- package/src/memory/conflict-policy.ts +73 -0
- package/src/memory/conflict-store.ts +9 -1
- package/src/memory/contradiction-checker.ts +28 -0
- package/src/memory/conversation-key-store.ts +15 -0
- package/src/memory/db.ts +81 -0
- package/src/memory/embedding-local.ts +3 -13
- package/src/memory/external-conversation-store.ts +234 -0
- package/src/memory/job-handlers/conflict.ts +22 -2
- package/src/memory/jobs-worker.ts +67 -28
- package/src/memory/runs-store.ts +54 -7
- package/src/memory/schema.ts +20 -0
- package/src/messaging/provider.ts +9 -0
- package/src/messaging/providers/telegram-bot/adapter.ts +162 -0
- package/src/messaging/providers/telegram-bot/client.ts +104 -0
- package/src/messaging/providers/telegram-bot/types.ts +15 -0
- package/src/messaging/registry.ts +1 -0
- package/src/permissions/checker.ts +48 -44
- package/src/permissions/prompter.ts +0 -4
- package/src/permissions/shell-identity.ts +227 -0
- package/src/permissions/trust-store.ts +76 -53
- package/src/permissions/types.ts +0 -19
- package/src/permissions/workspace-policy.ts +114 -0
- package/src/providers/retry.ts +12 -37
- package/src/runtime/assistant-event-hub.ts +41 -4
- package/src/runtime/channel-approval-parser.ts +60 -0
- package/src/runtime/channel-approval-types.ts +71 -0
- package/src/runtime/channel-approvals.ts +145 -0
- package/src/runtime/gateway-client.ts +16 -0
- package/src/runtime/http-server.ts +29 -9
- package/src/runtime/routes/call-routes.ts +52 -2
- package/src/runtime/routes/channel-routes.ts +296 -16
- package/src/runtime/routes/events-routes.ts +97 -28
- package/src/runtime/routes/run-routes.ts +2 -7
- package/src/runtime/run-orchestrator.ts +0 -3
- package/src/schedule/recurrence-engine.ts +26 -2
- package/src/schedule/recurrence-types.ts +1 -1
- package/src/schedule/schedule-store.ts +12 -3
- package/src/security/secret-scanner.ts +7 -0
- package/src/tasks/ephemeral-permissions.ts +0 -2
- package/src/tasks/task-scheduler.ts +2 -1
- package/src/tools/calls/call-start.ts +8 -0
- package/src/tools/execution-target.ts +21 -0
- package/src/tools/execution-timeout.ts +49 -0
- package/src/tools/executor.ts +6 -135
- package/src/tools/network/web-search.ts +9 -32
- package/src/tools/policy-context.ts +29 -0
- package/src/tools/schedule/update.ts +8 -1
- package/src/tools/terminal/parser.ts +16 -18
- package/src/tools/types.ts +4 -11
- package/src/twitter/oauth-client.ts +102 -0
- package/src/twitter/router.ts +101 -0
- package/src/util/debounce.ts +88 -0
- package/src/util/network-info.ts +47 -0
- package/src/util/platform.ts +29 -4
- package/src/util/promise-guard.ts +37 -0
- package/src/util/retry.ts +98 -0
- package/src/util/truncate.ts +1 -1
- package/src/workspace/git-service.ts +129 -112
- package/src/tools/contacts/contact-merge.ts +0 -55
- package/src/tools/contacts/contact-search.ts +0 -58
- package/src/tools/contacts/contact-upsert.ts +0 -64
- package/src/tools/playbooks/index.ts +0 -4
- package/src/tools/playbooks/playbook-create.ts +0 -96
- package/src/tools/playbooks/playbook-delete.ts +0 -52
- package/src/tools/playbooks/playbook-list.ts +0 -74
- package/src/tools/playbooks/playbook-update.ts +0 -111
|
@@ -0,0 +1,502 @@
|
|
|
1
|
+
import { describe, test, expect, beforeEach, afterAll, mock } from 'bun:test';
|
|
2
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
|
|
6
|
+
const testDir = mkdtempSync(join(tmpdir(), 'playbook-exec-test-'));
|
|
7
|
+
|
|
8
|
+
mock.module('../util/platform.js', () => ({
|
|
9
|
+
getDataDir: () => testDir,
|
|
10
|
+
isMacOS: () => process.platform === 'darwin',
|
|
11
|
+
isLinux: () => process.platform === 'linux',
|
|
12
|
+
isWindows: () => process.platform === 'win32',
|
|
13
|
+
getSocketPath: () => join(testDir, 'test.sock'),
|
|
14
|
+
getPidPath: () => join(testDir, 'test.pid'),
|
|
15
|
+
getDbPath: () => join(testDir, 'test.db'),
|
|
16
|
+
getLogPath: () => join(testDir, 'test.log'),
|
|
17
|
+
ensureDataDir: () => {},
|
|
18
|
+
migrateToDataLayout: () => {},
|
|
19
|
+
migrateToWorkspaceLayout: () => {},
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
mock.module('../util/logger.js', () => ({
|
|
23
|
+
getLogger: () => new Proxy({} as Record<string, unknown>, {
|
|
24
|
+
get: () => () => {},
|
|
25
|
+
}),
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
mock.module('../config/loader.js', () => ({
|
|
29
|
+
getConfig: () => ({ memory: {} }),
|
|
30
|
+
}));
|
|
31
|
+
|
|
32
|
+
mock.module('../memory/jobs-store.js', () => ({
|
|
33
|
+
enqueueMemoryJob: () => {},
|
|
34
|
+
}));
|
|
35
|
+
|
|
36
|
+
import type { Database } from 'bun:sqlite';
|
|
37
|
+
import { initializeDb, getDb, resetDb } from '../memory/db.js';
|
|
38
|
+
import { parsePlaybookStatement } from '../playbooks/types.js';
|
|
39
|
+
import { compilePlaybooks } from '../playbooks/playbook-compiler.js';
|
|
40
|
+
import { memoryItems } from '../memory/schema.js';
|
|
41
|
+
import { computeMemoryFingerprint } from '../memory/fingerprint.js';
|
|
42
|
+
import { v4 as uuid } from 'uuid';
|
|
43
|
+
import type { ToolContext } from '../tools/types.js';
|
|
44
|
+
import { executePlaybookCreate } from '../config/bundled-skills/playbooks/tools/playbook-create.js';
|
|
45
|
+
import { executePlaybookList } from '../config/bundled-skills/playbooks/tools/playbook-list.js';
|
|
46
|
+
import { executePlaybookUpdate } from '../config/bundled-skills/playbooks/tools/playbook-update.js';
|
|
47
|
+
import { executePlaybookDelete } from '../config/bundled-skills/playbooks/tools/playbook-delete.js';
|
|
48
|
+
|
|
49
|
+
initializeDb();
|
|
50
|
+
|
|
51
|
+
afterAll(() => {
|
|
52
|
+
resetDb();
|
|
53
|
+
try { rmSync(testDir, { recursive: true }); } catch { /* best effort */ }
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
function getRawDb(): Database {
|
|
57
|
+
return (getDb() as unknown as { $client: Database }).$client;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function clearPlaybooks(): void {
|
|
61
|
+
getRawDb().run("DELETE FROM memory_items WHERE kind = 'playbook'");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const ctx: ToolContext = {
|
|
65
|
+
workingDir: '/tmp',
|
|
66
|
+
sessionId: 'test-session',
|
|
67
|
+
conversationId: 'test-conversation',
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
function insertPlaybookRow(overrides: Partial<{
|
|
71
|
+
id: string;
|
|
72
|
+
trigger: string;
|
|
73
|
+
action: string;
|
|
74
|
+
channel: string;
|
|
75
|
+
category: string;
|
|
76
|
+
autonomyLevel: string;
|
|
77
|
+
priority: number;
|
|
78
|
+
status: string;
|
|
79
|
+
importance: number;
|
|
80
|
+
scopeId: string;
|
|
81
|
+
invalidAt: number | null;
|
|
82
|
+
statement: string;
|
|
83
|
+
}> = {}): string {
|
|
84
|
+
const db = getDb();
|
|
85
|
+
const id = overrides.id ?? uuid();
|
|
86
|
+
const trigger = overrides.trigger ?? 'test trigger';
|
|
87
|
+
const action = overrides.action ?? 'test action';
|
|
88
|
+
const channel = overrides.channel ?? '*';
|
|
89
|
+
const category = overrides.category ?? 'general';
|
|
90
|
+
const autonomyLevel = overrides.autonomyLevel ?? 'draft';
|
|
91
|
+
const priority = overrides.priority ?? 0;
|
|
92
|
+
const status = overrides.status ?? 'active';
|
|
93
|
+
const importance = overrides.importance ?? 0.8;
|
|
94
|
+
const scopeId = overrides.scopeId ?? 'default';
|
|
95
|
+
const invalidAt = overrides.invalidAt ?? null;
|
|
96
|
+
|
|
97
|
+
const statement = overrides.statement ?? JSON.stringify({
|
|
98
|
+
trigger, action, channel, category, autonomyLevel, priority,
|
|
99
|
+
});
|
|
100
|
+
const subject = `Playbook: ${trigger}`;
|
|
101
|
+
const fingerprint = computeMemoryFingerprint(scopeId, 'playbook', subject, statement);
|
|
102
|
+
const now = Date.now();
|
|
103
|
+
|
|
104
|
+
db.insert(memoryItems).values({
|
|
105
|
+
id,
|
|
106
|
+
kind: 'playbook',
|
|
107
|
+
subject,
|
|
108
|
+
statement,
|
|
109
|
+
status,
|
|
110
|
+
confidence: 0.95,
|
|
111
|
+
importance,
|
|
112
|
+
fingerprint,
|
|
113
|
+
verificationState: 'user_confirmed',
|
|
114
|
+
scopeId,
|
|
115
|
+
firstSeenAt: now,
|
|
116
|
+
lastSeenAt: now,
|
|
117
|
+
lastUsedAt: null,
|
|
118
|
+
invalidAt,
|
|
119
|
+
}).run();
|
|
120
|
+
|
|
121
|
+
return id;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ── parsePlaybookStatement ──────────────────────────────────────────
|
|
125
|
+
|
|
126
|
+
describe('parsePlaybookStatement', () => {
|
|
127
|
+
test('parses valid playbook JSON with all fields', () => {
|
|
128
|
+
const statement = JSON.stringify({
|
|
129
|
+
trigger: 'meeting request',
|
|
130
|
+
action: 'check calendar',
|
|
131
|
+
channel: 'email',
|
|
132
|
+
category: 'scheduling',
|
|
133
|
+
autonomyLevel: 'auto',
|
|
134
|
+
priority: 10,
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
const result = parsePlaybookStatement(statement);
|
|
138
|
+
|
|
139
|
+
expect(result).not.toBeNull();
|
|
140
|
+
expect(result!.trigger).toBe('meeting request');
|
|
141
|
+
expect(result!.action).toBe('check calendar');
|
|
142
|
+
expect(result!.channel).toBe('email');
|
|
143
|
+
expect(result!.category).toBe('scheduling');
|
|
144
|
+
expect(result!.autonomyLevel).toBe('auto');
|
|
145
|
+
expect(result!.priority).toBe(10);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test('applies defaults for missing optional fields', () => {
|
|
149
|
+
const statement = JSON.stringify({
|
|
150
|
+
trigger: 'newsletter',
|
|
151
|
+
action: 'archive it',
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
const result = parsePlaybookStatement(statement);
|
|
155
|
+
|
|
156
|
+
expect(result).not.toBeNull();
|
|
157
|
+
expect(result!.trigger).toBe('newsletter');
|
|
158
|
+
expect(result!.action).toBe('archive it');
|
|
159
|
+
expect(result!.channel).toBe('*');
|
|
160
|
+
expect(result!.category).toBe('general');
|
|
161
|
+
expect(result!.autonomyLevel).toBe('draft');
|
|
162
|
+
expect(result!.priority).toBe(0);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test('returns null for invalid JSON', () => {
|
|
166
|
+
expect(parsePlaybookStatement('not json')).toBeNull();
|
|
167
|
+
expect(parsePlaybookStatement('{broken')).toBeNull();
|
|
168
|
+
expect(parsePlaybookStatement('')).toBeNull();
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test('returns null when trigger is missing', () => {
|
|
172
|
+
const statement = JSON.stringify({ action: 'do something' });
|
|
173
|
+
expect(parsePlaybookStatement(statement)).toBeNull();
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test('returns null when action is missing', () => {
|
|
177
|
+
const statement = JSON.stringify({ trigger: 'test' });
|
|
178
|
+
expect(parsePlaybookStatement(statement)).toBeNull();
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test('returns null when trigger is not a string', () => {
|
|
182
|
+
const statement = JSON.stringify({ trigger: 42, action: 'test' });
|
|
183
|
+
expect(parsePlaybookStatement(statement)).toBeNull();
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test('returns null when action is not a string', () => {
|
|
187
|
+
const statement = JSON.stringify({ trigger: 'test', action: true });
|
|
188
|
+
expect(parsePlaybookStatement(statement)).toBeNull();
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test('defaults invalid autonomyLevel to draft', () => {
|
|
192
|
+
const statement = JSON.stringify({
|
|
193
|
+
trigger: 'test',
|
|
194
|
+
action: 'act',
|
|
195
|
+
autonomyLevel: 'invalid_level',
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
const result = parsePlaybookStatement(statement);
|
|
199
|
+
expect(result).not.toBeNull();
|
|
200
|
+
expect(result!.autonomyLevel).toBe('draft');
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test('defaults non-string channel to wildcard', () => {
|
|
204
|
+
const statement = JSON.stringify({
|
|
205
|
+
trigger: 'test',
|
|
206
|
+
action: 'act',
|
|
207
|
+
channel: 123,
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
const result = parsePlaybookStatement(statement);
|
|
211
|
+
expect(result).not.toBeNull();
|
|
212
|
+
expect(result!.channel).toBe('*');
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test('defaults non-number priority to 0', () => {
|
|
216
|
+
const statement = JSON.stringify({
|
|
217
|
+
trigger: 'test',
|
|
218
|
+
action: 'act',
|
|
219
|
+
priority: 'high',
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
const result = parsePlaybookStatement(statement);
|
|
223
|
+
expect(result).not.toBeNull();
|
|
224
|
+
expect(result!.priority).toBe(0);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test('accepts all valid autonomy levels', () => {
|
|
228
|
+
for (const level of ['auto', 'draft', 'notify'] as const) {
|
|
229
|
+
const statement = JSON.stringify({
|
|
230
|
+
trigger: 'test',
|
|
231
|
+
action: 'act',
|
|
232
|
+
autonomyLevel: level,
|
|
233
|
+
});
|
|
234
|
+
const result = parsePlaybookStatement(statement);
|
|
235
|
+
expect(result).not.toBeNull();
|
|
236
|
+
expect(result!.autonomyLevel).toBe(level);
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
// ── compilePlaybooks ────────────────────────────────────────────────
|
|
242
|
+
|
|
243
|
+
describe('compilePlaybooks', () => {
|
|
244
|
+
beforeEach(clearPlaybooks);
|
|
245
|
+
|
|
246
|
+
test('returns empty result when no playbooks exist', () => {
|
|
247
|
+
const result = compilePlaybooks();
|
|
248
|
+
expect(result.text).toBe('');
|
|
249
|
+
expect(result.totalCount).toBe(0);
|
|
250
|
+
expect(result.includedCount).toBe(0);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
test('compiles a single playbook into action-playbooks block', () => {
|
|
254
|
+
insertPlaybookRow({
|
|
255
|
+
trigger: 'meeting request',
|
|
256
|
+
action: 'check calendar',
|
|
257
|
+
channel: '*',
|
|
258
|
+
autonomyLevel: 'draft',
|
|
259
|
+
priority: 0,
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
const result = compilePlaybooks();
|
|
263
|
+
expect(result.totalCount).toBe(1);
|
|
264
|
+
expect(result.includedCount).toBe(1);
|
|
265
|
+
expect(result.text).toContain('<action-playbooks>');
|
|
266
|
+
expect(result.text).toContain('</action-playbooks>');
|
|
267
|
+
expect(result.text).toContain('WHEN "meeting request"');
|
|
268
|
+
expect(result.text).toContain('all channels');
|
|
269
|
+
expect(result.text).toContain('check calendar');
|
|
270
|
+
expect(result.text).toContain('draft for review');
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
test('sorts playbooks by priority descending', () => {
|
|
274
|
+
insertPlaybookRow({ trigger: 'low priority', action: 'act-low', priority: 1 });
|
|
275
|
+
insertPlaybookRow({ trigger: 'high priority', action: 'act-high', priority: 10 });
|
|
276
|
+
insertPlaybookRow({ trigger: 'medium priority', action: 'act-med', priority: 5 });
|
|
277
|
+
|
|
278
|
+
const result = compilePlaybooks();
|
|
279
|
+
expect(result.includedCount).toBe(3);
|
|
280
|
+
|
|
281
|
+
const lines = result.text.split('\n').filter(l => l.startsWith('- WHEN'));
|
|
282
|
+
expect(lines[0]).toContain('high priority');
|
|
283
|
+
expect(lines[1]).toContain('medium priority');
|
|
284
|
+
expect(lines[2]).toContain('low priority');
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
test('excludes inactive playbooks', () => {
|
|
288
|
+
insertPlaybookRow({ trigger: 'active', action: 'yes', status: 'active' });
|
|
289
|
+
insertPlaybookRow({ trigger: 'superseded', action: 'no', status: 'superseded' });
|
|
290
|
+
|
|
291
|
+
const result = compilePlaybooks();
|
|
292
|
+
expect(result.includedCount).toBe(1);
|
|
293
|
+
expect(result.text).toContain('active');
|
|
294
|
+
expect(result.text).not.toContain('superseded');
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
test('excludes invalidated playbooks', () => {
|
|
298
|
+
insertPlaybookRow({ trigger: 'valid', action: 'yes', invalidAt: null });
|
|
299
|
+
insertPlaybookRow({ trigger: 'invalid', action: 'no', invalidAt: Date.now() });
|
|
300
|
+
|
|
301
|
+
const result = compilePlaybooks();
|
|
302
|
+
expect(result.includedCount).toBe(1);
|
|
303
|
+
expect(result.text).toContain('valid');
|
|
304
|
+
expect(result.text).not.toContain('invalid');
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
test('scopes playbooks by scopeId', () => {
|
|
308
|
+
insertPlaybookRow({ trigger: 'default scope', action: 'yes', scopeId: 'default' });
|
|
309
|
+
insertPlaybookRow({ trigger: 'other scope', action: 'no', scopeId: 'workspace-2' });
|
|
310
|
+
|
|
311
|
+
const defaultResult = compilePlaybooks();
|
|
312
|
+
expect(defaultResult.includedCount).toBe(1);
|
|
313
|
+
expect(defaultResult.text).toContain('default scope');
|
|
314
|
+
|
|
315
|
+
const otherResult = compilePlaybooks({ scopeId: 'workspace-2' });
|
|
316
|
+
expect(otherResult.includedCount).toBe(1);
|
|
317
|
+
expect(otherResult.text).toContain('other scope');
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
test('skips rows with unparseable statements', () => {
|
|
321
|
+
insertPlaybookRow({ trigger: 'good', action: 'ok' });
|
|
322
|
+
// Insert a row with corrupted statement
|
|
323
|
+
insertPlaybookRow({ statement: 'not valid json', trigger: 'bad' });
|
|
324
|
+
|
|
325
|
+
const result = compilePlaybooks();
|
|
326
|
+
expect(result.totalCount).toBe(2);
|
|
327
|
+
expect(result.includedCount).toBe(1);
|
|
328
|
+
expect(result.text).toContain('good');
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
test('renders correct autonomy labels', () => {
|
|
332
|
+
insertPlaybookRow({ trigger: 'auto-trigger', action: 'auto-act', autonomyLevel: 'auto', priority: 3 });
|
|
333
|
+
insertPlaybookRow({ trigger: 'draft-trigger', action: 'draft-act', autonomyLevel: 'draft', priority: 2 });
|
|
334
|
+
insertPlaybookRow({ trigger: 'notify-trigger', action: 'notify-act', autonomyLevel: 'notify', priority: 1 });
|
|
335
|
+
|
|
336
|
+
const result = compilePlaybooks();
|
|
337
|
+
expect(result.text).toContain('execute automatically');
|
|
338
|
+
expect(result.text).toContain('draft for review');
|
|
339
|
+
expect(result.text).toContain('notify only');
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
test('renders channel name for non-wildcard channels', () => {
|
|
343
|
+
insertPlaybookRow({ trigger: 'email rule', action: 'handle', channel: 'email' });
|
|
344
|
+
|
|
345
|
+
const result = compilePlaybooks();
|
|
346
|
+
expect(result.text).toContain('on email');
|
|
347
|
+
expect(result.text).not.toContain('all channels');
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
test('returns empty text when all rows have unparseable statements', () => {
|
|
351
|
+
insertPlaybookRow({ statement: 'garbage1', trigger: 'a' });
|
|
352
|
+
insertPlaybookRow({ statement: 'garbage2', trigger: 'b' });
|
|
353
|
+
|
|
354
|
+
const result = compilePlaybooks();
|
|
355
|
+
expect(result.text).toBe('');
|
|
356
|
+
expect(result.totalCount).toBe(2);
|
|
357
|
+
expect(result.includedCount).toBe(0);
|
|
358
|
+
});
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
// ── Tool edge cases not covered in playbook-tools.test.ts ───────────
|
|
362
|
+
|
|
363
|
+
describe('playbook tool edge cases', () => {
|
|
364
|
+
beforeEach(clearPlaybooks);
|
|
365
|
+
|
|
366
|
+
test('create uses memoryScopeId from context when present', async () => {
|
|
367
|
+
const scopedCtx: ToolContext = { ...ctx, memoryScopeId: 'custom-scope' };
|
|
368
|
+
const result = await executePlaybookCreate({
|
|
369
|
+
trigger: 'scoped trigger',
|
|
370
|
+
action: 'scoped action',
|
|
371
|
+
}, scopedCtx);
|
|
372
|
+
|
|
373
|
+
expect(result.isError).toBe(false);
|
|
374
|
+
expect(result.content).toContain('Playbook created successfully');
|
|
375
|
+
|
|
376
|
+
// Verify it's invisible to default scope
|
|
377
|
+
const defaultList = await executePlaybookList({}, ctx);
|
|
378
|
+
expect(defaultList.content).toContain('No playbooks found');
|
|
379
|
+
|
|
380
|
+
// Visible to custom scope
|
|
381
|
+
const scopedList = await executePlaybookList({}, scopedCtx);
|
|
382
|
+
expect(scopedList.content).toContain('scoped trigger');
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
test('update detects collision with another playbook', async () => {
|
|
386
|
+
const _r1 = await executePlaybookCreate({ trigger: 'trigger A', action: 'action A' }, ctx);
|
|
387
|
+
const r2 = await executePlaybookCreate({ trigger: 'trigger B', action: 'action B' }, ctx);
|
|
388
|
+
|
|
389
|
+
const idB = r2.content.match(/ID: (\S+)/)![1];
|
|
390
|
+
|
|
391
|
+
// Try to update B to match A exactly
|
|
392
|
+
const updateResult = await executePlaybookUpdate({
|
|
393
|
+
playbook_id: idB,
|
|
394
|
+
trigger: 'trigger A',
|
|
395
|
+
action: 'action A',
|
|
396
|
+
}, ctx);
|
|
397
|
+
|
|
398
|
+
expect(updateResult.isError).toBe(true);
|
|
399
|
+
expect(updateResult.content).toContain('already exists');
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
test('delete soft-deletes by setting status to superseded', async () => {
|
|
403
|
+
const createResult = await executePlaybookCreate({
|
|
404
|
+
trigger: 'to delete',
|
|
405
|
+
action: 'remove me',
|
|
406
|
+
}, ctx);
|
|
407
|
+
const id = createResult.content.match(/ID: (\S+)/)![1];
|
|
408
|
+
|
|
409
|
+
await executePlaybookDelete({ playbook_id: id }, ctx);
|
|
410
|
+
|
|
411
|
+
// Row still exists in DB but with superseded status
|
|
412
|
+
const row = getRawDb().query("SELECT status, invalid_at FROM memory_items WHERE id = ?").get(id) as { status: string; invalid_at: number | null };
|
|
413
|
+
expect(row.status).toBe('superseded');
|
|
414
|
+
expect(row.invalid_at).not.toBeNull();
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
test('list returns filtered empty message with filter description', async () => {
|
|
418
|
+
await executePlaybookCreate({ trigger: 'only email', action: 'handle', channel: 'email' }, ctx);
|
|
419
|
+
|
|
420
|
+
const result = await executePlaybookList({ channel: 'slack' }, ctx);
|
|
421
|
+
expect(result.isError).toBe(false);
|
|
422
|
+
expect(result.content).toContain('No playbooks found matching');
|
|
423
|
+
expect(result.content).toContain('slack');
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
test('list sorts by priority descending', async () => {
|
|
427
|
+
await executePlaybookCreate({ trigger: 'low', action: 'act', priority: 1 }, ctx);
|
|
428
|
+
await executePlaybookCreate({ trigger: 'high', action: 'act', priority: 10 }, ctx);
|
|
429
|
+
|
|
430
|
+
const result = await executePlaybookList({}, ctx);
|
|
431
|
+
const lines = result.content.split('\n').filter((l: string) => l.startsWith('- **'));
|
|
432
|
+
expect(lines[0]).toContain('high');
|
|
433
|
+
expect(lines[1]).toContain('low');
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
test('update with no changes still succeeds', async () => {
|
|
437
|
+
const createResult = await executePlaybookCreate({
|
|
438
|
+
trigger: 'unchanged',
|
|
439
|
+
action: 'same',
|
|
440
|
+
}, ctx);
|
|
441
|
+
const id = createResult.content.match(/ID: (\S+)/)![1];
|
|
442
|
+
|
|
443
|
+
const result = await executePlaybookUpdate({ playbook_id: id }, ctx);
|
|
444
|
+
expect(result.isError).toBe(false);
|
|
445
|
+
expect(result.content).toContain('Playbook updated successfully');
|
|
446
|
+
expect(result.content).toContain('unchanged');
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
test('create rejects invalid autonomy_level gracefully by defaulting to draft', async () => {
|
|
450
|
+
const result = await executePlaybookCreate({
|
|
451
|
+
trigger: 'test',
|
|
452
|
+
action: 'test',
|
|
453
|
+
autonomy_level: 'invalid_level',
|
|
454
|
+
}, ctx);
|
|
455
|
+
|
|
456
|
+
expect(result.isError).toBe(false);
|
|
457
|
+
expect(result.content).toContain('Autonomy: draft for review');
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
test('update ignores invalid autonomy_level and keeps current', async () => {
|
|
461
|
+
const createResult = await executePlaybookCreate({
|
|
462
|
+
trigger: 'test',
|
|
463
|
+
action: 'test',
|
|
464
|
+
autonomy_level: 'auto',
|
|
465
|
+
}, ctx);
|
|
466
|
+
const id = createResult.content.match(/ID: (\S+)/)![1];
|
|
467
|
+
|
|
468
|
+
const result = await executePlaybookUpdate({
|
|
469
|
+
playbook_id: id,
|
|
470
|
+
autonomy_level: 'bogus',
|
|
471
|
+
}, ctx);
|
|
472
|
+
|
|
473
|
+
expect(result.isError).toBe(false);
|
|
474
|
+
expect(result.content).toContain('Autonomy: execute automatically');
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
test('combined channel and category filter on list', async () => {
|
|
478
|
+
await executePlaybookCreate({ trigger: 'a', action: 'a', channel: 'email', category: 'triage' }, ctx);
|
|
479
|
+
await executePlaybookCreate({ trigger: 'b', action: 'b', channel: 'slack', category: 'triage' }, ctx);
|
|
480
|
+
await executePlaybookCreate({ trigger: 'c', action: 'c', channel: 'email', category: 'notifications' }, ctx);
|
|
481
|
+
|
|
482
|
+
const result = await executePlaybookList({ channel: 'email', category: 'triage' }, ctx);
|
|
483
|
+
expect(result.isError).toBe(false);
|
|
484
|
+
expect(result.content).toContain('Found 1 playbook');
|
|
485
|
+
expect(result.content).toContain('**a**');
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
test('delete is scoped and does not affect other scopes', async () => {
|
|
489
|
+
const scopedCtx: ToolContext = { ...ctx, memoryScopeId: 'scope-A' };
|
|
490
|
+
const createResult = await executePlaybookCreate({ trigger: 'x', action: 'y' }, scopedCtx);
|
|
491
|
+
const id = createResult.content.match(/ID: (\S+)/)![1];
|
|
492
|
+
|
|
493
|
+
// Try to delete from default scope
|
|
494
|
+
const deleteResult = await executePlaybookDelete({ playbook_id: id }, ctx);
|
|
495
|
+
expect(deleteResult.isError).toBe(true);
|
|
496
|
+
expect(deleteResult.content).toContain('not found');
|
|
497
|
+
|
|
498
|
+
// Delete from correct scope succeeds
|
|
499
|
+
const correctDelete = await executePlaybookDelete({ playbook_id: id }, scopedCtx);
|
|
500
|
+
expect(correctDelete.isError).toBe(false);
|
|
501
|
+
});
|
|
502
|
+
});
|
|
@@ -37,12 +37,10 @@ mock.module('../memory/jobs-store.js', () => ({
|
|
|
37
37
|
import type { Database } from 'bun:sqlite';
|
|
38
38
|
import { initializeDb, getDb, resetDb } from '../memory/db.js';
|
|
39
39
|
import type { ToolContext } from '../tools/types.js';
|
|
40
|
-
import {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
executePlaybookDelete,
|
|
45
|
-
} from '../tools/playbooks/index.js';
|
|
40
|
+
import { executePlaybookCreate } from '../config/bundled-skills/playbooks/tools/playbook-create.js';
|
|
41
|
+
import { executePlaybookList } from '../config/bundled-skills/playbooks/tools/playbook-list.js';
|
|
42
|
+
import { executePlaybookUpdate } from '../config/bundled-skills/playbooks/tools/playbook-update.js';
|
|
43
|
+
import { executePlaybookDelete } from '../config/bundled-skills/playbooks/tools/playbook-delete.js';
|
|
46
44
|
|
|
47
45
|
initializeDb();
|
|
48
46
|
|
|
@@ -77,6 +77,40 @@ describe('getPublicBaseUrl', () => {
|
|
|
77
77
|
expect(() => getPublicBaseUrl({})).toThrow(/No public base URL configured/);
|
|
78
78
|
});
|
|
79
79
|
|
|
80
|
+
test('throws when ingress is explicitly disabled', () => {
|
|
81
|
+
expect(() => getPublicBaseUrl({
|
|
82
|
+
ingress: { enabled: false, publicBaseUrl: 'https://example.com' },
|
|
83
|
+
})).toThrow(/Public ingress is disabled/);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('throws when ingress is explicitly disabled with no URL', () => {
|
|
87
|
+
expect(() => getPublicBaseUrl({
|
|
88
|
+
ingress: { enabled: false, publicBaseUrl: '' },
|
|
89
|
+
})).toThrow(/Public ingress is disabled/);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('returns URL when enabled is undefined (backward compat)', () => {
|
|
93
|
+
const result = getPublicBaseUrl({
|
|
94
|
+
ingress: { enabled: undefined, publicBaseUrl: 'https://example.com' },
|
|
95
|
+
});
|
|
96
|
+
expect(result).toBe('https://example.com');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('returns URL when enabled is true', () => {
|
|
100
|
+
const result = getPublicBaseUrl({
|
|
101
|
+
ingress: { enabled: true, publicBaseUrl: 'https://example.com' },
|
|
102
|
+
});
|
|
103
|
+
expect(result).toBe('https://example.com');
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('falls back to env var when enabled is undefined and no publicBaseUrl', () => {
|
|
107
|
+
process.env.INGRESS_PUBLIC_BASE_URL = 'https://env-fallback.example.com';
|
|
108
|
+
const result = getPublicBaseUrl({
|
|
109
|
+
ingress: { enabled: undefined, publicBaseUrl: '' },
|
|
110
|
+
});
|
|
111
|
+
expect(result).toBe('https://env-fallback.example.com');
|
|
112
|
+
});
|
|
113
|
+
|
|
80
114
|
test('normalizes trailing slashes from ingress.publicBaseUrl', () => {
|
|
81
115
|
const result = getPublicBaseUrl({
|
|
82
116
|
ingress: { publicBaseUrl: 'https://example.com///' },
|