@signalvaultio/node 0.1.0 → 0.3.0

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 CHANGED
@@ -1,26 +1,38 @@
1
1
  # @signalvaultio/node
2
2
 
3
- AI audit logs and guardrails for your OpenAI applications.
3
+ AI audit logs and guardrails for your OpenAI and Anthropic applications.
4
+
5
+ > **Server-side only.** This SDK uses Node APIs (`async_hooks`, `fetch`) and
6
+ > requires Node.js **18+**. Do **not** bundle it for the browser — your
7
+ > `apiKey`, `openaiApiKey`, and `anthropicApiKey` would be exposed to end
8
+ > users. Always call SignalVault from your backend.
4
9
 
5
10
  ## Installation
6
11
 
7
12
  ```bash
13
+ # OpenAI only
8
14
  npm install @signalvaultio/node openai
15
+
16
+ # Anthropic only
17
+ npm install @signalvaultio/node @anthropic-ai/sdk
18
+
19
+ # Both
20
+ npm install @signalvaultio/node openai @anthropic-ai/sdk
9
21
  ```
10
22
 
11
- ## Quick Start
23
+ ## Quick Start — OpenAI
12
24
 
13
25
  ```typescript
14
26
  import SignalVaultClient from '@signalvaultio/node';
15
27
 
16
28
  const client = new SignalVaultClient({
17
- apiKey: 'sk_live_your_signalvault_key', // Your SignalVault API key
18
- openaiApiKey: process.env.OPENAI_API_KEY!, // Your OpenAI API key
19
- baseUrl: 'https://api.signalvault.io', // SignalVault API URL
29
+ apiKey: 'sk_live_your_signalvault_key',
30
+ openaiApiKey: process.env.OPENAI_API_KEY!,
31
+ baseUrl: 'https://api.signalvault.io',
20
32
  environment: 'production',
21
33
  });
22
34
 
23
- // Use exactly like OpenAI SDK — all calls are logged and guarded
35
+ // Use exactly like OpenAI SDK
24
36
  const response = await client.chat.completions.create({
25
37
  model: 'gpt-4',
26
38
  messages: [{ role: 'user', content: 'Hello!' }],
@@ -29,11 +41,31 @@ const response = await client.chat.completions.create({
29
41
  console.log(response.choices[0].message.content);
30
42
  ```
31
43
 
44
+ ## Quick Start — Anthropic
45
+
46
+ ```typescript
47
+ const client = new SignalVaultClient({
48
+ apiKey: 'sk_live_your_signalvault_key',
49
+ anthropicApiKey: process.env.ANTHROPIC_API_KEY!,
50
+ baseUrl: 'https://api.signalvault.io',
51
+ });
52
+
53
+ // Use exactly like Anthropic SDK
54
+ const response = await client.messages.create({
55
+ model: 'claude-3-5-sonnet-20241022',
56
+ messages: [{ role: 'user', content: 'Hello!' }],
57
+ max_tokens: 1024,
58
+ });
59
+
60
+ console.log(response.content[0].text);
61
+ ```
62
+
32
63
  ## Streaming
33
64
 
34
- Streaming responses are fully supported. SignalVault logs the complete response once the stream finishes:
65
+ Streaming is fully supported for both providers. SignalVault logs the complete response once the stream finishes:
35
66
 
