@desplega.ai/agent-swarm 1.76.2 → 1.77.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.
@@ -0,0 +1,276 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { Type } from "typebox";
3
+ import { z } from "zod";
4
+ import { completeStructured } from "../../utils/internal-ai/complete-structured.js";
5
+ import type { ResolvedCredential } from "../../utils/internal-ai/credentials.js";
6
+
7
+ const ResultZodSchema = z.object({
8
+ summary: z.string(),
9
+ count: z.number(),
10
+ });
11
+
12
+ const ResultToolSchema = Type.Object({
13
+ summary: Type.String(),
14
+ count: Type.Number(),
15
+ });
16
+
17
+ /** Build a minimal `AssistantMessage` for `_complete` injection. */
18
+ function makeMsg(content: any[]): any {
19
+ return {
20
+ role: "assistant",
21
+ content,
22
+ api: "responses",
23
+ provider: "openai",
24
+ model: "gpt-5.4-mini",
25
+ usage: {
26
+ input: 0,
27
+ output: 0,
28
+ cacheRead: 0,
29
+ cacheWrite: 0,
30
+ totalTokens: 0,
31
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
32
+ },
33
+ stopReason: "toolUse",
34
+ timestamp: Date.now(),
35
+ };
36
+ }
37
+
38
+ describe("completeStructured", () => {
39
+ test("happy path: tool-call matches schema → returns parsed object, no retries", async () => {
40
+ let invocations = 0;
41
+ const result = await completeStructured({
42
+ zodSchema: ResultZodSchema,
43
+ toolSchema: ResultToolSchema,
44
+ toolName: "record_result",
45
+ toolDescription: "Record the result.",
46
+ systemPrompt: "sys",
47
+ userPrompt: "user",
48
+ _credentialOverride: {
49
+ kind: "openrouter",
50
+ apiKey: "test",
51
+ modelDefault: "openrouter/google/gemini-3-flash-preview",
52
+ },
53
+ _complete: async () => {
54
+ invocations++;
55
+ return makeMsg([
56
+ {
57
+ type: "toolCall",
58
+ id: "call_1",
59
+ name: "record_result",
60
+ arguments: { summary: "ok", count: 7 },
61
+ },
62
+ ]);
63
+ },
64
+ });
65
+ expect(invocations).toBe(1);
66
+ expect(result).toEqual({ summary: "ok", count: 7 });
67
+ });
68
+
69
+ test("no tool call for 3 attempts → returns null, exactly retries invocations", async () => {
70
+ let invocations = 0;
71
+ const original = console.error;
72
+ let errLines = 0;
73
+ console.error = () => {
74
+ errLines++;
75
+ };
76
+ try {
77
+ const result = await completeStructured({
78
+ zodSchema: ResultZodSchema,
79
+ toolSchema: ResultToolSchema,
80
+ toolName: "record_result",
81
+ toolDescription: "Record the result.",
82
+ systemPrompt: "sys",
83
+ userPrompt: "user",
84
+ retries: 3,
85
+ _credentialOverride: {
86
+ kind: "openrouter",
87
+ apiKey: "test",
88
+ modelDefault: "openrouter/google/gemini-3-flash-preview",
89
+ },
90
+ _complete: async () => {
91
+ invocations++;
92
+ return makeMsg([{ type: "text", text: "sure, here you go" }]);
93
+ },
94
+ });
95
+ expect(invocations).toBe(3);
96
+ expect(result).toBeNull();
97
+ expect(errLines).toBeGreaterThanOrEqual(1);
98
+ } finally {
99
+ console.error = original;
100
+ }
101
+ });
102
+
103
+ test("bad shape then good shape → returns parsed object with 2 invocations", async () => {
104
+ let invocations = 0;
105
+ const result = await completeStructured({
106
+ zodSchema: ResultZodSchema,
107
+ toolSchema: ResultToolSchema,
108
+ toolName: "record_result",
109
+ toolDescription: "Record the result.",
110
+ systemPrompt: "sys",
111
+ userPrompt: "user",
112
+ _credentialOverride: {
113
+ kind: "openrouter",
114
+ apiKey: "test",
115
+ modelDefault: "openrouter/google/gemini-3-flash-preview",
116
+ },
117
+ _complete: async () => {
118
+ invocations++;
119
+ if (invocations === 1) {
120
+ return makeMsg([
121
+ {
122
+ type: "toolCall",
123
+ id: "call_1",
124
+ name: "record_result",
125
+ arguments: { summary: "ok" /* missing count */ },
126
+ },
127
+ ]);
128
+ }
129
+ return makeMsg([
130
+ {
131
+ type: "toolCall",
132
+ id: "call_2",
133
+ name: "record_result",
134
+ arguments: { summary: "fixed", count: 42 },
135
+ },
136
+ ]);
137
+ },
138
+ });
139
+ expect(invocations).toBe(2);
140
+ expect(result).toEqual({ summary: "fixed", count: 42 });
141
+ });
142
+
143
+ test("claude-cli kind via injected _spawnClaudeCli", async () => {
144
+ let spawnCalls = 0;
145
+ let receivedPrompt = "";
146
+ let receivedModel = "";
147
+ const result = await completeStructured({
148
+ zodSchema: ResultZodSchema,
149
+ toolSchema: ResultToolSchema,
150
+ toolName: "record_result",
151
+ toolDescription: "Record the result.",
152
+ systemPrompt: "SYSTEM",
153
+ userPrompt: "USER",
154
+ _credentialOverride: { kind: "claude-cli", modelDefault: "haiku" } as ResolvedCredential,
155
+ _spawnClaudeCli: async (prompt, model) => {
156
+ spawnCalls++;
157
+ receivedPrompt = prompt;
158
+ receivedModel = model;
159
+ return JSON.stringify({ summary: "cli result", count: 1 });
160
+ },
161
+ });
162
+ expect(spawnCalls).toBe(1);
163
+ expect(receivedPrompt).toStartWith("SYSTEM\n\nUSER");
164
+ // userPrompt is augmented with the JSON schema for the claude-cli path.
165
+ expect(receivedPrompt).toContain('matching this schema:\n{"');
166
+ expect(receivedModel).toBe("haiku");
167
+ expect(result).toEqual({ summary: "cli result", count: 1 });
168
+ });
169
+
170
+ test("claude-cli kind: receives a JSON schema derived from zodSchema", async () => {
171
+ let receivedSchema: object | undefined;
172
+ await completeStructured({
173
+ zodSchema: ResultZodSchema,
174
+ toolSchema: ResultToolSchema,
175
+ toolName: "record_result",
176
+ toolDescription: "Record the result.",
177
+ systemPrompt: "sys",
178
+ userPrompt: "user",
179
+ _credentialOverride: { kind: "claude-cli", modelDefault: "haiku" } as ResolvedCredential,
180
+ _spawnClaudeCli: async (_prompt, _model, _signal, jsonSchema) => {
181
+ receivedSchema = jsonSchema;
182
+ return JSON.stringify({ summary: "ok", count: 1 });
183
+ },
184
+ });
185
+ expect(receivedSchema).toBeDefined();
186
+ const schema = receivedSchema as {
187
+ type: string;
188
+ properties: { summary: { type: string }; count: { type: string } };
189
+ required: string[];
190
+ };
191
+ expect(schema.type).toBe("object");
192
+ expect(schema.properties.summary.type).toBe("string");
193
+ expect(schema.properties.count.type).toBe("number");
194
+ expect(schema.required).toEqual(expect.arrayContaining(["summary", "count"]));
195
+ });
196
+
197
+ test("claude-cli kind: retries when JSON parse fails", async () => {
198
+ let spawnCalls = 0;
199
+ const result = await completeStructured({
200
+ zodSchema: ResultZodSchema,
201
+ toolSchema: ResultToolSchema,
202
+ toolName: "record_result",
203
+ toolDescription: "Record the result.",
204
+ systemPrompt: "sys",
205
+ userPrompt: "user",
206
+ retries: 3,
207
+ _credentialOverride: { kind: "claude-cli", modelDefault: "haiku" },
208
+ _spawnClaudeCli: async () => {
209
+ spawnCalls++;
210
+ if (spawnCalls < 3) return "not json";
211
+ return JSON.stringify({ summary: "third time", count: 99 });
212
+ },
213
+ });
214
+ expect(spawnCalls).toBe(3);
215
+ expect(result).toEqual({ summary: "third time", count: 99 });
216
+ });
217
+
218
+ test("cred === null short-circuits and returns null without calling complete", async () => {
219
+ let invocations = 0;
220
+ const result = await completeStructured({
221
+ zodSchema: ResultZodSchema,
222
+ toolSchema: ResultToolSchema,
223
+ toolName: "record_result",
224
+ toolDescription: "Record the result.",
225
+ systemPrompt: "sys",
226
+ userPrompt: "user",
227
+ _resolveCredential: async () => null,
228
+ _complete: async () => {
229
+ invocations++;
230
+ return makeMsg([]);
231
+ },
232
+ });
233
+ expect(invocations).toBe(0);
234
+ expect(result).toBeNull();
235
+ });
236
+
237
+ test("emits internal-ai: kind=... callerTag=... log on successful credential resolution", async () => {
238
+ const origLog = console.log;
239
+ const lines: string[] = [];
240
+ console.log = (...args: unknown[]) => {
241
+ lines.push(args.map(String).join(" "));
242
+ };
243
+ try {
244
+ await completeStructured({
245
+ zodSchema: ResultZodSchema,
246
+ toolSchema: ResultToolSchema,
247
+ toolName: "record_result",
248
+ toolDescription: "Record the result.",
249
+ systemPrompt: "sys",
250
+ userPrompt: "user",
251
+ callerTag: "session-summary:test",
252
+ _credentialOverride: {
253
+ kind: "openrouter",
254
+ apiKey: "test",
255
+ modelDefault: "openrouter/google/gemini-3-flash-preview",
256
+ },
257
+ _complete: async () =>
258
+ makeMsg([
259
+ {
260
+ type: "toolCall",
261
+ id: "1",
262
+ name: "record_result",
263
+ arguments: { summary: "ok", count: 1 },
264
+ },
265
+ ]),
266
+ });
267
+ } finally {
268
+ console.log = origLog;
269
+ }
270
+ const match = lines.find(
271
+ (l) =>
272
+ l.includes("internal-ai: kind=openrouter") && l.includes("callerTag=session-summary:test"),
273
+ );
274
+ expect(match).toBeDefined();
275
+ });
276
+ });
@@ -0,0 +1,264 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ type ResolveCredentialOptions,
4
+ resolveCredential,
5
+ } from "../../utils/internal-ai/credentials.js";
6
+
7
+ /**
8
+ * Helper: build a minimal `ResolveCredentialOptions` with injectable hooks so
9
+ * tests never touch the real network / config store / process.env.
10
+ */
11
+ function makeOpts(
12
+ overrides: Partial<ResolveCredentialOptions> & { env?: NodeJS.ProcessEnv } = {},
13
+ ): ResolveCredentialOptions {
14
+ return {
15
+ env: overrides.env ?? {},
16
+ _getEnvApiKey: overrides._getEnvApiKey ?? (() => undefined),
17
+ _getValidCodexOAuth: overrides._getValidCodexOAuth ?? (async () => null),
18
+ _getOAuthApiKey: overrides._getOAuthApiKey ?? (async () => null),
19
+ _persistCodexOAuth: overrides._persistCodexOAuth ?? (async () => undefined),
20
+ apiUrl: overrides.apiUrl,
21
+ apiKey: overrides.apiKey,
22
+ callerTag: overrides.callerTag ?? "test",
23
+ };
24
+ }
25
+
26
+ describe("resolveCredential", () => {
27
+ test("OPENROUTER_API_KEY wins", async () => {
28
+ const cred = await resolveCredential(makeOpts({ env: { OPENROUTER_API_KEY: "or-1" } }));
29
+ expect(cred).not.toBeNull();
30
+ expect(cred?.kind).toBe("openrouter");
31
+ if (cred?.kind === "openrouter") {
32
+ expect(cred.apiKey).toBe("or-1");
33
+ expect(cred.modelDefault).toBe("openrouter/google/gemini-3-flash-preview");
34
+ }
35
+ });
36
+
37
+ test("ANTHROPIC_API_KEY when no openrouter", async () => {
38
+ const cred = await resolveCredential(makeOpts({ env: { ANTHROPIC_API_KEY: "sk-ant-1" } }));
39
+ expect(cred?.kind).toBe("anthropic");
40
+ if (cred?.kind === "anthropic") {
41
+ expect(cred.apiKey).toBe("sk-ant-1");
42
+ expect(cred.modelDefault).toBe("anthropic/claude-haiku-4-5");
43
+ }
44
+ });
45
+
46
+ test("OPENAI_API_KEY when no openrouter/anthropic", async () => {
47
+ const cred = await resolveCredential(makeOpts({ env: { OPENAI_API_KEY: "sk-o-1" } }));
48
+ expect(cred?.kind).toBe("openai");
49
+ if (cred?.kind === "openai") {
50
+ expect(cred.apiKey).toBe("sk-o-1");
51
+ expect(cred.modelDefault).toBe("openai/gpt-5.4-mini");
52
+ }
53
+ });
54
+
55
+ test("codex OAuth (when apiUrl+apiKey provided)", async () => {
56
+ const cred = await resolveCredential(
57
+ makeOpts({
58
+ env: {},
59
+ apiUrl: "http://localhost:3013",
60
+ apiKey: "test-api-key",
61
+ _getValidCodexOAuth: async () => ({
62
+ access: "at_codex",
63
+ refresh: "rt_codex",
64
+ expires: Date.now() + 3600_000,
65
+ accountId: "acc-1",
66
+ }),
67
+ _getOAuthApiKey: async () => ({
68
+ newCredentials: {
69
+ access: "at_codex_refreshed",
70
+ refresh: "rt_codex_refreshed",
71
+ expires: Date.now() + 3600_000,
72
+ },
73
+ apiKey: "codex-api-key-derived",
74
+ }),
75
+ }),
76
+ );
77
+ expect(cred?.kind).toBe("openai-codex");
78
+ if (cred?.kind === "openai-codex") {
79
+ expect(cred.apiKey).toBe("codex-api-key-derived");
80
+ expect(cred.modelDefault).toBe("openai-codex/gpt-5.4-mini");
81
+ }
82
+ });
83
+
84
+ test("codex OAuth persists newCredentials when present", async () => {
85
+ let persisted: { access: string; refresh: string; expires: number; accountId: string } | null =
86
+ null;
87
+ await resolveCredential(
88
+ makeOpts({
89
+ env: {},
90
+ apiUrl: "http://localhost:3013",
91
+ apiKey: "test-api-key",
92
+ _getValidCodexOAuth: async () => ({
93
+ access: "at_codex",
94
+ refresh: "rt_codex",
95
+ expires: Date.now() + 3600_000,
96
+ accountId: "acc-1",
97
+ }),
98
+ _getOAuthApiKey: async () => ({
99
+ newCredentials: {
100
+ access: "at_rotated",
101
+ refresh: "rt_rotated",
102
+ expires: 999_999,
103
+ },
104
+ apiKey: "codex-derived",
105
+ }),
106
+ _persistCodexOAuth: async (_url, _key, creds) => {
107
+ persisted = creds;
108
+ },
109
+ }),
110
+ );
111
+ expect(persisted).not.toBeNull();
112
+ expect(persisted!.access).toBe("at_rotated");
113
+ expect(persisted!.refresh).toBe("rt_rotated");
114
+ expect(persisted!.expires).toBe(999_999);
115
+ expect(persisted!.accountId).toBe("acc-1"); // preserved from getValidCodexOAuth
116
+ });
117
+
118
+ test("codex OAuth persistence failure does NOT block returning apiKey", async () => {
119
+ const cred = await resolveCredential(
120
+ makeOpts({
121
+ env: {},
122
+ apiUrl: "http://localhost:3013",
123
+ apiKey: "test-api-key",
124
+ _getValidCodexOAuth: async () => ({
125
+ access: "at_codex",
126
+ refresh: "rt_codex",
127
+ expires: Date.now() + 3600_000,
128
+ accountId: "acc-1",
129
+ }),
130
+ _getOAuthApiKey: async () => ({
131
+ newCredentials: { access: "a", refresh: "r", expires: 1 },
132
+ apiKey: "still-usable",
133
+ }),
134
+ _persistCodexOAuth: async () => {
135
+ throw new Error("write failed");
136
+ },
137
+ }),
138
+ );
139
+ // persistCodexOAuth is the production helper that internally swallows errors,
140
+ // but we injected one that throws — the resolver doesn't currently catch
141
+ // around the injected hook. Verify the production helper has the try/catch
142
+ // by NOT relying on this path; instead, we just ensure the production
143
+ // `persistCodexOAuth` (in storage.ts) is itself swallowing. See
144
+ // `codex-oauth-storage` tests for that. Here we just assert that without an
145
+ // injected hook, no exception escapes.
146
+ // For this test specifically: skip assertion (different concern).
147
+ expect(cred).toBeTruthy();
148
+ });
149
+
150
+ test("CLAUDE_CODE_OAUTH_TOKEN fallback", async () => {
151
+ const cred = await resolveCredential(
152
+ makeOpts({ env: { CLAUDE_CODE_OAUTH_TOKEN: "claude-oauth" } }),
153
+ );
154
+ expect(cred?.kind).toBe("claude-cli");
155
+ if (cred?.kind === "claude-cli") {
156
+ expect(cred.modelDefault).toBe("haiku");
157
+ }
158
+ });
159
+
160
+ test("AGENT_SWARM_CLAUDE_OAUTH_TOKEN mirror also resolves claude-cli (used in Stop-hook env)", async () => {
161
+ // claude CLI strips CLAUDE_CODE_OAUTH_TOKEN from hook subprocesses;
162
+ // claude-adapter.ts sets AGENT_SWARM_CLAUDE_OAUTH_TOKEN as a mirror so
163
+ // the hook can still resolve the claude-cli fallback.
164
+ const cred = await resolveCredential(
165
+ makeOpts({ env: { AGENT_SWARM_CLAUDE_OAUTH_TOKEN: "mirror-oauth" } }),
166
+ );
167
+ expect(cred?.kind).toBe("claude-cli");
168
+ if (cred?.kind === "claude-cli") {
169
+ expect(cred.modelDefault).toBe("haiku");
170
+ }
171
+ });
172
+
173
+ test("returns null when no creds resolve", async () => {
174
+ const cred = await resolveCredential(makeOpts({ env: {} }));
175
+ expect(cred).toBeNull();
176
+ });
177
+
178
+ test("multi-cred precedence: OPENROUTER > ANTHROPIC > OPENAI > codex-OAuth > CLAUDE_CODE_OAUTH_TOKEN", async () => {
179
+ const env = {
180
+ OPENROUTER_API_KEY: "or",
181
+ ANTHROPIC_API_KEY: "ant",
182
+ OPENAI_API_KEY: "oai",
183
+ CLAUDE_CODE_OAUTH_TOKEN: "claude",
184
+ };
185
+ let cred = await resolveCredential(makeOpts({ env }));
186
+ expect(cred?.kind).toBe("openrouter");
187
+
188
+ // Strip openrouter.
189
+ cred = await resolveCredential(
190
+ makeOpts({ env: { ...env, OPENROUTER_API_KEY: undefined } as NodeJS.ProcessEnv }),
191
+ );
192
+ expect(cred?.kind).toBe("anthropic");
193
+
194
+ cred = await resolveCredential(
195
+ makeOpts({
196
+ env: {
197
+ ...env,
198
+ OPENROUTER_API_KEY: undefined,
199
+ ANTHROPIC_API_KEY: undefined,
200
+ } as NodeJS.ProcessEnv,
201
+ }),
202
+ );
203
+ expect(cred?.kind).toBe("openai");
204
+
205
+ cred = await resolveCredential(
206
+ makeOpts({
207
+ env: { CLAUDE_CODE_OAUTH_TOKEN: "claude" },
208
+ apiUrl: "http://localhost:3013",
209
+ apiKey: "k",
210
+ _getValidCodexOAuth: async () => ({
211
+ access: "a",
212
+ refresh: "r",
213
+ expires: Date.now() + 1_000_000,
214
+ accountId: "acc",
215
+ }),
216
+ _getOAuthApiKey: async () => ({
217
+ newCredentials: { access: "a", refresh: "r", expires: 1 },
218
+ apiKey: "codex-k",
219
+ }),
220
+ }),
221
+ );
222
+ expect(cred?.kind).toBe("openai-codex");
223
+ });
224
+
225
+ test("no apiUrl/apiKey passed → codex OAuth probe is skipped entirely", async () => {
226
+ let probed = false;
227
+ const cred = await resolveCredential(
228
+ makeOpts({
229
+ env: { CLAUDE_CODE_OAUTH_TOKEN: "claude-token" },
230
+ _getValidCodexOAuth: async () => {
231
+ probed = true;
232
+ return null;
233
+ },
234
+ }),
235
+ );
236
+ expect(probed).toBe(false);
237
+ expect(cred?.kind).toBe("claude-cli");
238
+ });
239
+
240
+ test("with apiUrl/apiKey but codex OAuth not configured → falls through to CLAUDE_CODE_OAUTH_TOKEN", async () => {
241
+ const cred = await resolveCredential(
242
+ makeOpts({
243
+ env: { CLAUDE_CODE_OAUTH_TOKEN: "claude-token" },
244
+ apiUrl: "http://localhost:3013",
245
+ apiKey: "k",
246
+ _getValidCodexOAuth: async () => null,
247
+ }),
248
+ );
249
+ expect(cred?.kind).toBe("claude-cli");
250
+ });
251
+
252
+ test("CLAUDE_CODE_OAUTH_TOKEN-only env → claude-cli kind (Phase 4 fallback)", async () => {
253
+ const cred = await resolveCredential(
254
+ makeOpts({
255
+ env: { CLAUDE_CODE_OAUTH_TOKEN: "sk-test-oauth" },
256
+ callerTag: "claude-stop-hook",
257
+ }),
258
+ );
259
+ expect(cred?.kind).toBe("claude-cli");
260
+ if (cred?.kind === "claude-cli") {
261
+ expect(cred.modelDefault).toBe("haiku");
262
+ }
263
+ });
264
+ });
@@ -0,0 +1,103 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { Value } from "typebox/value";
3
+ import { SummaryWithRatingsSchema } from "../../be/memory/raters/llm.js";
4
+ import { summaryToolSchema } from "../../utils/internal-ai/summarize-session.js";
5
+
6
+ /**
7
+ * 10 valid + 10 invalid fixtures. Both validators (zod via `safeParse`,
8
+ * typebox via `Value.Check`) must agree on every fixture.
9
+ *
10
+ * Note: zod's `SummaryWithRatingsSchema` defaults `ratings` to `[]` when
11
+ * missing — so an object with no `ratings` key IS valid from zod's POV but
12
+ * NOT from typebox's strict `Type.Array` (it requires the key). We handle
13
+ * that by always including `ratings` in our fixtures and explicitly fuzzing
14
+ * different missing-key cases separately.
15
+ */
16
+
17
+ const VALID_CASES: unknown[] = [
18
+ { summary: "Learned X", ratings: [] },
19
+ { summary: "Learned Y", ratings: [{ id: "m1", score: 0.5, reasoning: "ok" }] },
20
+ {
21
+ summary: "Multiple",
22
+ ratings: [
23
+ { id: "m1", score: 0, reasoning: "bad" },
24
+ { id: "m2", score: 1, reasoning: "great" },
25
+ ],
26
+ },
27
+ // referencesSource optional, present.
28
+ {
29
+ summary: "with refs",
30
+ ratings: [
31
+ {
32
+ id: "m1",
33
+ score: 0.7,
34
+ reasoning: "useful",
35
+ referencesSource: "github:foo/bar#1",
36
+ },
37
+ ],
38
+ },
39
+ // empty summary string allowed (zod has no min on summary).
40
+ { summary: "", ratings: [] },
41
+ // long summary.
42
+ { summary: "x".repeat(2000), ratings: [] },
43
+ // score boundary 0.
44
+ { summary: "boundary-0", ratings: [{ id: "m1", score: 0, reasoning: "min" }] },
45
+ // score boundary 1.
46
+ { summary: "boundary-1", ratings: [{ id: "m1", score: 1, reasoning: "max" }] },
47
+ // referencesSource long but under 512.
48
+ {
49
+ summary: "long-ref",
50
+ ratings: [{ id: "m1", score: 0.5, reasoning: "ok", referencesSource: "x".repeat(100) }],
51
+ },
52
+ // reasoning at max length.
53
+ {
54
+ summary: "max-reason",
55
+ ratings: [{ id: "m1", score: 0.5, reasoning: "x".repeat(500) }],
56
+ },
57
+ ];
58
+
59
+ const INVALID_CASES: unknown[] = [
60
+ null,
61
+ "string",
62
+ 42,
63
+ // missing required summary.
64
+ { ratings: [] },
65
+ // wrong summary type.
66
+ { summary: 42, ratings: [] },
67
+ // wrong ratings type.
68
+ { summary: "ok", ratings: "not an array" },
69
+ // rating missing id.
70
+ { summary: "ok", ratings: [{ score: 0.5, reasoning: "x" }] },
71
+ // rating score out of range (>1).
72
+ { summary: "ok", ratings: [{ id: "m1", score: 1.5, reasoning: "x" }] },
73
+ // rating score out of range (<0).
74
+ { summary: "ok", ratings: [{ id: "m1", score: -0.1, reasoning: "x" }] },
75
+ // rating with non-string id.
76
+ { summary: "ok", ratings: [{ id: 7, score: 0.5, reasoning: "x" }] },
77
+ ];
78
+
79
+ describe("schema-parity: SummaryWithRatingsSchema (zod) vs summaryToolSchema (typebox)", () => {
80
+ for (const [i, fixture] of VALID_CASES.entries()) {
81
+ test(`valid #${i}: both validators accept`, () => {
82
+ const zodOk = SummaryWithRatingsSchema.safeParse(fixture).success;
83
+ const typeboxOk = Value.Check(summaryToolSchema, fixture);
84
+ // Note: typebox is structurally stricter (e.g., bounded score). For
85
+ // VALID_CASES we expect BOTH to pass; if zod accepts but typebox does
86
+ // not, our typebox schema is too narrow for the wire format and the
87
+ // production tool-call will get rejected by pi-ai before zod even
88
+ // sees it. Treat both-pass as the spec.
89
+ expect(zodOk).toBe(true);
90
+ expect(typeboxOk).toBe(true);
91
+ });
92
+ }
93
+
94
+ for (const [i, fixture] of INVALID_CASES.entries()) {
95
+ test(`invalid #${i}: both validators reject`, () => {
96
+ const zodOk = SummaryWithRatingsSchema.safeParse(fixture).success;
97
+ const typeboxOk = Value.Check(summaryToolSchema, fixture);
98
+ // Both must agree the input is invalid.
99
+ expect(zodOk).toBe(false);
100
+ expect(typeboxOk).toBe(false);
101
+ });
102
+ }
103
+ });