@auden.to/protocol 0.1.0-alpha.2

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.
@@ -0,0 +1,963 @@
1
+ import { ValiError } from 'valibot';
2
+ import { describe, it, expect } from 'vitest';
3
+ import { MAX_ACTION_METADATA_BYTES, MAX_CONTEXT_CONTENT_BYTES, MAX_TRANSFORM_SOURCE_IDS, MAX_INBOX_ARCHIVE_BATCH, MAX_RAW_GUIDE_CONTENT_BYTES, MAX_RULE_DELETIONS_BATCH, MAX_RULES_IMPORT_BATCH, MAX_SKILL_MEMBER_FILES, MAX_SYNC_ACTIONS_BATCH, MAX_SYNC_AUX_BATCH, parseActionEntry, parseAgentConfig, parseBundle, parseBundleListResponse, parseCliTokenMetadata, parseContextItem, parseContextItemsResponse, parseCreateBundleRequest, parseCreateBundleResponse, parseInboxArchiveRequest, parseInboxCaptureRequest, parseInboxListResponse, parsePushContextRequest, parsePushContextResponse, parseUpdateContextItemRequest, parseDashboardSyncRequest, parseEvalProviderConfig, parseEvalProviderSettings, parseExtractedFeedbackBatch, parseExtractedGuideSuggestion, parseExtractedSkillSuggestion, parseEvalResult, parseGuideBundleResponse, parseNormalizedGuide, parseRawGuideFilePayload, parseGuideMemberFilesResponse, parseRuleDeletionsRequest, parseRulesImportRequest, parseGuideSnapshot, parseProposalCitation, parseProjectionRequest, parseProjectionResult, parseRuleVerdict, parseRunEvalResult, parseValidateTokenRequest, parseValidateTokenResponse, } from './schemas.js';
4
+ // Helper: assert that parse throws a ValiError (not some other error)
5
+ function expectValiError(fn) {
6
+ expect(fn).toThrow(ValiError);
7
+ }
8
+ describe('parseAgentConfig', () => {
9
+ it('accepts a valid agent config', () => {
10
+ const config = {
11
+ id: 'agent-1',
12
+ name: 'Test Agent',
13
+ description: 'A test agent',
14
+ capabilities: ['read', 'write'],
15
+ };
16
+ expect(parseAgentConfig(config)).toEqual(config);
17
+ });
18
+ it('throws ValiError when id is missing', () => {
19
+ expectValiError(() => parseAgentConfig({ name: 'x', description: 'y', capabilities: [] }));
20
+ });
21
+ it('throws ValiError when capabilities contains non-string', () => {
22
+ expectValiError(() => parseAgentConfig({ id: '1', name: 'x', description: 'y', capabilities: [42] }));
23
+ });
24
+ it('accepts optional meta field', () => {
25
+ const config = {
26
+ id: 'a',
27
+ name: 'n',
28
+ description: 'd',
29
+ capabilities: [],
30
+ meta: { key: 'value' },
31
+ };
32
+ expect(parseAgentConfig(config)).toEqual(config);
33
+ });
34
+ });
35
+ describe('parseEvalResult', () => {
36
+ it('accepts a valid eval result', () => {
37
+ const result = {
38
+ agentId: 'agent-1',
39
+ passed: true,
40
+ summary: 'All checks passed',
41
+ findings: [{ level: 'info', message: 'OK' }],
42
+ };
43
+ expect(parseEvalResult(result)).toEqual(result);
44
+ });
45
+ it('throws ValiError when passed is not boolean', () => {
46
+ expectValiError(() => parseEvalResult({ agentId: '1', passed: 'yes', summary: 'x', findings: [] }));
47
+ });
48
+ it('throws ValiError when findings contains invalid level', () => {
49
+ expectValiError(() => parseEvalResult({
50
+ agentId: '1',
51
+ passed: true,
52
+ summary: 'x',
53
+ findings: [{ level: 'debug', message: 'x' }],
54
+ }));
55
+ });
56
+ });
57
+ describe('parseEvalProviderSettings', () => {
58
+ it('accepts partial settings', () => {
59
+ const settings = {
60
+ provider: 'openrouter',
61
+ model: 'anthropic/claude-sonnet-4.6',
62
+ };
63
+ expect(parseEvalProviderSettings(settings)).toEqual(settings);
64
+ });
65
+ it('rejects unknown providers', () => {
66
+ expectValiError(() => parseEvalProviderSettings({ provider: 'bedrock' }));
67
+ });
68
+ });
69
+ describe('parseEvalProviderConfig', () => {
70
+ it('accepts a complete runtime config', () => {
71
+ const config = {
72
+ provider: 'anthropic',
73
+ model: 'claude-sonnet-4-6',
74
+ apiKey: 'sk-ant-test',
75
+ baseUrl: 'https://api.anthropic.com',
76
+ };
77
+ expect(parseEvalProviderConfig(config)).toEqual(config);
78
+ });
79
+ it('requires model', () => {
80
+ expectValiError(() => parseEvalProviderConfig({ provider: 'openrouter' }));
81
+ });
82
+ });
83
+ describe('parseValidateTokenRequest', () => {
84
+ it('accepts valid input', () => {
85
+ expect(parseValidateTokenRequest({ token: 'tok_abc' })).toEqual({ token: 'tok_abc' });
86
+ });
87
+ it('throws ValiError when token is missing', () => {
88
+ expectValiError(() => parseValidateTokenRequest({}));
89
+ });
90
+ });
91
+ describe('parseValidateTokenResponse', () => {
92
+ it('accepts valid input', () => {
93
+ const r = { valid: true, userId: 'u1', expiresAt: '2026-12-31' };
94
+ expect(parseValidateTokenResponse(r)).toEqual(r);
95
+ });
96
+ it('accepts valid-only response payload', () => {
97
+ expect(parseValidateTokenResponse({ valid: true })).toEqual({ valid: true });
98
+ });
99
+ it('throws ValiError when valid is not boolean', () => {
100
+ expectValiError(() => parseValidateTokenResponse({ valid: 'yes', userId: 'u1', expiresAt: '2026-12-31' }));
101
+ });
102
+ });
103
+ describe('parseCliTokenMetadata', () => {
104
+ it('accepts valid input', () => {
105
+ const m = {
106
+ token: 't',
107
+ userId: 'u',
108
+ createdAt: '2026-01-01',
109
+ expiresAt: '2026-12-31',
110
+ };
111
+ expect(parseCliTokenMetadata(m)).toEqual(m);
112
+ });
113
+ it('throws ValiError when createdAt is missing', () => {
114
+ expectValiError(() => parseCliTokenMetadata({ token: 't', userId: 'u', expiresAt: '2026-12-31' }));
115
+ });
116
+ });
117
+ describe('parseGuideSnapshot', () => {
118
+ const snapshot = {
119
+ id: 'g1',
120
+ title: 'My Guide',
121
+ content: '# Rules',
122
+ enabled: true,
123
+ updatedAt: '2026-04-01',
124
+ };
125
+ it('accepts valid snapshot', () => {
126
+ expect(parseGuideSnapshot(snapshot)).toEqual(snapshot);
127
+ });
128
+ it('throws ValiError when enabled is not boolean', () => {
129
+ expectValiError(() => parseGuideSnapshot({ ...snapshot, enabled: 1 }));
130
+ });
131
+ it('accepts optional origin and contentHash for imported guides', () => {
132
+ const imported = {
133
+ ...snapshot,
134
+ origin: 'file:agents-dir:.agents/skill-foo/SKILL.md',
135
+ contentHash: 'a'.repeat(64),
136
+ };
137
+ expect(parseGuideSnapshot(imported)).toEqual(imported);
138
+ });
139
+ it('accepts null origin and contentHash for non-imported guides', () => {
140
+ expect(parseGuideSnapshot({ ...snapshot, origin: null, contentHash: null })).toEqual({
141
+ ...snapshot,
142
+ origin: null,
143
+ contentHash: null,
144
+ });
145
+ });
146
+ it('throws ValiError when contentHash is not a 64-char hex string', () => {
147
+ expectValiError(() => parseGuideSnapshot({ ...snapshot, contentHash: 'not-a-hash' }));
148
+ });
149
+ });
150
+ describe('parseGuideBundleResponse', () => {
151
+ it('accepts valid response', () => {
152
+ const r = { guides: [], pulledAt: '2026-04-01' };
153
+ expect(parseGuideBundleResponse(r)).toEqual(r);
154
+ });
155
+ it('throws ValiError when guides is not an array', () => {
156
+ expectValiError(() => parseGuideBundleResponse({ guides: null, pulledAt: 'x' }));
157
+ });
158
+ });
159
+ describe('parseRawGuideFilePayload', () => {
160
+ it('accepts a valid raw guide payload', () => {
161
+ const payload = {
162
+ path: '.agents/reviewer/SKILL.md',
163
+ format: 'agents-dir',
164
+ rawContent: '# Reviewer\n\n- Check tests.',
165
+ lastModified: 1_745_040_000_000,
166
+ content_hash: '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef',
167
+ };
168
+ expect(parseRawGuideFilePayload(payload)).toEqual(payload);
169
+ });
170
+ it('rejects unsupported formats', () => {
171
+ expectValiError(() => parseRawGuideFilePayload({
172
+ path: 'README.md',
173
+ format: 'markdown',
174
+ rawContent: '# Nope',
175
+ lastModified: 1,
176
+ content_hash: '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef',
177
+ }));
178
+ });
179
+ it('rejects invalid content hashes', () => {
180
+ expectValiError(() => parseRawGuideFilePayload({
181
+ path: 'AGENTS.md',
182
+ format: 'agents-md',
183
+ rawContent: '# Agents',
184
+ lastModified: 1,
185
+ content_hash: 'not-a-sha256',
186
+ }));
187
+ });
188
+ it('accepts rawContent up to the byte cap', () => {
189
+ const rawContent = 'a'.repeat(MAX_RAW_GUIDE_CONTENT_BYTES);
190
+ expect(() => parseRawGuideFilePayload({
191
+ path: 'AGENTS.md',
192
+ format: 'agents-md',
193
+ rawContent,
194
+ lastModified: 1,
195
+ content_hash: '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef',
196
+ })).not.toThrow();
197
+ });
198
+ it('rejects rawContent over the byte cap', () => {
199
+ const rawContent = 'a'.repeat(MAX_RAW_GUIDE_CONTENT_BYTES + 1);
200
+ expectValiError(() => parseRawGuideFilePayload({
201
+ path: 'AGENTS.md',
202
+ format: 'agents-md',
203
+ rawContent,
204
+ lastModified: 1,
205
+ content_hash: '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef',
206
+ }));
207
+ });
208
+ describe('skill members', () => {
209
+ const HASH = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef';
210
+ const member = (path) => ({
211
+ path,
212
+ rawContent: 'print("hi")',
213
+ lastModified: 1,
214
+ content_hash: HASH,
215
+ });
216
+ const skillPayload = (members) => ({
217
+ path: '.claude/skills/reviewer/SKILL.md',
218
+ format: 'skill-md',
219
+ rawContent: '# Reviewer',
220
+ lastModified: 1,
221
+ content_hash: HASH,
222
+ members,
223
+ });
224
+ it('accepts a skill-md payload with members, including an empty list', () => {
225
+ expect(() => parseRawGuideFilePayload(skillPayload([member('scripts/lint.py')]))).not.toThrow();
226
+ expect(() => parseRawGuideFilePayload(skillPayload([]))).not.toThrow();
227
+ });
228
+ it('rejects members on non-skill-md payloads', () => {
229
+ expectValiError(() => parseRawGuideFilePayload({
230
+ path: 'AGENTS.md',
231
+ format: 'agents-md',
232
+ rawContent: '# Agents',
233
+ lastModified: 1,
234
+ content_hash: HASH,
235
+ members: [],
236
+ }));
237
+ });
238
+ it('rejects unsafe member paths', () => {
239
+ for (const path of [
240
+ '../escape.md',
241
+ '/abs.md',
242
+ 'a//b.md',
243
+ 'a/../b.md',
244
+ './x.md',
245
+ 'a\\b.md',
246
+ // Windows drive-letter absolute forms escape path.resolve(skillDir, …).
247
+ 'C:/evil.md',
248
+ 'c:relative-to-drive.md',
249
+ // Control characters must never reach a filesystem API on export.
250
+ 'notes/\u0000evil.md',
251
+ 'notes/line\nbreak.md',
252
+ // A member must not shadow the skill's own SKILL.md.
253
+ 'SKILL.md',
254
+ 'skill.md',
255
+ ]) {
256
+ expectValiError(() => parseRawGuideFilePayload(skillPayload([member(path)])));
257
+ }
258
+ });
259
+ it('accepts a nested SKILL.md member path (only the root one shadows)', () => {
260
+ expect(() => parseRawGuideFilePayload(skillPayload([member('references/SKILL.md')]))).not.toThrow();
261
+ });
262
+ it('rejects duplicate member paths, case-insensitively', () => {
263
+ expectValiError(() => parseRawGuideFilePayload(skillPayload([member('references/api.md'), member('references/api.md')])));
264
+ // Case-folding filesystems (macOS/Windows default) would collapse these
265
+ // into one file on export.
266
+ expectValiError(() => parseRawGuideFilePayload(skillPayload([member('references/api.md'), member('references/API.md')])));
267
+ });
268
+ it('rejects more members than the per-skill cap', () => {
269
+ const members = Array.from({ length: MAX_SKILL_MEMBER_FILES + 1 }, (_, i) => member(`references/${i}.md`));
270
+ expectValiError(() => parseRawGuideFilePayload(skillPayload(members)));
271
+ });
272
+ it('accepts skippedMemberPaths alongside members, with looser path rules', () => {
273
+ expect(() => parseRawGuideFilePayload({
274
+ ...skillPayload([member('scripts/lint.py')]),
275
+ // Skipped paths describe what is on disk — odd names must not fail
276
+ // the payload (they are only string-matched, never re-created).
277
+ skippedMemberPaths: ['assets/logo.png', 'weird name…\\here.bin'],
278
+ })).not.toThrow();
279
+ });
280
+ it('parses a member-files response and rejects unsafe paths in it', () => {
281
+ expect(() => parseGuideMemberFilesResponse({
282
+ memberFiles: [
283
+ { path: 'scripts/lint.py', content: 'print("hi")', contentHash: HASH },
284
+ ],
285
+ })).not.toThrow();
286
+ expectValiError(() => parseGuideMemberFilesResponse({
287
+ memberFiles: [{ path: '../escape.md', content: 'x', contentHash: HASH }],
288
+ }));
289
+ // Case-collisions resolve to one file on macOS/Windows — the second
290
+ // would silently clobber the first without an OVERWRITE prompt.
291
+ expectValiError(() => parseGuideMemberFilesResponse({
292
+ memberFiles: [
293
+ { path: 'notes.md', content: 'a', contentHash: HASH },
294
+ { path: 'Notes.md', content: 'b', contentHash: HASH },
295
+ ],
296
+ }));
297
+ });
298
+ it('rejects skippedMemberPaths without members', () => {
299
+ expectValiError(() => parseRawGuideFilePayload({
300
+ ...skillPayload(undefined),
301
+ members: undefined,
302
+ skippedMemberPaths: ['assets/logo.png'],
303
+ }));
304
+ });
305
+ });
306
+ });
307
+ describe('parseRulesImportRequest', () => {
308
+ it('accepts an empty rules array', () => {
309
+ expect(parseRulesImportRequest({ rules: [] })).toEqual({ rules: [] });
310
+ });
311
+ it('accepts a rules array up to the batch cap', () => {
312
+ const rules = Array.from({ length: MAX_RULES_IMPORT_BATCH }, () => ({}));
313
+ expect(() => parseRulesImportRequest({ rules })).not.toThrow();
314
+ });
315
+ it('rejects a rules array over the batch cap', () => {
316
+ const rules = Array.from({ length: MAX_RULES_IMPORT_BATCH + 1 }, () => ({}));
317
+ expectValiError(() => parseRulesImportRequest({ rules }));
318
+ });
319
+ it('rejects a body without a rules array', () => {
320
+ expectValiError(() => parseRulesImportRequest({}));
321
+ expectValiError(() => parseRulesImportRequest({ rules: 'oops' }));
322
+ });
323
+ });
324
+ describe('parseRuleDeletionsRequest', () => {
325
+ const deletion = { path: 'AGENTS.md', format: 'agents-md' };
326
+ it('accepts a deletions array up to the batch cap', () => {
327
+ const deletions = Array.from({ length: MAX_RULE_DELETIONS_BATCH }, () => deletion);
328
+ expect(() => parseRuleDeletionsRequest({ deletions })).not.toThrow();
329
+ });
330
+ it('rejects a deletions array over the batch cap', () => {
331
+ const deletions = Array.from({ length: MAX_RULE_DELETIONS_BATCH + 1 }, () => deletion);
332
+ expectValiError(() => parseRuleDeletionsRequest({ deletions }));
333
+ });
334
+ });
335
+ describe('parseNormalizedGuide', () => {
336
+ const valid = {
337
+ rules: [
338
+ { text: 'Use single quotes for strings.', section: 'Style' },
339
+ { text: 'Top-level instruction.', section: null },
340
+ ],
341
+ summary: 'Repository conventions for a TypeScript dashboard.',
342
+ };
343
+ it('accepts a valid payload with mixed-section rules', () => {
344
+ expect(parseNormalizedGuide(valid)).toEqual(valid);
345
+ });
346
+ it('accepts an empty rules array', () => {
347
+ const payload = { rules: [], summary: 'No actionable rules detected.' };
348
+ expect(parseNormalizedGuide(payload)).toEqual(payload);
349
+ });
350
+ it('rejects empty rule text', () => {
351
+ expectValiError(() => parseNormalizedGuide({
352
+ rules: [{ text: '', section: null }],
353
+ summary: 'x',
354
+ }));
355
+ });
356
+ it('rejects rules with missing section field', () => {
357
+ expectValiError(() => parseNormalizedGuide({
358
+ rules: [{ text: 'a rule' }],
359
+ summary: 'x',
360
+ }));
361
+ });
362
+ it('rejects payloads missing summary', () => {
363
+ expectValiError(() => parseNormalizedGuide({
364
+ rules: [{ text: 'a rule', section: null }],
365
+ }));
366
+ });
367
+ it('rejects non-array rules', () => {
368
+ expectValiError(() => parseNormalizedGuide({
369
+ rules: 'not an array',
370
+ summary: 'x',
371
+ }));
372
+ });
373
+ });
374
+ describe('parseDashboardSyncRequest', () => {
375
+ it('accepts a valid dashboard sync payload', () => {
376
+ const payload = {
377
+ actions: [
378
+ {
379
+ id: 'action-1',
380
+ actionType: 'file_edit',
381
+ summary: 'Edited src/index.ts',
382
+ verdict: 'aligned',
383
+ gradingTier: 'pattern_match',
384
+ filePaths: ['/src/index.ts'],
385
+ metadata: { prompt: 'update component' },
386
+ },
387
+ ],
388
+ guideSuggestions: [
389
+ {
390
+ id: 'guide-1',
391
+ content: 'Avoid adding unit tests unless requested.',
392
+ reasoning: 'Repeated correction across sessions.',
393
+ triggerType: 'conversational_feedback',
394
+ sourceActionIds: [
395
+ {
396
+ sessionId: 'session-1',
397
+ timestamp: '2026-04-19T10:00:00Z',
398
+ summary: 'Asked the agent not to add tests.',
399
+ },
400
+ ],
401
+ status: 'pending',
402
+ },
403
+ ],
404
+ feedbackCandidates: [
405
+ {
406
+ id: 'candidate-1',
407
+ signal: 'Repeated workflow: bun test → bun run lint.',
408
+ signalType: 'skill_suggestion',
409
+ confidence: 0.78,
410
+ occurrences: 2,
411
+ status: 'inferred',
412
+ },
413
+ ],
414
+ };
415
+ expect(parseDashboardSyncRequest(payload)).toEqual(payload);
416
+ });
417
+ it('accepts run usage entries', () => {
418
+ const payload = {
419
+ actions: [],
420
+ guideSuggestions: [],
421
+ feedbackCandidates: [],
422
+ runUsage: [
423
+ {
424
+ runId: 'run-1',
425
+ sessionId: 'session-1',
426
+ usage: [
427
+ {
428
+ model: 'claude-sonnet-5',
429
+ inputTokens: 100,
430
+ outputTokens: 50,
431
+ cacheReadTokens: 200,
432
+ cacheWriteTokens: 10,
433
+ },
434
+ ],
435
+ },
436
+ ],
437
+ };
438
+ expect(parseDashboardSyncRequest(payload)).toEqual(payload);
439
+ });
440
+ it('throws ValiError when a run usage entry has negative tokens', () => {
441
+ expectValiError(() => parseDashboardSyncRequest({
442
+ actions: [],
443
+ guideSuggestions: [],
444
+ feedbackCandidates: [],
445
+ runUsage: [
446
+ {
447
+ sessionId: 'session-1',
448
+ usage: [{ model: 'claude-sonnet-5', inputTokens: -1, outputTokens: 50 }],
449
+ },
450
+ ],
451
+ }));
452
+ });
453
+ it('throws ValiError when a guide suggestion is malformed', () => {
454
+ expectValiError(() => parseDashboardSyncRequest({
455
+ actions: [],
456
+ guideSuggestions: [
457
+ {
458
+ content: 'Always use strict mode',
459
+ reasoning: 'Repeated gap',
460
+ triggerType: 'not-valid',
461
+ },
462
+ ],
463
+ feedbackCandidates: [],
464
+ }));
465
+ });
466
+ it('throws ValiError when a feedback candidate is malformed', () => {
467
+ expectValiError(() => parseDashboardSyncRequest({
468
+ actions: [],
469
+ guideSuggestions: [],
470
+ feedbackCandidates: [
471
+ {
472
+ signal: 'Repeated workflow',
473
+ signalType: 'skill_suggestion',
474
+ confidence: 1.5,
475
+ },
476
+ ],
477
+ }));
478
+ });
479
+ it('accepts action metadata up to the size cap', () => {
480
+ // -2 to leave room for the wrapping `{"x":"..."}` quotes/braces.
481
+ const value = 'a'.repeat(MAX_ACTION_METADATA_BYTES - '{"x":""}'.length);
482
+ const payload = {
483
+ actions: [
484
+ {
485
+ actionType: 'file_edit',
486
+ summary: 'Edited src/index.ts',
487
+ metadata: { x: value },
488
+ },
489
+ ],
490
+ };
491
+ expect(() => parseDashboardSyncRequest(payload)).not.toThrow();
492
+ });
493
+ it('throws ValiError when action metadata exceeds the size cap', () => {
494
+ const value = 'a'.repeat(MAX_ACTION_METADATA_BYTES);
495
+ expectValiError(() => parseDashboardSyncRequest({
496
+ actions: [
497
+ {
498
+ actionType: 'file_edit',
499
+ summary: 'Edited src/index.ts',
500
+ metadata: { x: value },
501
+ },
502
+ ],
503
+ }));
504
+ });
505
+ it('accepts actions up to the batch cap', () => {
506
+ const actions = Array.from({ length: MAX_SYNC_ACTIONS_BATCH }, (_, i) => ({
507
+ actionType: 'file_edit',
508
+ summary: `action-${i}`,
509
+ }));
510
+ expect(() => parseDashboardSyncRequest({ actions })).not.toThrow();
511
+ });
512
+ it('throws ValiError when actions exceed the batch cap', () => {
513
+ const actions = Array.from({ length: MAX_SYNC_ACTIONS_BATCH + 1 }, (_, i) => ({
514
+ actionType: 'file_edit',
515
+ summary: `action-${i}`,
516
+ }));
517
+ expectValiError(() => parseDashboardSyncRequest({ actions }));
518
+ });
519
+ it('throws ValiError when guideSuggestions exceed the batch cap', () => {
520
+ const guideSuggestions = Array.from({ length: MAX_SYNC_AUX_BATCH + 1 }, () => ({
521
+ content: 'Always use strict mode.',
522
+ triggerType: 'uncovered',
523
+ }));
524
+ expectValiError(() => parseDashboardSyncRequest({ guideSuggestions }));
525
+ });
526
+ it('throws ValiError when feedbackCandidates exceed the batch cap', () => {
527
+ const feedbackCandidates = Array.from({ length: MAX_SYNC_AUX_BATCH + 1 }, () => ({
528
+ signal: 'Repeated workflow.',
529
+ signalType: 'correction',
530
+ }));
531
+ expectValiError(() => parseDashboardSyncRequest({ feedbackCandidates }));
532
+ });
533
+ it('throws ValiError when runUsage exceeds the batch cap', () => {
534
+ const runUsage = Array.from({ length: MAX_SYNC_AUX_BATCH + 1 }, (_, i) => ({
535
+ sessionId: `session-${i}`,
536
+ usage: [],
537
+ }));
538
+ expectValiError(() => parseDashboardSyncRequest({ runUsage }));
539
+ });
540
+ });
541
+ describe('conversational feedback schemas', () => {
542
+ const citation = {
543
+ sessionId: 'session-1',
544
+ timestamp: '2026-04-19T10:00:00Z',
545
+ summary: 'Asked the agent not to add unit tests.',
546
+ };
547
+ it('accepts a valid proposal citation', () => {
548
+ expect(parseProposalCitation(citation)).toEqual(citation);
549
+ });
550
+ it('accepts a valid extracted guide suggestion', () => {
551
+ const suggestion = {
552
+ id: 'guide-1',
553
+ content: 'Avoid adding unit tests unless the user asks for them.',
554
+ reasoning: 'Observed the same correction in three Claude Code sessions.',
555
+ triggerType: 'conversational_feedback',
556
+ confidence: 0.88,
557
+ occurrences: 3,
558
+ citations: [citation],
559
+ source: 'claude_code',
560
+ };
561
+ expect(parseExtractedGuideSuggestion(suggestion)).toEqual(suggestion);
562
+ });
563
+ it('accepts a valid extracted skill suggestion', () => {
564
+ const suggestion = {
565
+ id: 'skill-1',
566
+ signal: 'Repeated workflow: bun test → bun run lint.',
567
+ confidence: 0.76,
568
+ occurrences: 2,
569
+ citations: [citation],
570
+ source: 'claude_code',
571
+ };
572
+ expect(parseExtractedSkillSuggestion(suggestion)).toEqual(suggestion);
573
+ });
574
+ it('accepts a valid extracted feedback batch', () => {
575
+ const batch = {
576
+ guideSuggestions: [
577
+ {
578
+ id: 'guide-1',
579
+ content: 'Prefer var over explicit types.',
580
+ reasoning: 'Observed the same correction in two sessions.',
581
+ triggerType: 'conversational_feedback',
582
+ confidence: 0.72,
583
+ occurrences: 2,
584
+ citations: [citation],
585
+ source: 'claude_code',
586
+ },
587
+ ],
588
+ skillSuggestions: [
589
+ {
590
+ id: 'skill-1',
591
+ signal: 'Repeated workflow: bun test → bun run lint.',
592
+ confidence: 0.76,
593
+ occurrences: 2,
594
+ citations: [citation],
595
+ source: 'claude_code',
596
+ },
597
+ ],
598
+ };
599
+ expect(parseExtractedFeedbackBatch(batch)).toEqual(batch);
600
+ });
601
+ it('rejects guide suggestions with unsupported trigger types', () => {
602
+ expectValiError(() => parseExtractedGuideSuggestion({
603
+ id: 'guide-1',
604
+ content: 'x',
605
+ reasoning: 'y',
606
+ triggerType: 'uncovered',
607
+ confidence: 0.9,
608
+ occurrences: 2,
609
+ citations: [citation],
610
+ source: 'claude_code',
611
+ }));
612
+ });
613
+ });
614
+ describe('parseProjectionRequest', () => {
615
+ it('accepts valid input', () => {
616
+ const r = { agents: [], outputDir: './out' };
617
+ expect(parseProjectionRequest(r)).toEqual(r);
618
+ });
619
+ it('throws ValiError when outputDir is missing', () => {
620
+ expectValiError(() => parseProjectionRequest({ agents: [] }));
621
+ });
622
+ });
623
+ describe('parseProjectionResult', () => {
624
+ it('accepts valid input', () => {
625
+ const r = { agentsMd: '# Agents', agentsDir: {} };
626
+ expect(parseProjectionResult(r)).toEqual(r);
627
+ });
628
+ it('throws ValiError when agentsMd is missing', () => {
629
+ expectValiError(() => parseProjectionResult({ agentsDir: {} }));
630
+ });
631
+ });
632
+ describe('parseActionEntry', () => {
633
+ it('accepts valid entry with all fields', () => {
634
+ const entry = {
635
+ timestamp: '2026-04-19T10:00:00Z',
636
+ sessionId: 'abc123',
637
+ toolName: 'Edit',
638
+ filePath: '/src/foo.ts',
639
+ command: undefined,
640
+ };
641
+ expect(parseActionEntry(entry)).toMatchObject({
642
+ timestamp: entry.timestamp,
643
+ sessionId: entry.sessionId,
644
+ toolName: entry.toolName,
645
+ filePath: entry.filePath,
646
+ });
647
+ });
648
+ it('accepts entry without optional fields', () => {
649
+ const entry = {
650
+ timestamp: '2026-04-19T10:00:00Z',
651
+ sessionId: 'abc',
652
+ toolName: 'Read',
653
+ };
654
+ expect(parseActionEntry(entry)).toMatchObject(entry);
655
+ });
656
+ it('throws ValiError when toolName is missing', () => {
657
+ expectValiError(() => parseActionEntry({ timestamp: 't', sessionId: 's' }));
658
+ });
659
+ it('throws ValiError when sessionId is missing', () => {
660
+ expectValiError(() => parseActionEntry({ timestamp: 't', toolName: 'Edit' }));
661
+ });
662
+ });
663
+ describe('parseRuleVerdict', () => {
664
+ it('accepts aligned verdict', () => {
665
+ const v = {
666
+ rule: 'Use single quotes',
667
+ verdict: 'aligned',
668
+ reasoning: 'All edits used single quotes',
669
+ };
670
+ expect(parseRuleVerdict(v)).toEqual(v);
671
+ });
672
+ it('accepts misaligned verdict', () => {
673
+ const v = {
674
+ rule: 'Write tests',
675
+ verdict: 'misaligned',
676
+ reasoning: 'No test file created',
677
+ };
678
+ expect(parseRuleVerdict(v)).toEqual(v);
679
+ });
680
+ it('accepts not_applicable verdict', () => {
681
+ const v = {
682
+ rule: 'Use Tamagui',
683
+ verdict: 'not_applicable',
684
+ reasoning: 'No UI changes',
685
+ };
686
+ expect(parseRuleVerdict(v)).toEqual(v);
687
+ });
688
+ it('throws ValiError on invalid verdict', () => {
689
+ expectValiError(() => parseRuleVerdict({ rule: 'x', verdict: 'unknown', reasoning: 'y' }));
690
+ });
691
+ it('throws ValiError when reasoning is missing', () => {
692
+ expectValiError(() => parseRuleVerdict({ rule: 'x', verdict: 'aligned' }));
693
+ });
694
+ });
695
+ describe('parseRunEvalResult', () => {
696
+ const valid = {
697
+ verdicts: [{ rule: 'Use single quotes', verdict: 'aligned', reasoning: 'ok' }],
698
+ summary: '1/1 rules followed',
699
+ score: 1.0,
700
+ };
701
+ it('accepts valid result', () => {
702
+ expect(parseRunEvalResult(valid)).toEqual(valid);
703
+ });
704
+ it('accepts score of 0.0', () => {
705
+ expect(parseRunEvalResult({ ...valid, score: 0 })).toMatchObject({ score: 0 });
706
+ });
707
+ it('throws ValiError when score exceeds 1', () => {
708
+ expectValiError(() => parseRunEvalResult({ ...valid, score: 1.5 }));
709
+ });
710
+ it('throws ValiError when score is negative', () => {
711
+ expectValiError(() => parseRunEvalResult({ ...valid, score: -0.1 }));
712
+ });
713
+ it('throws ValiError when verdicts is not an array', () => {
714
+ expectValiError(() => parseRunEvalResult({ ...valid, verdicts: null }));
715
+ });
716
+ });
717
+ describe('parseBundle', () => {
718
+ const valid = {
719
+ id: 'bundle-1',
720
+ slug: 'travel-log',
721
+ name: 'Travel Log',
722
+ kind: 'user',
723
+ createdAt: '2026-07-06T00:00:00.000Z',
724
+ };
725
+ it('accepts a valid bundle', () => {
726
+ expect(parseBundle(valid)).toEqual(valid);
727
+ });
728
+ it('accepts each bundle kind', () => {
729
+ for (const kind of ['user', 'inbox', 'system']) {
730
+ expect(parseBundle({ ...valid, kind })).toMatchObject({ kind });
731
+ }
732
+ });
733
+ it('throws ValiError on an unknown kind', () => {
734
+ expectValiError(() => parseBundle({ ...valid, kind: 'archive' }));
735
+ });
736
+ it('throws ValiError on an uppercase slug', () => {
737
+ expectValiError(() => parseBundle({ ...valid, slug: 'Travel-Log' }));
738
+ });
739
+ it('throws ValiError on a slug with spaces', () => {
740
+ expectValiError(() => parseBundle({ ...valid, slug: 'travel log' }));
741
+ });
742
+ it('throws ValiError on a slug starting with a hyphen', () => {
743
+ expectValiError(() => parseBundle({ ...valid, slug: '-notes' }));
744
+ });
745
+ });
746
+ describe('parseBundleListResponse', () => {
747
+ it('accepts an empty list', () => {
748
+ expect(parseBundleListResponse({ bundles: [] })).toEqual({ bundles: [] });
749
+ });
750
+ it('throws ValiError when bundles is missing', () => {
751
+ expectValiError(() => parseBundleListResponse({}));
752
+ });
753
+ });
754
+ describe('parseCreateBundleRequest', () => {
755
+ it('accepts slug + name without kind', () => {
756
+ expect(parseCreateBundleRequest({ slug: 'notes', name: 'Notes' })).toEqual({
757
+ slug: 'notes',
758
+ name: 'Notes',
759
+ });
760
+ });
761
+ it('accepts an optional kind', () => {
762
+ expect(parseCreateBundleRequest({ slug: 'notes', name: 'Notes', kind: 'user' })).toMatchObject({ kind: 'user' });
763
+ });
764
+ it('throws ValiError when name is empty', () => {
765
+ expectValiError(() => parseCreateBundleRequest({ slug: 'notes', name: '' }));
766
+ });
767
+ it('throws ValiError when slug is invalid', () => {
768
+ expectValiError(() => parseCreateBundleRequest({ slug: 'My Notes', name: 'Notes' }));
769
+ });
770
+ });
771
+ describe('parseCreateBundleResponse', () => {
772
+ it('accepts a wrapped bundle', () => {
773
+ const bundle = {
774
+ id: 'bundle-1',
775
+ slug: 'notes',
776
+ name: 'Notes',
777
+ kind: 'user',
778
+ createdAt: '2026-07-06T00:00:00.000Z',
779
+ };
780
+ expect(parseCreateBundleResponse({ bundle })).toEqual({ bundle });
781
+ });
782
+ it('throws ValiError when bundle is missing', () => {
783
+ expectValiError(() => parseCreateBundleResponse({}));
784
+ });
785
+ });
786
+ describe('parseContextItem', () => {
787
+ const valid = {
788
+ id: 'file-1',
789
+ type: 'document',
790
+ name: 'Trip notes',
791
+ contentType: 'document',
792
+ content: 'Went to Lisbon.',
793
+ origin: null,
794
+ updatedAt: '2026-07-06T00:00:00.000Z',
795
+ };
796
+ it('accepts a valid item', () => {
797
+ expect(parseContextItem(valid)).toEqual(valid);
798
+ });
799
+ it('accepts each item type', () => {
800
+ for (const type of ['guide', 'document', 'reference', 'asset']) {
801
+ expect(parseContextItem({ ...valid, type })).toMatchObject({ type });
802
+ }
803
+ });
804
+ it('accepts null content (assets store an object key elsewhere)', () => {
805
+ expect(parseContextItem({ ...valid, type: 'asset', content: null })).toMatchObject({
806
+ content: null,
807
+ });
808
+ });
809
+ it('throws ValiError on an unknown type', () => {
810
+ expectValiError(() => parseContextItem({ ...valid, type: 'note' }));
811
+ });
812
+ });
813
+ describe('parseContextItemsResponse', () => {
814
+ it('accepts an empty list', () => {
815
+ expect(parseContextItemsResponse({ items: [] })).toEqual({ items: [] });
816
+ });
817
+ it('throws ValiError when items is missing', () => {
818
+ expectValiError(() => parseContextItemsResponse({}));
819
+ });
820
+ });
821
+ describe('parsePushContextRequest', () => {
822
+ const valid = { name: 'Notes', contentType: 'document', content: 'body' };
823
+ it('accepts a minimal create payload', () => {
824
+ expect(parsePushContextRequest(valid)).toMatchObject(valid);
825
+ });
826
+ it('accepts an optional itemId + origin', () => {
827
+ expect(parsePushContextRequest({ ...valid, itemId: 'file-1', origin: 'cli' })).toMatchObject({ itemId: 'file-1', origin: 'cli' });
828
+ });
829
+ it('throws ValiError on a non-writable contentType', () => {
830
+ expectValiError(() => parsePushContextRequest({ ...valid, contentType: 'guide' }));
831
+ });
832
+ it('throws ValiError when name is empty', () => {
833
+ expectValiError(() => parsePushContextRequest({ ...valid, name: '' }));
834
+ });
835
+ it('throws ValiError when content exceeds the byte cap', () => {
836
+ const content = 'a'.repeat(MAX_CONTEXT_CONTENT_BYTES + 1);
837
+ expectValiError(() => parsePushContextRequest({ ...valid, content }));
838
+ });
839
+ it('accepts sourceItemIds citing the transform sources', () => {
840
+ expect(parsePushContextRequest({ ...valid, sourceItemIds: ['item-1', 'item-2'] })).toMatchObject({ sourceItemIds: ['item-1', 'item-2'] });
841
+ });
842
+ it('throws ValiError on an empty sourceItemIds array', () => {
843
+ expectValiError(() => parsePushContextRequest({ ...valid, sourceItemIds: [] }));
844
+ });
845
+ it('throws ValiError when sourceItemIds exceeds the batch cap', () => {
846
+ const ids = Array.from({ length: MAX_TRANSFORM_SOURCE_IDS + 1 }, (_, i) => `id-${i}`);
847
+ expectValiError(() => parsePushContextRequest({ ...valid, sourceItemIds: ids }));
848
+ });
849
+ });
850
+ describe('parsePushContextResponse', () => {
851
+ const item = {
852
+ id: 'file-1',
853
+ type: 'document',
854
+ name: 'Notes',
855
+ contentType: 'document',
856
+ content: 'body',
857
+ updatedAt: '2026-07-10T00:00:00.000Z',
858
+ };
859
+ it('accepts a response without a transform grade', () => {
860
+ expect(parsePushContextResponse({ item, created: true })).toMatchObject({
861
+ created: true,
862
+ });
863
+ });
864
+ it('accepts a response carrying a transform grade', () => {
865
+ const transform = {
866
+ actionId: 'action-1',
867
+ verdict: 'aligned',
868
+ gradingTier: 'llm',
869
+ reasoning: 'All facts preserved.',
870
+ };
871
+ expect(parsePushContextResponse({ item, created: true, transform })).toMatchObject({
872
+ transform,
873
+ });
874
+ });
875
+ it('tolerates a future gradingTier value (open string, not a picklist)', () => {
876
+ expect(parsePushContextResponse({
877
+ item,
878
+ created: true,
879
+ transform: {
880
+ actionId: 'action-1',
881
+ verdict: 'aligned',
882
+ gradingTier: 'cached',
883
+ reasoning: null,
884
+ },
885
+ }).transform?.gradingTier).toBe('cached');
886
+ });
887
+ it('throws ValiError on an unknown transform verdict', () => {
888
+ expectValiError(() => parsePushContextResponse({
889
+ item,
890
+ created: true,
891
+ transform: {
892
+ actionId: 'action-1',
893
+ verdict: 'passed',
894
+ gradingTier: 'llm',
895
+ reasoning: null,
896
+ },
897
+ }));
898
+ });
899
+ });
900
+ describe('parseUpdateContextItemRequest', () => {
901
+ it('accepts a partial update (content only)', () => {
902
+ expect(parseUpdateContextItemRequest({ itemId: 'file-1', content: 'new' })).toMatchObject({
903
+ itemId: 'file-1',
904
+ content: 'new',
905
+ });
906
+ });
907
+ it('throws ValiError when itemId is empty', () => {
908
+ expectValiError(() => parseUpdateContextItemRequest({ itemId: '', content: 'new' }));
909
+ });
910
+ });
911
+ describe('parseInboxCaptureRequest', () => {
912
+ it('accepts a text capture', () => {
913
+ expect(parseInboxCaptureRequest({ contentType: 'document', content: 'quick note' })).toMatchObject({ contentType: 'document' });
914
+ });
915
+ it('accepts an optional name + source', () => {
916
+ expect(parseInboxCaptureRequest({
917
+ contentType: 'document',
918
+ content: 'x',
919
+ name: 'Idea',
920
+ source: 'app',
921
+ })).toMatchObject({ name: 'Idea', source: 'app' });
922
+ });
923
+ it('throws ValiError on a reference contentType (inbox is document | asset)', () => {
924
+ expectValiError(() => parseInboxCaptureRequest({ contentType: 'reference', content: 'x' }));
925
+ });
926
+ });
927
+ describe('parseInboxListResponse', () => {
928
+ it('accepts an empty list', () => {
929
+ expect(parseInboxListResponse({ items: [] })).toEqual({ items: [] });
930
+ });
931
+ it('accepts an item carrying capture provenance', () => {
932
+ const item = {
933
+ id: 'file-1',
934
+ type: 'document',
935
+ name: 'Note',
936
+ contentType: 'document',
937
+ content: 'x',
938
+ source: 'app',
939
+ capturedAt: '2026-07-06T00:00:00.000Z',
940
+ updatedAt: '2026-07-06T00:00:00.000Z',
941
+ };
942
+ expect(parseInboxListResponse({ items: [item] })).toEqual({ items: [item] });
943
+ });
944
+ });
945
+ describe('parseInboxArchiveRequest', () => {
946
+ it('accepts a non-empty id list', () => {
947
+ expect(parseInboxArchiveRequest({ ids: ['file-1', 'file-2'] })).toEqual({
948
+ ids: ['file-1', 'file-2'],
949
+ });
950
+ });
951
+ it('throws ValiError on an empty id list', () => {
952
+ expectValiError(() => parseInboxArchiveRequest({ ids: [] }));
953
+ });
954
+ it('accepts an id list up to the batch cap', () => {
955
+ const ids = Array.from({ length: MAX_INBOX_ARCHIVE_BATCH }, (_, i) => `id-${i}`);
956
+ expect(() => parseInboxArchiveRequest({ ids })).not.toThrow();
957
+ });
958
+ it('throws ValiError on an id list over the batch cap', () => {
959
+ const ids = Array.from({ length: MAX_INBOX_ARCHIVE_BATCH + 1 }, (_, i) => `id-${i}`);
960
+ expectValiError(() => parseInboxArchiveRequest({ ids }));
961
+ });
962
+ });
963
+ //# sourceMappingURL=schemas.test.js.map