36
67
  ```typescript
68
+ // OpenAI streaming
37
69
  const stream = await client.chat.completions.create({
38
70
  model: 'gpt-4',
39
71
  messages: [{ role: 'user', content: 'Write a poem' }],
@@ -43,48 +75,161 @@ const stream = await client.chat.completions.create({
43
75
  for await (const chunk of stream) {
44
76
  process.stdout.write(chunk.choices[0]?.delta?.content || '');
45
77
  }
78
+
79
+ // Anthropic streaming
80
+ const stream = await client.messages.create({
81
+ model: 'claude-3-5-sonnet-20241022',
82
+ messages: [{ role: 'user', content: 'Write a poem' }],
83
+ max_tokens: 1024,
84
+ stream: true,
85
+ });
86
+
87
+ for await (const event of stream) {
88
+ if (event.type === 'content_block_delta') {
89
+ process.stdout.write(event.delta.text || '');
90
+ }
91
+ }
92
+ ```
93
+
94
+ ## Agent Tool-Use Capture
95
+
96
+ Log every tool invocation made by your agents — name, input, output, duration, and any error — as auditable events alongside your LLM calls.
97
+
98
+ ### Wrapper API (recommended)
99
+
100
+ ```typescript
101
+ const fetchWeather = client.tool('fetch_weather', async (city: string) => {
102
+ const res = await fetch(`https://api.example.com/weather?city=${city}`);
103
+ return res.json();
104
+ });
105
+
106
+ // Call it like the original — SignalVault auto-times and audits
107
+ const weather = await fetchWeather('London');
108
+ ```
109
+
110
+ The wrapper records the call asynchronously (no impact on your tool's latency) and passes the result through unchanged. Errors are recorded and rethrown.
111
+
112
+ ### Manual API
113
+
114
+ ```typescript
115
+ await client.tools.record({
116
+ toolName: 'fetch_weather',
117
+ toolInput: { city: 'London' },
118
+ toolOutput: { temp: 12.3 },
119
+ durationMs: 142,
120
+ });
121
+ ```
122
+
123
+ ### What gets captured (and what to keep out)
124
+
125
+ When you wrap a tool or call `tools.record()`, SignalVault captures:
126
+
127
+ - `tool_name` (truncated to 200 bytes)
128
+ - `tool_input` — the function arguments, JSON-serialized (capped at 256 KB; oversize values are truncated with a marker)
129
+ - `tool_output` — the function return value, JSON-serialized (same 256 KB cap)
130
+ - `error.message` if the tool throws (truncated to 1900 bytes)
131
+ - `duration_ms`, `started_at`, and any `metadata` you attach
132
+
133
+ These fields are stored encrypted at rest server-side, but they go on the wire
134
+ to SignalVault's API. **If you pass user PII, secrets, or API keys as tool
135
+ arguments, those values will leave your process and be stored in SignalVault.**
136
+ Recommendations:
137
+
138
+ - Sanitize sensitive arguments before invoking the wrapped tool, or use the
139
+ manual `tools.record()` API and pass a redacted copy.
140
+ - Don't put secrets in error messages — they end up in `error` verbatim.
141
+ - Use `metadata` for non-sensitive identifiers (`user_id`, `feature`,
142
+ `workspace_id`); avoid putting raw user content in metadata.
143
+
144
+ ### Linking tool calls to a parent LLM turn
145
+
146
+ Wrap your agent loop in `withContext` and tool calls inside auto-correlate to the given `requestId`:
147
+
148
+ ```typescript
149
+ await client.withContext({ requestId: 'agent-turn-abc' }, async () => {
150
+ const llmResponse = await client.chat.completions.create({...});
151
+ await fetchWeather('London'); // auto-linked to 'agent-turn-abc'
152
+ });
153
+ ```
154
+
155
+ Without `withContext`, tool calls are recorded as orphans (no parent request).
156
+
157
+ ## Metadata
158
+
159
+ Attach contextual metadata to every event — perfect for audit trails, user attribution, and analytics:
160
+
161
+ ```typescript
162
+ // Set defaults at client level
163
+ const client = new SignalVaultClient({
164
+ apiKey: 'sk_live_...',
165
+ openaiApiKey: process.env.OPENAI_API_KEY!,
166
+ metadata: { workspace_id: 'ws_abc', environment: 'production' },
167
+ });
168
+
169
+ // Override per-call
170
+ const response = await client.chat.completions.create(
171
+ { model: 'gpt-4', messages: [...] },
172
+ { metadata: { user_id: 'u_123', feature: 'support-chat' } }
173
+ );
174
+ ```
175
+
176
+ ## Timeout Configuration
177
+
178
+ The pre-flight guardrail check is in your request's critical path. SignalVaultClient uses a short timeout and **fails open** — your request always goes through even if the SignalVault API is slow or unreachable:
179
+
180
+ ```typescript
181
+ const client = new SignalVaultClient({
182
+ apiKey: 'sk_live_...',
183
+ openaiApiKey: process.env.OPENAI_API_KEY!,
184
+ preflightTimeout: 2000, // ms — pre-flight check timeout (fails open). Default: 2000
185
+ timeout: 10000, // ms — background/post-flight calls. Default: 10000
186
+ });
46
187
  ```
47
188
 
48
189
  ## Mirror Mode
49
190
 
50
- In mirror mode, requests go directly to OpenAI first and SignalVault audits them asynchronously — no latency added, never blocks:
191
+ In mirror mode, requests go directly to the AI provider first and SignalVault audits them asynchronously — no latency added, never blocks:
51
192
 
52
193
  ```typescript
