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,242 @@
|
|
|
1
|
+
import { describe, test, expect, beforeEach, mock } from 'bun:test';
|
|
2
|
+
import { mkdtempSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
|
|
6
|
+
const testDir = mkdtempSync(join(tmpdir(), 'session-process-bridge-test-'));
|
|
7
|
+
|
|
8
|
+
// ── Platform + logger mocks ─────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
mock.module('../util/platform.js', () => ({
|
|
11
|
+
getDataDir: () => testDir,
|
|
12
|
+
isMacOS: () => process.platform === 'darwin',
|
|
13
|
+
isLinux: () => process.platform === 'linux',
|
|
14
|
+
isWindows: () => process.platform === 'win32',
|
|
15
|
+
getSocketPath: () => join(testDir, 'test.sock'),
|
|
16
|
+
getPidPath: () => join(testDir, 'test.pid'),
|
|
17
|
+
getDbPath: () => join(testDir, 'test.db'),
|
|
18
|
+
getLogPath: () => join(testDir, 'test.log'),
|
|
19
|
+
ensureDataDir: () => {},
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
mock.module('../util/logger.js', () => ({
|
|
23
|
+
getLogger: () =>
|
|
24
|
+
new Proxy({} as Record<string, unknown>, {
|
|
25
|
+
get: () => () => {},
|
|
26
|
+
}),
|
|
27
|
+
}));
|
|
28
|
+
|
|
29
|
+
mock.module('../config/loader.js', () => ({
|
|
30
|
+
getConfig: () => ({
|
|
31
|
+
apiKeys: { anthropic: 'test-key' },
|
|
32
|
+
model: 'claude-sonnet-4-20250514',
|
|
33
|
+
provider: 'anthropic',
|
|
34
|
+
memory: { enabled: false },
|
|
35
|
+
calls: { enabled: false },
|
|
36
|
+
}),
|
|
37
|
+
}));
|
|
38
|
+
|
|
39
|
+
// ── Mock the call bridge ─────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
import type { CallBridgeResult } from '../calls/call-bridge.js';
|
|
42
|
+
|
|
43
|
+
const mockTryRouteCallMessage = mock(
|
|
44
|
+
(_convId: string, _text: string, _msgId?: string): Promise<CallBridgeResult> =>
|
|
45
|
+
Promise.resolve({ handled: false, reason: 'no_active_call' }),
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
mock.module('../calls/call-bridge.js', () => ({
|
|
49
|
+
tryRouteCallMessage: (...args: [string, string, string?]) => mockTryRouteCallMessage(...args),
|
|
50
|
+
}));
|
|
51
|
+
|
|
52
|
+
// ── Mock slash resolution ────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
mock.module('./session-slash.js', () => ({
|
|
55
|
+
resolveSlash: (content: string) => ({ kind: 'passthrough' as const, content }),
|
|
56
|
+
}));
|
|
57
|
+
|
|
58
|
+
// ── Import after mocks ──────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
import type { ServerMessage } from '../daemon/ipc-protocol.js';
|
|
61
|
+
import type { ProcessSessionContext } from '../daemon/session-process.js';
|
|
62
|
+
import { processMessage, drainQueue } from '../daemon/session-process.js';
|
|
63
|
+
import { MessageQueue } from '../daemon/session-queue-manager.js';
|
|
64
|
+
|
|
65
|
+
// ── Session mock factory ─────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
function createMockSession(overrides?: Partial<ProcessSessionContext>): ProcessSessionContext {
|
|
68
|
+
return {
|
|
69
|
+
conversationId: 'test-conv',
|
|
70
|
+
messages: [],
|
|
71
|
+
processing: false,
|
|
72
|
+
abortController: null,
|
|
73
|
+
currentRequestId: undefined,
|
|
74
|
+
queue: new MessageQueue(),
|
|
75
|
+
traceEmitter: {
|
|
76
|
+
emit: () => {},
|
|
77
|
+
} as unknown as ProcessSessionContext['traceEmitter'],
|
|
78
|
+
persistUserMessage: mock((_content: string, _attachments: unknown[], _requestId?: string) => 'mock-msg-id'),
|
|
79
|
+
runAgentLoop: mock(async () => {}),
|
|
80
|
+
...overrides,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ── Tests ────────────────────────────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
describe('session-process bridge consumption', () => {
|
|
87
|
+
beforeEach(() => {
|
|
88
|
+
mockTryRouteCallMessage.mockReset();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// ── Direct processMessage path ───────────────────────────────
|
|
92
|
+
|
|
93
|
+
test('processMessage emits assistant_text_delta + message_complete when bridge consumes with userFacingText', async () => {
|
|
94
|
+
mockTryRouteCallMessage.mockResolvedValue({
|
|
95
|
+
handled: true,
|
|
96
|
+
userFacingText: 'Instruction relayed to active call.',
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
const events: ServerMessage[] = [];
|
|
100
|
+
const onEvent = (msg: ServerMessage) => events.push(msg);
|
|
101
|
+
const session = createMockSession();
|
|
102
|
+
|
|
103
|
+
await processMessage(session, 'ask about pricing', [], onEvent);
|
|
104
|
+
|
|
105
|
+
// Should have emitted text delta then message_complete
|
|
106
|
+
const textDelta = events.find((e) => e.type === 'assistant_text_delta');
|
|
107
|
+
expect(textDelta).toBeDefined();
|
|
108
|
+
expect((textDelta as { text: string }).text).toBe('Instruction relayed to active call.');
|
|
109
|
+
|
|
110
|
+
const complete = events.find((e) => e.type === 'message_complete');
|
|
111
|
+
expect(complete).toBeDefined();
|
|
112
|
+
|
|
113
|
+
// Should NOT have called runAgentLoop
|
|
114
|
+
expect(session.runAgentLoop).not.toHaveBeenCalled();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('processMessage emits failure text when bridge consumes with failure userFacingText', async () => {
|
|
118
|
+
mockTryRouteCallMessage.mockResolvedValue({
|
|
119
|
+
handled: true,
|
|
120
|
+
reason: 'instruction_relay_failed',
|
|
121
|
+
userFacingText: 'Failed to relay instruction to the active call.',
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
const events: ServerMessage[] = [];
|
|
125
|
+
const onEvent = (msg: ServerMessage) => events.push(msg);
|
|
126
|
+
const session = createMockSession();
|
|
127
|
+
|
|
128
|
+
await processMessage(session, 'change the topic', [], onEvent);
|
|
129
|
+
|
|
130
|
+
const textDelta = events.find((e) => e.type === 'assistant_text_delta');
|
|
131
|
+
expect(textDelta).toBeDefined();
|
|
132
|
+
expect((textDelta as { text: string }).text).toBe('Failed to relay instruction to the active call.');
|
|
133
|
+
|
|
134
|
+
const complete = events.find((e) => e.type === 'message_complete');
|
|
135
|
+
expect(complete).toBeDefined();
|
|
136
|
+
|
|
137
|
+
// Only one message_complete
|
|
138
|
+
const completeCount = events.filter((e) => e.type === 'message_complete').length;
|
|
139
|
+
expect(completeCount).toBe(1);
|
|
140
|
+
|
|
141
|
+
expect(session.runAgentLoop).not.toHaveBeenCalled();
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('processMessage skips text delta when bridge consumes without userFacingText', async () => {
|
|
145
|
+
mockTryRouteCallMessage.mockResolvedValue({
|
|
146
|
+
handled: true,
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
const events: ServerMessage[] = [];
|
|
150
|
+
const onEvent = (msg: ServerMessage) => events.push(msg);
|
|
151
|
+
const session = createMockSession();
|
|
152
|
+
|
|
153
|
+
await processMessage(session, 'hello', [], onEvent);
|
|
154
|
+
|
|
155
|
+
const textDelta = events.find((e) => e.type === 'assistant_text_delta');
|
|
156
|
+
expect(textDelta).toBeUndefined();
|
|
157
|
+
|
|
158
|
+
const complete = events.find((e) => e.type === 'message_complete');
|
|
159
|
+
expect(complete).toBeDefined();
|
|
160
|
+
|
|
161
|
+
expect(session.runAgentLoop).not.toHaveBeenCalled();
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test('processMessage falls through to agent loop when bridge does not consume', async () => {
|
|
165
|
+
mockTryRouteCallMessage.mockResolvedValue({
|
|
166
|
+
handled: false,
|
|
167
|
+
reason: 'no_active_call',
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
const events: ServerMessage[] = [];
|
|
171
|
+
const onEvent = (msg: ServerMessage) => events.push(msg);
|
|
172
|
+
const session = createMockSession();
|
|
173
|
+
|
|
174
|
+
await processMessage(session, 'normal message', [], onEvent);
|
|
175
|
+
|
|
176
|
+
expect(session.runAgentLoop).toHaveBeenCalled();
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
// ── Queued routeOrProcess path ───────────────────────────────
|
|
180
|
+
|
|
181
|
+
test('drainQueue emits assistant_text_delta + message_complete for bridge-consumed queued message', async () => {
|
|
182
|
+
mockTryRouteCallMessage.mockResolvedValue({
|
|
183
|
+
handled: true,
|
|
184
|
+
userFacingText: 'Instruction relayed to active call.',
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
const events: ServerMessage[] = [];
|
|
188
|
+
const onEvent = (msg: ServerMessage) => events.push(msg);
|
|
189
|
+
const session = createMockSession({ processing: true });
|
|
190
|
+
|
|
191
|
+
// Enqueue a message
|
|
192
|
+
session.queue.push({
|
|
193
|
+
content: 'ask about pricing',
|
|
194
|
+
attachments: [],
|
|
195
|
+
requestId: 'req-1',
|
|
196
|
+
onEvent,
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
drainQueue(session);
|
|
200
|
+
|
|
201
|
+
// Wait for async routeOrProcess
|
|
202
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
203
|
+
|
|
204
|
+
const textDelta = events.find((e) => e.type === 'assistant_text_delta');
|
|
205
|
+
expect(textDelta).toBeDefined();
|
|
206
|
+
expect((textDelta as { text: string }).text).toBe('Instruction relayed to active call.');
|
|
207
|
+
|
|
208
|
+
// message_complete (from dequeue + bridge consumption — only one expected for this request)
|
|
209
|
+
const completeEvents = events.filter((e) => e.type === 'message_complete');
|
|
210
|
+
expect(completeEvents.length).toBe(1);
|
|
211
|
+
|
|
212
|
+
expect(session.runAgentLoop).not.toHaveBeenCalled();
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test('drainQueue emits failure text for bridge-consumed queued message with relay failure', async () => {
|
|
216
|
+
mockTryRouteCallMessage.mockResolvedValue({
|
|
217
|
+
handled: true,
|
|
218
|
+
reason: 'instruction_relay_failed',
|
|
219
|
+
userFacingText: 'Failed to relay instruction to the active call.',
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
const events: ServerMessage[] = [];
|
|
223
|
+
const onEvent = (msg: ServerMessage) => events.push(msg);
|
|
224
|
+
const session = createMockSession({ processing: true });
|
|
225
|
+
|
|
226
|
+
session.queue.push({
|
|
227
|
+
content: 'change the topic',
|
|
228
|
+
attachments: [],
|
|
229
|
+
requestId: 'req-2',
|
|
230
|
+
onEvent,
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
drainQueue(session);
|
|
234
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
235
|
+
|
|
236
|
+
const textDelta = events.find((e) => e.type === 'assistant_text_delta');
|
|
237
|
+
expect(textDelta).toBeDefined();
|
|
238
|
+
expect((textDelta as { text: string }).text).toBe('Failed to relay instruction to the active call.');
|
|
239
|
+
|
|
240
|
+
expect(session.runAgentLoop).not.toHaveBeenCalled();
|
|
241
|
+
});
|
|
242
|
+
});
|
|
@@ -2175,7 +2175,7 @@ describe('hash change re-prompt regressions (PR 35)', () => {
|
|
|
2175
2175
|
// Version hash plumbing regression tests
|
|
2176
2176
|
// Verify that createSkillToolsFromManifest receives the computed hash and
|
|
2177
2177
|
// that projected tools carry ownerSkillVersionHash, which downstream
|
|
2178
|
-
// components (executor.ts) use to build
|
|
2178
|
+
// components (executor.ts) use to build policy context.
|
|
2179
2179
|
// ---------------------------------------------------------------------------
|
|
2180
2180
|
|
|
2181
2181
|
describe('version hash plumbing to projected tools', () => {
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import { describe, test, expect, beforeAll } from 'bun:test';
|
|
2
|
+
import { analyzeShellCommand, deriveShellActionKeys, buildShellCommandCandidates, buildShellAllowlistOptions } from '../permissions/shell-identity.js';
|
|
3
|
+
import { parse } from '../tools/terminal/parser.js';
|
|
4
|
+
|
|
5
|
+
describe('analyzeShellCommand', () => {
|
|
6
|
+
beforeAll(async () => {
|
|
7
|
+
// Warm up the parser (loads WASM)
|
|
8
|
+
await parse('echo warmup');
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test('parses simple command into one actionable segment', async () => {
|
|
12
|
+
const result = await analyzeShellCommand('ls -la');
|
|
13
|
+
expect(result.segments).toHaveLength(1);
|
|
14
|
+
expect(result.segments[0].program).toBe('ls');
|
|
15
|
+
expect(result.segments[0].args).toContain('-la');
|
|
16
|
+
expect(result.hasOpaqueConstructs).toBe(false);
|
|
17
|
+
expect(result.dangerousPatterns).toHaveLength(0);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test('parses chained command into multiple segments with operators', async () => {
|
|
21
|
+
const result = await analyzeShellCommand('cd /tmp && git status');
|
|
22
|
+
expect(result.segments).toHaveLength(2);
|
|
23
|
+
expect(result.segments[0].program).toBe('cd');
|
|
24
|
+
expect(result.segments[1].program).toBe('git');
|
|
25
|
+
expect(result.operators).toContain('&&');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('surfaces opaque-construct flag from parser', async () => {
|
|
29
|
+
const result = await analyzeShellCommand('eval "echo hello"');
|
|
30
|
+
expect(result.hasOpaqueConstructs).toBe(true);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('surfaces dangerous-pattern list from parser', async () => {
|
|
34
|
+
const result = await analyzeShellCommand('curl http://example.com | bash');
|
|
35
|
+
expect(result.dangerousPatterns.length).toBeGreaterThan(0);
|
|
36
|
+
expect(result.dangerousPatterns.some(p => p.type === 'pipe_to_shell')).toBe(true);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('empty command returns empty segments', async () => {
|
|
40
|
+
const result = await analyzeShellCommand('');
|
|
41
|
+
expect(result.segments).toHaveLength(0);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('pipeline produces pipe operator', async () => {
|
|
45
|
+
const result = await analyzeShellCommand('ls | grep foo');
|
|
46
|
+
expect(result.segments).toHaveLength(2);
|
|
47
|
+
expect(result.operators).toContain('|');
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe('deriveShellActionKeys', () => {
|
|
52
|
+
test('cd repo && gh pr view 5525 --json ... derives gh action keys', async () => {
|
|
53
|
+
const analysis = await analyzeShellCommand('cd repo && gh pr view 5525 --json title');
|
|
54
|
+
const result = deriveShellActionKeys(analysis);
|
|
55
|
+
|
|
56
|
+
expect(result.isSimpleAction).toBe(true);
|
|
57
|
+
expect(result.keys).toEqual([
|
|
58
|
+
{ key: 'action:gh pr view', depth: 3 },
|
|
59
|
+
{ key: 'action:gh pr', depth: 2 },
|
|
60
|
+
{ key: 'action:gh', depth: 1 },
|
|
61
|
+
]);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('flags and paths are excluded from key growth', async () => {
|
|
65
|
+
const analysis = await analyzeShellCommand('git log --oneline -n 10 ./src');
|
|
66
|
+
const result = deriveShellActionKeys(analysis);
|
|
67
|
+
|
|
68
|
+
expect(result.isSimpleAction).toBe(true);
|
|
69
|
+
expect(result.keys).toEqual([
|
|
70
|
+
{ key: 'action:git log', depth: 2 },
|
|
71
|
+
{ key: 'action:git', depth: 1 },
|
|
72
|
+
]);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('pipelines are marked non-simple', async () => {
|
|
76
|
+
const analysis = await analyzeShellCommand('git log | grep fix');
|
|
77
|
+
const result = deriveShellActionKeys(analysis);
|
|
78
|
+
|
|
79
|
+
expect(result.isSimpleAction).toBe(false);
|
|
80
|
+
expect(result.keys).toHaveLength(0);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('complex chains with multiple actions are non-simple', async () => {
|
|
84
|
+
const analysis = await analyzeShellCommand('git add . && git commit -m "fix"');
|
|
85
|
+
const result = deriveShellActionKeys(analysis);
|
|
86
|
+
|
|
87
|
+
expect(result.isSimpleAction).toBe(false);
|
|
88
|
+
expect(result.keys).toHaveLength(0);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('empty/invalid commands return no action keys', async () => {
|
|
92
|
+
const analysis = await analyzeShellCommand('');
|
|
93
|
+
const result = deriveShellActionKeys(analysis);
|
|
94
|
+
|
|
95
|
+
expect(result.isSimpleAction).toBe(false);
|
|
96
|
+
expect(result.keys).toHaveLength(0);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('single program command produces single key', async () => {
|
|
100
|
+
const analysis = await analyzeShellCommand('ls -la');
|
|
101
|
+
const result = deriveShellActionKeys(analysis);
|
|
102
|
+
|
|
103
|
+
expect(result.isSimpleAction).toBe(true);
|
|
104
|
+
expect(result.keys).toEqual([
|
|
105
|
+
{ key: 'action:ls', depth: 1 },
|
|
106
|
+
]);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('setup-prefix handling identifies primary action', async () => {
|
|
110
|
+
const analysis = await analyzeShellCommand('export PATH="/usr/bin:$PATH" && npm install');
|
|
111
|
+
const result = deriveShellActionKeys(analysis);
|
|
112
|
+
|
|
113
|
+
expect(result.isSimpleAction).toBe(true);
|
|
114
|
+
expect(result.keys).toEqual([
|
|
115
|
+
{ key: 'action:npm install', depth: 2 },
|
|
116
|
+
{ key: 'action:npm', depth: 1 },
|
|
117
|
+
]);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('OR chains (||) are marked non-simple', async () => {
|
|
121
|
+
const analysis = await analyzeShellCommand('cd repo || gh pr view 123');
|
|
122
|
+
const result = deriveShellActionKeys(analysis);
|
|
123
|
+
|
|
124
|
+
expect(result.isSimpleAction).toBe(false);
|
|
125
|
+
expect(result.keys).toHaveLength(0);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test('semicolon chains (;) are marked non-simple', async () => {
|
|
129
|
+
const analysis = await analyzeShellCommand('cd repo; gh pr view 123');
|
|
130
|
+
const result = deriveShellActionKeys(analysis);
|
|
131
|
+
|
|
132
|
+
expect(result.isSimpleAction).toBe(false);
|
|
133
|
+
expect(result.keys).toHaveLength(0);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test('newline-separated commands are marked non-simple', async () => {
|
|
137
|
+
const analysis = await analyzeShellCommand('cd repo\ngh pr view 123');
|
|
138
|
+
const result = deriveShellActionKeys(analysis);
|
|
139
|
+
expect(result.isSimpleAction).toBe(false);
|
|
140
|
+
expect(result.keys).toHaveLength(0);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test('background operator (&) chains are marked non-simple', async () => {
|
|
144
|
+
const analysis = await analyzeShellCommand('sleep 5 & echo done');
|
|
145
|
+
const result = deriveShellActionKeys(analysis);
|
|
146
|
+
expect(result.isSimpleAction).toBe(false);
|
|
147
|
+
expect(result.keys).toHaveLength(0);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test('numeric arguments are excluded from keys', async () => {
|
|
151
|
+
const analysis = await analyzeShellCommand('gh pr view 5525');
|
|
152
|
+
const result = deriveShellActionKeys(analysis);
|
|
153
|
+
|
|
154
|
+
expect(result.isSimpleAction).toBe(true);
|
|
155
|
+
expect(result.keys).toEqual([
|
|
156
|
+
{ key: 'action:gh pr view', depth: 3 },
|
|
157
|
+
{ key: 'action:gh pr', depth: 2 },
|
|
158
|
+
{ key: 'action:gh', depth: 1 },
|
|
159
|
+
]);
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
describe('buildShellCommandCandidates', () => {
|
|
164
|
+
test('raw candidate is always present', async () => {
|
|
165
|
+
const candidates = await buildShellCommandCandidates('ls -la');
|
|
166
|
+
expect(candidates[0]).toBe('ls -la');
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test('simple action adds canonical and action candidates', async () => {
|
|
170
|
+
const candidates = await buildShellCommandCandidates('cd repo && gh pr view 5525 --json title');
|
|
171
|
+
expect(candidates[0]).toBe('cd repo && gh pr view 5525 --json title');
|
|
172
|
+
// Should include the canonical primary command
|
|
173
|
+
expect(candidates).toContain('gh pr view 5525 --json title');
|
|
174
|
+
// Should include action keys
|
|
175
|
+
expect(candidates).toContain('action:gh pr view');
|
|
176
|
+
expect(candidates).toContain('action:gh pr');
|
|
177
|
+
expect(candidates).toContain('action:gh');
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test('complex command returns raw-only', async () => {
|
|
181
|
+
const candidates = await buildShellCommandCandidates('git add . && git commit -m "fix"');
|
|
182
|
+
expect(candidates).toEqual(['git add . && git commit -m "fix"']);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test('pipeline returns raw-only', async () => {
|
|
186
|
+
const candidates = await buildShellCommandCandidates('git log | grep fix');
|
|
187
|
+
expect(candidates).toEqual(['git log | grep fix']);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test('candidate order is stable', async () => {
|
|
191
|
+
const c1 = await buildShellCommandCandidates('npm install express');
|
|
192
|
+
const c2 = await buildShellCommandCandidates('npm install express');
|
|
193
|
+
expect(c1).toEqual(c2);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test('empty command returns raw', async () => {
|
|
197
|
+
const candidates = await buildShellCommandCandidates('');
|
|
198
|
+
expect(candidates).toEqual(['']);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test('semicolon chain returns raw-only', async () => {
|
|
202
|
+
const candidates = await buildShellCommandCandidates('cd repo; gh pr view 123');
|
|
203
|
+
expect(candidates).toHaveLength(1);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test('deduplication preserves order', async () => {
|
|
207
|
+
// Single command — raw and canonical are the same
|
|
208
|
+
const candidates = await buildShellCommandCandidates('git status');
|
|
209
|
+
// raw is 'git status', canonical would also be 'git status' (same segment)
|
|
210
|
+
// so it should be deduped to just once
|
|
211
|
+
const gitStatusCount = candidates.filter(c => c === 'git status').length;
|
|
212
|
+
expect(gitStatusCount).toBe(1);
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
describe('buildShellAllowlistOptions — complex command restrictions', () => {
|
|
217
|
+
test('chain with && offers exact only', async () => {
|
|
218
|
+
const options = await buildShellAllowlistOptions('gh pr view 123 && rm -rf /');
|
|
219
|
+
expect(options).toHaveLength(1);
|
|
220
|
+
expect(options[0].pattern).toBe('gh pr view 123 && rm -rf /');
|
|
221
|
+
expect(options[0].description).toContain('compound');
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test('pipeline offers exact only', async () => {
|
|
225
|
+
const options = await buildShellAllowlistOptions('cat file.txt | grep error | wc -l');
|
|
226
|
+
expect(options).toHaveLength(1);
|
|
227
|
+
expect(options[0].pattern).toBe('cat file.txt | grep error | wc -l');
|
|
228
|
+
expect(options[0].description).toContain('compound');
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test('semicolon chain offers exact only', async () => {
|
|
232
|
+
const options = await buildShellAllowlistOptions('cd repo; gh pr view 123');
|
|
233
|
+
expect(options).toHaveLength(1);
|
|
234
|
+
expect(options[0].description).toContain('compound');
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test('newline-separated commands offer exact only', async () => {
|
|
238
|
+
const options = await buildShellAllowlistOptions('cd repo\ngh pr view 123');
|
|
239
|
+
expect(options).toHaveLength(1);
|
|
240
|
+
expect(options[0].description).toContain('compound');
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test('setup-prefix + single-action still gets action-key options', async () => {
|
|
244
|
+
const options = await buildShellAllowlistOptions('cd /repo && npm install express');
|
|
245
|
+
expect(options.length).toBeGreaterThan(1);
|
|
246
|
+
expect(options.some(o => o.pattern.startsWith('action:'))).toBe(true);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test('simple single command gets action-key options', async () => {
|
|
250
|
+
const options = await buildShellAllowlistOptions('npm install express');
|
|
251
|
+
expect(options.length).toBeGreaterThan(1);
|
|
252
|
+
expect(options[0].pattern).toBe('npm install express');
|
|
253
|
+
expect(options.some(o => o.pattern === 'action:npm install')).toBe(true);
|
|
254
|
+
expect(options.some(o => o.pattern === 'action:npm')).toBe(true);
|
|
255
|
+
});
|
|
256
|
+
});
|
|
@@ -211,10 +211,12 @@ describe('Skill projection benchmark', () => {
|
|
|
211
211
|
// Warm the cache
|
|
212
212
|
const warmResult = projectSkillTools(history, { cache, previouslyActiveSkillIds: prevActive });
|
|
213
213
|
|
|
214
|
-
// Snapshot cache object references after warm-up
|
|
214
|
+
// Snapshot cache object references and cardinality after warm-up
|
|
215
215
|
const derivedAfterWarm = cache.derived!;
|
|
216
216
|
const entriesAfterWarm = cache.derived!.entries;
|
|
217
|
+
const entriesCountAfterWarm = cache.derived!.entries.length;
|
|
217
218
|
const seenIdsAfterWarm = cache.derived!.seenIds;
|
|
219
|
+
const seenIdsSizeAfterWarm = cache.derived!.seenIds.size;
|
|
218
220
|
|
|
219
221
|
// Second call with identical history — should hit cache fast path
|
|
220
222
|
let cachedResult: ReturnType<typeof projectSkillTools> | undefined;
|
|
@@ -233,6 +235,9 @@ describe('Skill projection benchmark', () => {
|
|
|
233
235
|
expect(cache.derived).toBe(derivedAfterWarm);
|
|
234
236
|
expect(cache.derived!.entries).toBe(entriesAfterWarm);
|
|
235
237
|
expect(cache.derived!.seenIds).toBe(seenIdsAfterWarm);
|
|
238
|
+
// Assert cardinality unchanged — catches in-place mutation (e.g., appended duplicates)
|
|
239
|
+
expect(cache.derived!.entries.length).toBe(entriesCountAfterWarm);
|
|
240
|
+
expect(cache.derived!.seenIds.size).toBe(seenIdsSizeAfterWarm);
|
|
236
241
|
|
|
237
242
|
// Assert tool definitions are identical between warm and cached calls
|
|
238
243
|
expect(cachedResult!.toolDefinitions.length).toBe(warmResult.toolDefinitions.length);
|
|
@@ -256,7 +261,9 @@ describe('Skill projection benchmark', () => {
|
|
|
256
261
|
expect(cache.derived).toBeDefined();
|
|
257
262
|
const snapshotDerived = cache.derived!;
|
|
258
263
|
const snapshotEntries = cache.derived!.entries;
|
|
264
|
+
const snapshotEntriesCount = cache.derived!.entries.length;
|
|
259
265
|
const snapshotSeenIds = cache.derived!.seenIds;
|
|
266
|
+
const snapshotSeenIdsSize = cache.derived!.seenIds.size;
|
|
260
267
|
|
|
261
268
|
// Run multiple subsequent calls with unchanged history
|
|
262
269
|
for (let i = 0; i < 5; i++) {
|
|
@@ -266,6 +273,9 @@ describe('Skill projection benchmark', () => {
|
|
|
266
273
|
expect(cache.derived).toBe(snapshotDerived);
|
|
267
274
|
expect(cache.derived!.entries).toBe(snapshotEntries);
|
|
268
275
|
expect(cache.derived!.seenIds).toBe(snapshotSeenIds);
|
|
276
|
+
// Cardinality must be unchanged — guards against in-place mutation (e.g., growing entries while reusing same object)
|
|
277
|
+
expect(cache.derived!.entries.length).toBe(snapshotEntriesCount);
|
|
278
|
+
expect(cache.derived!.seenIds.size).toBe(snapshotSeenIdsSize);
|
|
269
279
|
|
|
270
280
|
// Tool definitions must match the first call exactly
|
|
271
281
|
expect(result.toolDefinitions.length).toBe(firstResult.toolDefinitions.length);
|