53
194
  const client = new SignalVaultClient({
54
195
  apiKey: 'sk_live_...',
55
196
  openaiApiKey: process.env.OPENAI_API_KEY!,
56
- mirrorMode: true, // Monitor only, never block
197
+ mirrorMode: true,
57
198
  });
58
199
  ```
59
200
 
60
201
  ## Features
61
202
 
62
203
  - **Automatic Logging** — Every request and response is recorded in your SignalVault dashboard
63
- - **Pre-flight Guardrails** — Block or redact requests before they reach OpenAI
204
+ - **Pre-flight Guardrails** — Block or redact requests before they reach the AI provider
64
205
  - **PII Detection** — Automatically detect emails, phone numbers, SSNs in prompts
65
206
  - **Secret Detection** — Block API keys and tokens in prompts
66
207
  - **Token Limits** — Enforce cost controls per request
67
208
  - **Model Allowlists** — Restrict which AI models can be used
68
- - **Streaming Support** — Full support for streaming completions
209
+ - **Streaming Support** — Full support for streaming completions (OpenAI + Anthropic)
69
210
  - **Mirror Mode** — Observe without blocking (zero added latency)
211
+ - **Metadata** — Tag every event with user_id, feature, workspace_id, etc.
212
+ - **Multi-provider** — OpenAI and Anthropic/Claude support
70
213
 
71
214
  ## Configuration
72
215
 
73
216
  ```typescript
74
217
  const client = new SignalVaultClient({
75
- apiKey: 'sk_live_...', // Your SignalVault API key
76
- openaiApiKey: 'sk-...', // Your OpenAI API key
77
- baseUrl: 'https://api.signalvault.io', // SignalVault API URL
78
- environment: 'production', // 'development' | 'staging' | 'production'
79
- debug: false, // Enable debug logging
80
- mirrorMode: false, // Monitor-only mode
218
+ apiKey: 'sk_live_...', // Your SignalVault API key (required)
219
+ openaiApiKey: 'sk-...', // OpenAI API key (required for chat.completions)
220
+ anthropicApiKey: 'sk-ant-...', // Anthropic API key (required for messages)
221
+ baseUrl: 'https://api.signalvault.io',
222
+ environment: 'production', // 'development' | 'staging' | 'production'
223
+ debug: false,
224
+ mirrorMode: false,
225
+ preflightTimeout: 2000, // ms
226
+ timeout: 10000, // ms
227
+ metadata: {}, // Default metadata for all events
81
228
  });
82
229
  ```
83
230
 
84
231
  ## Error Handling
85
232
 
86
- If a request is blocked by a guardrail, an error will be thrown:
87
-
88
233
  ```typescript
89
234
  try {
90
235
  const response = await client.chat.completions.create({
@@ -1,49 +1,99 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  const index_1 = require("../index");
4
- // Minimal unit tests for the SDK (no network calls)
5
4
  describe('SignalVaultClient', () => {
6
- it('should construct with required config', () => {
7
- const client = new index_1.SignalVaultClient({
8
- apiKey: 'sk_test_abc',
9
- openaiApiKey: 'sk-fake',
10
- });
11
- expect(client).toBeDefined();
12
- expect(client.chat).toBeDefined();
13
- expect(client.chat.completions).toBeDefined();
14
- expect(typeof client.chat.completions.create).toBe('function');
5
+ describe('construction', () => {
6
+ it('constructs with openaiApiKey', () => {
7
+ const client = new index_1.SignalVaultClient({ apiKey: 'sk_test_abc', openaiApiKey: 'sk-fake' });
8
+ expect(client).toBeDefined();
9
+ expect(client.chat).toBeDefined();
10
+ expect(client.chat.completions).toBeDefined();
11
+ expect(typeof client.chat.completions.create).toBe('function');
12
+ });
13
+ it('throws when neither openaiApiKey nor anthropicApiKey is provided', () => {
14
+ expect(() => new index_1.SignalVaultClient({ apiKey: 'sk_test_abc' })).toThrow('At least one of openaiApiKey or anthropicApiKey is required');
15
+ });
16
+ it('defaults to localhost and production', () => {
17
+ const client = new index_1.SignalVaultClient({ apiKey: 'sk_test_abc', openaiApiKey: 'sk-fake' });
18
+ expect(client.svBaseUrl).toBe('http://localhost:4000');
19
+ expect(client.svEnvironment).toBe('production');
20
+ expect(client.debugMode).toBe(false);
21
+ expect(client.mirrorMode).toBe(false);
22
+ });
23
+ it('accepts custom config', () => {
24
+ const client = new index_1.SignalVaultClient({
25
+ apiKey: 'sk_test_abc',
26
+ openaiApiKey: 'sk-fake',
27
+ baseUrl: 'https://api.signalvault.io/',
28
+ environment: 'staging',
29
+ debug: true,
30
+ mirrorMode: true,
31
+ });
32
+ expect(client.svBaseUrl).toBe('https://api.signalvault.io');
33
+ expect(client.svEnvironment).toBe('staging');
34
+ expect(client.debugMode).toBe(true);
35
+ expect(client.mirrorMode).toBe(true);
36
+ });
37
+ it('strips trailing slashes from baseUrl', () => {
38
+ const client = new index_1.SignalVaultClient({
39
+ apiKey: 'sk_test_abc',
40
+ openaiApiKey: 'sk-fake',
41
+ baseUrl: 'https://example.com///',
42
+ });
43
+ expect(client.svBaseUrl).toBe('https://example.com');
44
+ });
45
+ });
46
+ describe('timeout configuration', () => {
47
+ it('defaults to 2000ms preflight and 10000ms background', () => {
48
+ const client = new index_1.SignalVaultClient({ apiKey: 'sk_test_abc', openaiApiKey: 'sk-fake' });
49
+ expect(client.preflightTimeout).toBe(2000);
50
+ expect(client.bgTimeout).toBe(10000);
51
+ });
52
+ it('accepts custom timeouts', () => {
53
+ const client = new index_1.SignalVaultClient({
54
+ apiKey: 'sk_test_abc',
55
+ openaiApiKey: 'sk-fake',
56
+ preflightTimeout: 500,
57
+ timeout: 5000,
58
+ });
59
+ expect(client.preflightTimeout).toBe(500);
60
+ expect(client.bgTimeout).toBe(5000);
61
+ });
15
62
  });
16
- it('should default to localhost and production', () => {
17
- const client = new index_1.SignalVaultClient({
18
- apiKey: 'sk_test_abc',
19
- openaiApiKey: 'sk-fake',
20
- });
21
- // Access private fields via bracket notation for testing
22
- expect(client.signalvaultBaseUrl).toBe('http://localhost:4000');
23
- expect(client.signalvaultEnvironment).toBe('production');
24
- expect(client.debugMode).toBe(false);
25
- expect(client.mirrorMode).toBe(false);
63
+ describe('metadata', () => {
64
+ it('defaults to empty metadata', () => {
65
+ const client = new index_1.SignalVaultClient({ apiKey: 'sk_test_abc', openaiApiKey: 'sk-fake' });
66
+ expect(client.defaultMetadata).toEqual({});
67
+ });
68
+ it('accepts metadata in config', () => {
69
+ const client = new index_1.SignalVaultClient({
70
+ apiKey: 'sk_test_abc',
71
+ openaiApiKey: 'sk-fake',
72
+ metadata: { user_id: 'u_123', feature: 'chat' },
73
+ });
74
+ expect(client.defaultMetadata).toEqual({ user_id: 'u_123', feature: 'chat' });
75
+ });
26
76
  });
27
- it('should accept custom baseUrl and environment', () => {
28
- const client = new index_1.SignalVaultClient({
29
- apiKey: 'sk_test_abc',
30
- openaiApiKey: 'sk-fake',
31
- baseUrl: 'https://api.signalvault.io/',
32
- environment: 'staging',
33
- debug: true,
34
- mirrorMode: true,
35
- });
36
- expect(client.signalvaultBaseUrl).toBe('https://api.signalvault.io');
37
- expect(client.signalvaultEnvironment).toBe('staging');
38
- expect(client.debugMode).toBe(true);
39
- expect(client.mirrorMode).toBe(true);
77
+ describe('Anthropic', () => {
78
+ it('throws on messages access when anthropicApiKey not provided', () => {
79
+ const client = new index_1.SignalVaultClient({ apiKey: 'sk_test_abc', openaiApiKey: 'sk-fake' });
80
+ expect(() => client.messages).toThrow('anthropicApiKey not provided');
81
+ });
82
+ it('throws when @anthropic-ai/sdk is not installed', () => {
83
+ expect(() => new index_1.SignalVaultClient({ apiKey: 'sk_test_abc', anthropicApiKey: 'sk-ant-fake' })).toThrow('@anthropic-ai/sdk is not installed');
84
+ });
40
85
  });
41
- it('should strip trailing slashes from baseUrl', () => {
42
- const client = new index_1.SignalVaultClient({
43
- apiKey: 'sk_test_abc',
44
- openaiApiKey: 'sk-fake',
45
- baseUrl: 'https://example.com///',
86
+ describe('fail-open on preflight timeout', () => {
87
+ it('sendRequest returns allow decision on fetch error', async () => {
88
+ const client = new index_1.SignalVaultClient({
89
+ apiKey: 'sk_test_abc',
90
+ openaiApiKey: 'sk-fake',
91
+ baseUrl: 'http://127.0.0.1:1', // unreachable
92
+ preflightTimeout: 100,
93
+ });
94
+ const decision = await client.sendRequest('req-1', 'gpt-4', [], {}, 'openai');
95
+ expect(decision.decision).toBe('allow');
96
+ expect(decision.violations).toEqual([]);
46
97
  });
47
- expect(client.signalvaultBaseUrl).toBe('https://example.com');
48
98
  });
49
99
  });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,243 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const index_1 = require("../index");
4
+ describe('Agent tool-use capture', () => {
5
+ let fetchMock;
6
+ let client;
7
+ beforeEach(() => {
8
+ fetchMock = jest.spyOn(global, 'fetch').mockResolvedValue(new Response(JSON.stringify({ event_id: 'evt_123' }), { status: 200 }));
9
+ client = new index_1.SignalVaultClient({
10
+ apiKey: 'sk_test_abc',
11
+ openaiApiKey: 'sk-fake',
12
+ baseUrl: 'https://api.example.com',
13
+ });
14
+ });
15
+ afterEach(() => {
16
+ fetchMock.mockRestore();
17
+ });
18
+ // Helper: lets the fire-and-forget tool record promise settle.
19
+ const flushAsync = () => new Promise((resolve) => setImmediate(resolve));
20
+ describe('client.tool() wrapper', () => {
21
+ it('records a successful tool invocation with input/output and timing', async () => {
22
+ const fetchWeather = client.tool('fetch_weather', async (city) => {
23
+ return { temp: 12.3, conditions: 'rain', for: city };
24
+ });
25
+ const result = await fetchWeather('London');
26
+ expect(result).toEqual({ temp: 12.3, conditions: 'rain', for: 'London' });
27
+ await flushAsync();
28
+ expect(fetchMock).toHaveBeenCalledTimes(1);
29
+ const [url, init] = fetchMock.mock.calls[0];
30
+ expect(url).toBe('https://api.example.com/v1/events');
31
+ const body = JSON.parse(init.body);
32
+ expect(body.type).toBe('agent.tool_call');
33
+ expect(body.payload.tool_name).toBe('fetch_weather');
34
+ expect(body.payload.tool_input).toBe('London');
35
+ expect(body.payload.tool_output).toEqual({ temp: 12.3, conditions: 'rain', for: 'London' });
36
+ expect(body.payload.duration_ms).toBeGreaterThanOrEqual(0);
37
+ expect(body.payload.started_at).toMatch(/^\d{4}-\d{2}-\d{2}T/);
38
+ });
39
+ it('records an errored invocation and rethrows', async () => {
40
+ const failingTool = client.tool('fetch_external', async () => {
41
+ throw new Error('connection refused');
42
+ });
43
+ await expect(failingTool()).rejects.toThrow('connection refused');
44
+ await flushAsync();
45
+ expect(fetchMock).toHaveBeenCalledTimes(1);
46
+ const body = JSON.parse(fetchMock.mock.calls[0][1].body);
47
+ expect(body.payload.error).toBe('connection refused');
48
+ expect(body.payload.tool_output).toBeNull();
49
+ });
50
+ it('serializes a single object arg as the value, not as an array', async () => {
51
+ const search = client.tool('search', async (query) => ({ matches: query.k }));
52
+ await search({ q: 'embeddings', k: 5 });
53
+ await flushAsync();
54
+ const body = JSON.parse(fetchMock.mock.calls[0][1].body);
55
+ expect(body.payload.tool_input).toEqual({ q: 'embeddings', k: 5 });
56
+ });
57
+ it('serializes multiple args as an array', async () => {
58
+ const add = client.tool('add', async (a, b) => a + b);
59
+ await add(2, 3);
60
+ await flushAsync();
61
+ const body = JSON.parse(fetchMock.mock.calls[0][1].body);
62
+ expect(body.payload.tool_input).toEqual([2, 3]);
63
+ });
64
+ it('does not block on the audit network call (fire-and-forget)', async () => {
65
+ // Make fetch hang so we can verify the wrapper returns before the audit completes
66
+ fetchMock.mockImplementation(() => new Promise((resolve) => setTimeout(() => resolve(new Response('{}', { status: 200 })), 5000)));
67
+ const start = Date.now();
68
+ const tool = client.tool('fast_tool', async () => 42);
69
+ const result = await tool();
70
+ const elapsed = Date.now() - start;
71
+ expect(result).toBe(42);
72
+ expect(elapsed).toBeLessThan(100); // should NOT wait the 5s audit timeout
73
+ });
74
+ it('supports synchronous functions', async () => {
75
+ const sum = client.tool('sum', (a, b) => a + b);
76
+ const result = await sum(2, 3);
77
+ expect(result).toBe(5);
78
+ });
79
+ });
80
+ describe('client.tools.record() manual API', () => {
81
+ it('posts a tool_call event with the given fields', async () => {
82
+ await client.tools.record({
83
+ toolName: 'manual_tool',
84
+ toolInput: { x: 1 },
85
+ toolOutput: { y: 2 },
86
+ durationMs: 50,
87
+ requestId: 'req-explicit',
88
+ });
89
+ expect(fetchMock).toHaveBeenCalledTimes(1);
90
+ const body = JSON.parse(fetchMock.mock.calls[0][1].body);
91
+ expect(body.type).toBe('agent.tool_call');
92
+ expect(body.request_id).toBe('req-explicit');
93
+ expect(body.payload.tool_name).toBe('manual_tool');
94
+ expect(body.payload.duration_ms).toBe(50);
95
+ });
96
+ it('omits request_id when not provided (orphan tool call)', async () => {
97
+ await client.tools.record({
98
+ toolName: 'orphan',
99
+ durationMs: 10,
100
+ });
101
+ const body = JSON.parse(fetchMock.mock.calls[0][1].body);
102
+ expect(body.request_id).toBeUndefined();
103
+ });
104
+ });
105
+ describe('input sanitization', () => {
106
+ it('replaces circular references with a marker instead of throwing', async () => {
107
+ const circular = { name: 'root' };
108
+ circular.self = circular;
109
+ const tool = client.tool('circular_tool', async (_arg) => 'ok');
110
+ await tool(circular);
111
+ await flushAsync();
112
+ expect(fetchMock).toHaveBeenCalledTimes(1);
113
+ const body = JSON.parse(fetchMock.mock.calls[0][1].body);
114
+ expect(body.payload.tool_input).toEqual({ name: 'root', self: '[Circular]' });
115
+ });
116
+ it('serializes BigInt arguments as a string with n suffix', async () => {
117
+ const tool = client.tool('bigint_tool', async (_n) => 'ok');
118
+ await tool(BigInt(42));
119
+ await flushAsync();
120
+ const body = JSON.parse(fetchMock.mock.calls[0][1].body);
121
+ expect(body.payload.tool_input).toBe('42n');
122
+ });
123
+ it('truncates oversized tool_output with a marker', async () => {
124
+ // ~300 KB — over the 256 KB cap.
125
+ const big = 'x'.repeat(300 * 1024);
126
+ const tool = client.tool('big_output', async () => ({ blob: big }));
127
+ await tool();
128
+ await flushAsync();
129
+ const body = JSON.parse(fetchMock.mock.calls[0][1].body);
130
+ expect(body.payload.tool_output._signalvault_truncated).toBe(true);
131
+ expect(body.payload.tool_output._signalvault_original_bytes).toBeGreaterThan(256 * 1024);
132
+ expect(typeof body.payload.tool_output.preview).toBe('string');
133
+ });
134
+ it('truncates a long error message to ~1900 bytes', async () => {
135
+ const longMsg = 'a'.repeat(5000);
136
+ const failing = client.tool('long_error', async () => {
137
+ throw new Error(longMsg);
138
+ });
139
+ await expect(failing()).rejects.toThrow();
140
+ await flushAsync();
141
+ const body = JSON.parse(fetchMock.mock.calls[0][1].body);
142
+ const err = body.payload.error;
143
+ // Allow a generous upper bound to account for the truncation marker.
144
+ expect(Buffer.byteLength(err, 'utf8')).toBeLessThanOrEqual(1900);
145
+ expect(err).toMatch(/truncated/);
146
+ });
147
+ it('truncates an oversized tool_name and still records the call', async () => {
148
+ const longName = 'n'.repeat(500);
149
+ await client.tools.record({
150
+ toolName: longName,
151
+ durationMs: 1,
152
+ });
153
+ const body = JSON.parse(fetchMock.mock.calls[0][1].body);
154
+ expect(Buffer.byteLength(body.payload.tool_name, 'utf8')).toBeLessThanOrEqual(200);
155
+ expect(body.payload.tool_name).toMatch(/truncated/);
156
+ });
157
+ it('rejects an empty tool name from the manual API', async () => {
158
+ await expect(client.tools.record({ toolName: '', durationMs: 1 })).rejects.toThrow(/non-empty string/);
159
+ expect(fetchMock).not.toHaveBeenCalled();
160
+ });
161
+ it('replaces undefined tool_output with null in the body', async () => {
162
+ const tool = client.tool('void_tool', async () => undefined);
163
+ await tool();
164
+ await flushAsync();
165
+ const body = JSON.parse(fetchMock.mock.calls[0][1].body);
166
+ expect(body.payload.tool_output).toBeNull();
167
+ });
168
+ it('replaces a function value with a [Function] marker (not a crash)', async () => {
169
+ const tool = client.tool('weird_input', async (_fn) => 'ok');
170
+ await tool(() => 'hello');
171
+ await flushAsync();
172
+ const body = JSON.parse(fetchMock.mock.calls[0][1].body);
173
+ expect(body.payload.tool_input).toBe('[Function]');
174
+ });
175
+ it('falls back to the serialization-error envelope for top-level undefined', async () => {
176
+ // A bare `undefined` is JSON-serializable as nothing — the replacer leaves
177
+ // it alone and JSON.stringify returns undefined. We expose this as a
178
+ // marker so the audit isn't silently dropped.
179
+ await client.tools.record({
180
+ toolName: 'undef',
181
+ toolInput: undefined,
182
+ durationMs: 1,
183
+ });
184
+ const body = JSON.parse(fetchMock.mock.calls[0][1].body);
185
+ // sanitizePayload(undefined) returns null per the function contract.
186
+ expect(body.payload.tool_input).toBeNull();
187
+ });
188
+ });
189
+ describe('error logging', () => {
190
+ let warnSpy;
191
+ let errorSpy;
192
+ beforeEach(() => {
193
+ warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => { });
194
+ errorSpy = jest.spyOn(console, 'error').mockImplementation(() => { });
195
+ });
196
+ afterEach(() => {
197
+ warnSpy.mockRestore();
198
+ errorSpy.mockRestore();
199
+ });
200
+ it('surfaces 4xx responses to console.warn even with debug=false', async () => {
201
+ fetchMock.mockResolvedValue(new Response('bad request', { status: 422 }));
202
+ await client.tools.record({ toolName: 'will_fail', durationMs: 1 });
203
+ expect(warnSpy).toHaveBeenCalledTimes(1);
204
+ expect(warnSpy.mock.calls[0][0]).toMatch(/422/);
205
+ expect(warnSpy.mock.calls[0][0]).toMatch(/Check your apiKey/);
206
+ });
207
+ it('does NOT log 5xx responses unless debug is true', async () => {
208
+ fetchMock.mockResolvedValue(new Response('boom', { status: 503 }));
209
+ await client.tools.record({ toolName: 'svc_down', durationMs: 1 });
210
+ expect(warnSpy).not.toHaveBeenCalled();
211
+ expect(errorSpy).not.toHaveBeenCalled();
212
+ });
213
+ });
214
+ describe('client.withContext() correlation', () => {
215
+ it('auto-links wrapped tool calls to the surrounding requestId', async () => {
216
+ const tool = client.tool('search', async (q) => ({ q }));
217
+ await client.withContext({ requestId: 'turn-abc' }, async () => {
218
+ await tool('embeddings');
219
+ });
220
+ await flushAsync();
221
+ const body = JSON.parse(fetchMock.mock.calls[0][1].body);
222
+ expect(body.request_id).toBe('turn-abc');
223
+ });
224
+ it('explicit requestId on record() overrides context', async () => {
225
+ await client.withContext({ requestId: 'context-id' }, async () => {
226
+ await client.tools.record({
227
+ toolName: 'override',
228
+ durationMs: 1,
229
+ requestId: 'explicit-id',
230
+ });
231
+ });
232
+ const body = JSON.parse(fetchMock.mock.calls[0][1].body);
233
+ expect(body.request_id).toBe('explicit-id');
234
+ });
235
+ it('tools called outside any context are orphans', async () => {
236
+ const tool = client.tool('orphan_tool', async () => 'ok');
237
+ await tool();
238
+ await flushAsync();
239
+ const body = JSON.parse(fetchMock.mock.calls[0][1].body);
240
+ expect(body.request_id).toBeUndefined();
241
+ });
242
+ });
243
+ });