pi-system-one 1.3.1 → 1.3.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,345 @@
1
+ import assert from "node:assert/strict";
2
+ import { it } from "node:test";
3
+ import { visibleWidth } from "@earendil-works/pi-tui";
4
+ import { createSessionConfig } from "../src/config.ts";
5
+ import { DebugHistory, observedProvider } from "../src/debug.ts";
6
+ import { DebugOverlay, debugSummary } from "../src/debug-ui.ts";
7
+
8
+ it("shows page position, distinct question counts, target, and readable controls", () => {
9
+ const history = new DebugHistory();
10
+ for (let i = 0; i < 17; i++) {
11
+ const entry = history.start("custom", {
12
+ q: { type: "score", instructions: "hidden", criteria: ["low", "high"] },
13
+ });
14
+ entry.provider = "configured";
15
+ entry.outcome = "failure";
16
+ entry.httpStatus = 405;
17
+ entry.failure = "HTTP error";
18
+ entry.requestTarget = "POST http://localhost:8008/v1/systemone";
19
+ }
20
+ const colors: string[] = [];
21
+ const overlay = new DebugOverlay(
22
+ history.entries,
23
+ {
24
+ fg: (color: string, text: string) => {
25
+ colors.push(`${color}:${text}`);
26
+ return text;
27
+ },
28
+ } as never,
29
+ () => {},
30
+ () => {},
31
+ );
32
+ const initial = overlay.render(68).join("\n");
33
+ assert.match(initial, /1-7 of 17/);
34
+ assert.match(initial, /#1/);
35
+ assert.match(initial, /1 score/);
36
+ assert.match(initial, /POST http:\/\/localhost:8008\/v1\/systemone/);
37
+ assert.ok(
38
+ colors.some(
39
+ (line) => line.startsWith("text:") && line.includes("recent call"),
40
+ ),
41
+ );
42
+ assert.ok(
43
+ colors.some((line) => line.startsWith("text:") && line.includes("browse")),
44
+ );
45
+ for (let i = 0; i < 7; i++) overlay.handleInput("\u001b[B");
46
+ assert.match(overlay.render(68).join("\n"), /8-14 of 17/);
47
+ });
48
+
49
+ it("renders bounded debug overlay and lets keyboard move focus and close", () => {
50
+ const history = new DebugHistory();
51
+ const entry = history.start("auto", {
52
+ q: { type: "noul", instructions: "secret instruction" },
53
+ });
54
+ entry.provider = "pi-native";
55
+ entry.reason = "compatible request";
56
+ entry.outcome = "success";
57
+ entry.durationMs = 7;
58
+ history.start("custom", {
59
+ q: {
60
+ type: "score",
61
+ instructions: "private rubric",
62
+ criteria: ["low", "high"],
63
+ },
64
+ });
65
+ let renders = 0;
66
+ let closed = false;
67
+ const overlay = new DebugOverlay(
68
+ history.entries,
69
+ { fg: (_color: string, text: string) => text } as never,
70
+ () => {
71
+ closed = true;
72
+ },
73
+ () => {
74
+ renders++;
75
+ },
76
+ );
77
+ const first = overlay.render(44);
78
+ assert.ok(first.every((line) => visibleWidth(line) <= 44));
79
+ assert.doesNotMatch(first.join("\n"), /secret instruction|private rubric/);
80
+ assert.match(first.join("\n"), /Provider: not selected/);
81
+ overlay.handleInput("\u001b[B");
82
+ assert.ok(renders > 0);
83
+ assert.match(overlay.render(44).join("\n"), /Provider: pi-native/);
84
+ overlay.handleInput("\u001b");
85
+ assert.ok(closed);
86
+ assert.match(debugSummary([]), /No system_one calls yet/);
87
+ history.clear();
88
+ assert.equal(history.entries.length, 0);
89
+ });
90
+
91
+ it("shows POST target shape for custom 405 without retaining URL secrets", async () => {
92
+ const originalFetch = globalThis.fetch;
93
+ globalThis.fetch = (async () =>
94
+ new Response("not allowed", { status: 405 })) as typeof fetch;
95
+ try {
96
+ const history = new DebugHistory();
97
+ const request = {
98
+ state: "private",
99
+ questions: { q: { type: "noul" as const, instructions: "Yes?" } },
100
+ };
101
+ const duplicate = observedProvider(
102
+ createSessionConfig({
103
+ SYSTEM_ONE_BASE_URL: "http://localhost:8008/v1/systemone",
104
+ }),
105
+ {} as never,
106
+ history,
107
+ );
108
+ await assert.rejects(duplicate.evaluate(request), /provider error 405/);
109
+ assert.match(
110
+ history.entries[0]?.requestTarget ?? "",
111
+ /POST http:\/\/localhost:8008\/v1\/systemone\/v1\/systemone/,
112
+ );
113
+ const secret = observedProvider(
114
+ createSessionConfig({
115
+ SYSTEM_ONE_BASE_URL:
116
+ "https://user:password@private.example/api/token123?key=secret#fragment",
117
+ }),
118
+ {} as never,
119
+ history,
120
+ );
121
+ await assert.rejects(secret.evaluate(request), /provider error 405/);
122
+ const record = JSON.stringify(history.entries);
123
+ assert.match(
124
+ history.entries[0]?.requestTarget ?? "",
125
+ /POST https:\/\/private.example\/api\/\[redacted\]/,
126
+ );
127
+ assert.match(history.entries[0]?.requestTarget ?? "", /query\/fragment/);
128
+ assert.doesNotMatch(
129
+ record,
130
+ /user:password|token123|key=secret|#fragment|private"/,
131
+ );
132
+ } finally {
133
+ globalThis.fetch = originalFetch;
134
+ }
135
+ });
136
+
137
+ it("records credential failure as selected but not dispatched, without error text", async () => {
138
+ const history = new DebugHistory();
139
+ const provider = observedProvider(
140
+ createSessionConfig({}, { mode: "typesafe" }),
141
+ {} as never,
142
+ history,
143
+ );
144
+ await assert.rejects(
145
+ provider.evaluate({
146
+ state: "secret-state",
147
+ questions: { q: { type: "noul", instructions: "secret-question" } },
148
+ }),
149
+ /TypeSafe key missing/,
150
+ );
151
+ assert.equal(history.entries[0]?.provider, "typesafe");
152
+ assert.equal(history.entries[0]?.outcome, "failure");
153
+ assert.equal(history.entries[0]?.attempted, false);
154
+ assert.doesNotMatch(
155
+ JSON.stringify(history.entries),
156
+ /secret-state|secret-question|TypeSafe key missing/,
157
+ );
158
+ });
159
+
160
+ it("reports native Score rejection as not dispatched and gives a fixed reason", async () => {
161
+ const history = new DebugHistory();
162
+ const provider = observedProvider(
163
+ createSessionConfig({}, { mode: "native" }),
164
+ {
165
+ modelRegistry: {
166
+ findOfType: () => ({ id: "jev-latest" }),
167
+ classify: () => {
168
+ throw Error("must not classify unsupported Score");
169
+ },
170
+ },
171
+ } as never,
172
+ history,
173
+ );
174
+ await assert.rejects(
175
+ provider.evaluate({
176
+ state: "x",
177
+ questions: {
178
+ q: { type: "score", instructions: "Rate", criteria: ["low", "high"] },
179
+ },
180
+ }),
181
+ /omits Score probabilities/,
182
+ );
183
+ assert.equal(history.entries[0]?.failure, "native Score unsupported");
184
+ assert.equal(history.entries[0]?.attempted, false);
185
+ });
186
+
187
+ it("distinguishes native provider error from malformed success without logging content", async () => {
188
+ const cases = [
189
+ {
190
+ result: {
191
+ stopReason: "error",
192
+ errorMessage: "Bearer secret-provider-body",
193
+ },
194
+ category: "native classifier returned error",
195
+ },
196
+ {
197
+ result: {
198
+ stopReason: "stop",
199
+ answers: { q: { type: "bool", probability: 2 } },
200
+ },
201
+ category: "invalid native response",
202
+ },
203
+ ];
204
+ for (const { result, category } of cases) {
205
+ const history = new DebugHistory();
206
+ const provider = observedProvider(
207
+ createSessionConfig({}, { mode: "native" }),
208
+ {
209
+ modelRegistry: {
210
+ findOfType: () => ({ id: "jev-latest" }),
211
+ classify: async () => result,
212
+ },
213
+ } as never,
214
+ history,
215
+ );
216
+ await assert.rejects(
217
+ provider.evaluate({
218
+ state: "private state",
219
+ questions: { q: { type: "noul", instructions: "Yes?" } },
220
+ }),
221
+ /Pi native classifier failed/,
222
+ );
223
+ assert.equal(history.entries[0]?.failure, category);
224
+ assert.equal(history.entries[0]?.attempted, true);
225
+ assert.doesNotMatch(
226
+ JSON.stringify(history.entries),
227
+ /secret-provider-body|private state/,
228
+ );
229
+ }
230
+ });
231
+
232
+ it("records failed native attempt without retaining provider-controlled error text", async () => {
233
+ const history = new DebugHistory();
234
+ const provider = observedProvider(
235
+ createSessionConfig({}, { mode: "auto" }),
236
+ {
237
+ modelRegistry: {
238
+ findOfType: () => ({ id: "jev-latest" }),
239
+ classify: async () => {
240
+ throw Error("private response body");
241
+ },
242
+ },
243
+ } as never,
244
+ history,
245
+ );
246
+ await assert.rejects(
247
+ provider.evaluate({
248
+ state: "x",
249
+ questions: { q: { type: "noul", instructions: "Yes?" } },
250
+ }),
251
+ /Pi native classifier failed/,
252
+ );
253
+ assert.equal(history.entries[0]?.provider, "pi-native");
254
+ assert.equal(history.entries[0]?.outcome, "failure");
255
+ assert.equal(history.entries[0]?.attempted, true);
256
+ assert.equal(history.entries[0]?.failure, "native classify threw");
257
+ assert.doesNotMatch(JSON.stringify(history.entries), /private response body/);
258
+ });
259
+
260
+ it("records HTTP fallback for auto Score and keeps only bounded history", async () => {
261
+ const originalFetch = globalThis.fetch;
262
+ const history = new DebugHistory();
263
+ globalThis.fetch = (async () =>
264
+ new Response(
265
+ JSON.stringify({
266
+ answers: {
267
+ q: {
268
+ type: "score",
269
+ score: 0,
270
+ confidence: 1,
271
+ probabilities: { "0": 1, "1": 0 },
272
+ legend: { "0": "low", "1": "high" },
273
+ },
274
+ },
275
+ }),
276
+ { status: 200 },
277
+ )) as typeof fetch;
278
+ try {
279
+ const provider = observedProvider(
280
+ createSessionConfig({}, { mode: "auto" }),
281
+ {
282
+ modelRegistry: {
283
+ findOfType: () => ({ id: "jev-latest" }),
284
+ classify: () => {
285
+ throw Error("must not classify Score");
286
+ },
287
+ getApiKeyForProvider: async () => "secret-pi-key",
288
+ },
289
+ } as never,
290
+ history,
291
+ );
292
+ const request = {
293
+ state: "private-state",
294
+ questions: {
295
+ q: {
296
+ type: "score" as const,
297
+ instructions: "Score private item",
298
+ criteria: ["low", "high"],
299
+ },
300
+ },
301
+ };
302
+ for (let i = 0; i < 21; i++) await provider.evaluate(request);
303
+ assert.equal(history.entries.length, 20);
304
+ assert.equal(history.entries[0]?.provider, "typesafe");
305
+ assert.equal(history.entries[0]?.reason, "contains Score");
306
+ assert.equal(history.entries[0]?.outcome, "success");
307
+ assert.doesNotMatch(
308
+ JSON.stringify(history.entries),
309
+ /private-state|private item|secret-pi-key/,
310
+ );
311
+ } finally {
312
+ globalThis.fetch = originalFetch;
313
+ }
314
+ });
315
+
316
+ it("records actual auto route without retaining request state or credentials", async () => {
317
+ const history = new DebugHistory();
318
+ const ctx = {
319
+ modelRegistry: {
320
+ findOfType: () => ({ id: "jev-latest" }),
321
+ classify: async () => ({
322
+ stopReason: "stop",
323
+ answers: { q: { type: "bool", probability: 0.8 } },
324
+ }),
325
+ },
326
+ } as never;
327
+ const provider = observedProvider(
328
+ createSessionConfig(
329
+ { SYSTEM_ONE_API_KEY: "private-api-key" },
330
+ { mode: "auto" },
331
+ ),
332
+ ctx,
333
+ history,
334
+ );
335
+ await provider.evaluate({
336
+ state: "private-user-state",
337
+ questions: { q: { type: "noul", instructions: "private-instruction" } },
338
+ });
339
+ assert.equal(history.entries.length, 1);
340
+ assert.match(JSON.stringify(history.entries), /pi-native|native/);
341
+ assert.doesNotMatch(
342
+ JSON.stringify(history.entries),
343
+ /private-user-state|private-instruction|private-api-key/,
344
+ );
345
+ });
@@ -27,25 +27,29 @@ describe("extension resources", () => {
27
27
  join(dir, "pi-system-one.json"),
28
28
  JSON.stringify({ "Bearer secret-token": "ignored" }),
29
29
  );
30
- const handlers: Record<string, (event: never, ctx: never) => void> = {};
30
+ const handlers: Record<string, ((event: never, ctx: never) => void)[]> =
31
+ {};
31
32
  piSystemOneExtension({
32
33
  registerTool: () => {},
33
34
  registerCommand: () => {},
34
35
  on: (name: string, handler: (event: never, ctx: never) => void) => {
35
- handlers[name] = handler;
36
+ handlers[name] ??= [];
37
+ handlers[name].push(handler);
36
38
  },
37
39
  } as never);
38
40
  let warning = "";
39
- handlers.session_start(
40
- { reason: "startup" } as never,
41
- {
42
- ui: {
43
- notify: (message: string) => {
44
- warning = message;
41
+ for (const handler of handlers.session_start) {
42
+ handler(
43
+ { reason: "startup" } as never,
44
+ {
45
+ ui: {
46
+ notify: (message: string) => {
47
+ warning = message;
48
+ },
45
49
  },
46
- },
47
- } as never,
48
- );
50
+ } as never,
51
+ );
52
+ }
49
53
  assert.match(warning, /Ignoring saved settings/);
50
54
  assert.doesNotMatch(warning, /Bearer|secret-token/);
51
55
  } finally {
@@ -116,6 +120,70 @@ describe("extension resources", () => {
116
120
  rmSync(dir, { recursive: true, force: true });
117
121
  }
118
122
  });
123
+ it("loads saved native mode instead of an inherited TypeSafe endpoint on restart", async () => {
124
+ const dir = mkdtempSync(join(tmpdir(), "so-saved-native-"));
125
+ const oldDir = process.env.PI_CODING_AGENT_DIR;
126
+ const oldUrl = process.env.SYSTEM_ONE_BASE_URL;
127
+ const originalFetch = globalThis.fetch;
128
+ try {
129
+ process.env.PI_CODING_AGENT_DIR = dir;
130
+ process.env.SYSTEM_ONE_BASE_URL = "https://api.typesafe.ai";
131
+ writeFileSync(join(dir, "pi-system-one.json"), '{"mode":"native"}');
132
+ let tool: ReturnType<typeof buildSystemOneTool> | undefined;
133
+ let command: ((args: string, ctx: never) => Promise<void>) | undefined;
134
+ piSystemOneExtension({
135
+ registerTool: (value: ReturnType<typeof buildSystemOneTool>) => {
136
+ tool = value;
137
+ },
138
+ registerCommand: (
139
+ _name: string,
140
+ options: { handler: typeof command },
141
+ ) => {
142
+ command = options.handler;
143
+ },
144
+ on: () => {},
145
+ } as never);
146
+ let status = "";
147
+ await command?.("status", {
148
+ ui: {
149
+ notify: (message: string) => {
150
+ status = message;
151
+ },
152
+ },
153
+ } as never);
154
+ assert.match(status, /mode: native \(saved\)/);
155
+ globalThis.fetch = (async () => {
156
+ throw Error("saved native mode must not make HTTP calls");
157
+ }) as typeof fetch;
158
+ assert.ok(tool);
159
+ const result = await tool.execute(
160
+ "saved-native",
161
+ {
162
+ state: "x",
163
+ questions: { q: { type: "noul", instructions: "Yes?" } },
164
+ },
165
+ undefined,
166
+ undefined,
167
+ {
168
+ modelRegistry: {
169
+ findOfType: () => ({ id: "jev-latest" }),
170
+ classify: async () => ({
171
+ stopReason: "stop",
172
+ answers: { q: { type: "bool", probability: 0.8 } },
173
+ }),
174
+ },
175
+ } as never,
176
+ );
177
+ assert.deepEqual(result.details.answers.q, { type: "noul", noul: 0.8 });
178
+ } finally {
179
+ globalThis.fetch = originalFetch;
180
+ if (oldDir === undefined) delete process.env.PI_CODING_AGENT_DIR;
181
+ else process.env.PI_CODING_AGENT_DIR = oldDir;
182
+ if (oldUrl === undefined) delete process.env.SYSTEM_ONE_BASE_URL;
183
+ else process.env.SYSTEM_ONE_BASE_URL = oldUrl;
184
+ rmSync(dir, { recursive: true, force: true });
185
+ }
186
+ });
119
187
  it("reloads saved custom config across extension instances", async () => {
120
188
  const dir = mkdtempSync(join(tmpdir(), "so-extension-"));
121
189
  const prev = {
@@ -27,6 +27,21 @@ it("round-trips persistent settings without writing a key", () => {
27
27
  }
28
28
  });
29
29
 
30
+ it("persists auto routing policy without a detected provider or credentials", () => {
31
+ const dir = mkdtempSync(join(tmpdir(), "so-auto-"));
32
+ try {
33
+ const path = join(dir, "pi-system-one.json");
34
+ saveStoredConfig(path, { mode: "auto", timeoutMs: 5000 });
35
+ assert.deepEqual(loadStoredConfig(path), { mode: "auto", timeoutMs: 5000 });
36
+ assert.doesNotMatch(
37
+ readFileSync(path, "utf8"),
38
+ /baseUrl|apiKey|jev-latest/,
39
+ );
40
+ } finally {
41
+ rmSync(dir, { recursive: true, force: true });
42
+ }
43
+ });
44
+
30
45
  it("rejects credential-bearing endpoint URLs without persisting them", () => {
31
46
  const dir = mkdtempSync(join(tmpdir(), "so-config-"));
32
47
  try {
@@ -4,6 +4,215 @@ import { createSessionConfig } from "../src/config.ts";
4
4
  import { resolveSessionProvider } from "../src/provider.ts";
5
5
  import { buildSystemOneTool } from "../src/tool.ts";
6
6
 
7
+ it("auto sends compatible Noul to native and Score to canonical TypeSafe HTTP", async () => {
8
+ const originalFetch = globalThis.fetch;
9
+ const urls: string[] = [];
10
+ let nativeCalls = 0;
11
+ const session = createSessionConfig(
12
+ {
13
+ SYSTEM_ONE_BASE_URL: "https://custom.example",
14
+ SYSTEM_ONE_API_KEY: "custom-secret",
15
+ },
16
+ { mode: "auto" as never },
17
+ );
18
+ const ctx = {
19
+ modelRegistry: {
20
+ findOfType: () => ({ id: "jev-latest" }),
21
+ classify: async () => {
22
+ nativeCalls++;
23
+ return {
24
+ stopReason: "stop",
25
+ model: "jev-latest",
26
+ answers: { q: { type: "bool", probability: 0.8 } },
27
+ };
28
+ },
29
+ getApiKeyForProvider: async () => "pi-key",
30
+ },
31
+ } as never;
32
+ globalThis.fetch = (async (url, options) => {
33
+ urls.push(String(url));
34
+ assert.equal(
35
+ new Headers(options?.headers).get("authorization"),
36
+ "Bearer pi-key",
37
+ );
38
+ return new Response(
39
+ JSON.stringify({
40
+ model: "jev-latest",
41
+ answers: {
42
+ score: {
43
+ type: "score",
44
+ score: 0,
45
+ confidence: 1,
46
+ probabilities: { "0": 1, "1": 0 },
47
+ legend: { "0": "low", "1": "high" },
48
+ },
49
+ },
50
+ }),
51
+ { status: 200 },
52
+ );
53
+ }) as typeof fetch;
54
+ try {
55
+ const provider = await resolveSessionProvider(session, ctx);
56
+ const native = await provider.evaluate({
57
+ state: "x",
58
+ questions: { q: { type: "noul", instructions: "Is it?" } },
59
+ });
60
+ assert.deepEqual(native.answers.q, { type: "noul", noul: 0.8 });
61
+ const score = await provider.evaluate({
62
+ state: "x",
63
+ questions: {
64
+ score: {
65
+ type: "score",
66
+ instructions: "Rate",
67
+ criteria: ["low", "high"],
68
+ },
69
+ },
70
+ });
71
+ assert.equal(score.answers.score.type, "score");
72
+ assert.equal(nativeCalls, 1);
73
+ assert.deepEqual(urls, ["https://api.typesafe.ai/v1/systemone"]);
74
+ } finally {
75
+ globalThis.fetch = originalFetch;
76
+ }
77
+ });
78
+
79
+ it("auto routes mixed and structured requests as one TypeSafe HTTP call", async () => {
80
+ const originalFetch = globalThis.fetch;
81
+ let calls = 0;
82
+ const session = createSessionConfig({}, { mode: "auto" });
83
+ const ctx = {
84
+ modelRegistry: {
85
+ findOfType: () => ({ id: "jev-latest" }),
86
+ classify: () => {
87
+ throw new Error("native must not be called");
88
+ },
89
+ getApiKeyForProvider: async () => "pi-key",
90
+ },
91
+ } as never;
92
+ globalThis.fetch = (async (url, options) => {
93
+ calls++;
94
+ assert.equal(String(url), "https://api.typesafe.ai/v1/systemone");
95
+ const request = JSON.parse(String(options?.body));
96
+ assert.ok(request.questions.noul && request.questions.score);
97
+ return new Response(
98
+ JSON.stringify({
99
+ answers: {
100
+ noul: { type: "noul", noul: 0.8 },
101
+ score: {
102
+ type: "score",
103
+ score: 0,
104
+ confidence: 1,
105
+ probabilities: { "0": 1, "1": 0 },
106
+ legend: { "0": "low", "1": "high" },
107
+ },
108
+ },
109
+ }),
110
+ { status: 200 },
111
+ );
112
+ }) as typeof fetch;
113
+ try {
114
+ const provider = await resolveSessionProvider(session, ctx);
115
+ await provider.evaluate({
116
+ state: "x",
117
+ questions: {
118
+ noul: { type: "noul", instructions: "Is it?" },
119
+ score: {
120
+ type: "score",
121
+ instructions: "Rate",
122
+ criteria: ["low", "high"],
123
+ },
124
+ },
125
+ });
126
+ assert.equal(calls, 1);
127
+ } finally {
128
+ globalThis.fetch = originalFetch;
129
+ }
130
+ });
131
+
132
+ it("auto uses TypeSafe HTTP when classifier is missing or input is incompatible", async () => {
133
+ const originalFetch = globalThis.fetch;
134
+ let calls = 0;
135
+ let modelAvailable = false;
136
+ const session = createSessionConfig({}, { mode: "auto" });
137
+ const ctx = {
138
+ modelRegistry: {
139
+ findOfType: () => (modelAvailable ? { id: "jev-latest" } : undefined),
140
+ classify: () => {
141
+ throw new Error("native must not be called");
142
+ },
143
+ getApiKeyForProvider: async () => "pi-key",
144
+ },
145
+ } as never;
146
+ globalThis.fetch = (async (url) => {
147
+ calls++;
148
+ assert.equal(String(url), "https://api.typesafe.ai/v1/systemone");
149
+ return new Response(
150
+ JSON.stringify({ answers: { q: { type: "noul", noul: 0.7 } } }),
151
+ { status: 200 },
152
+ );
153
+ }) as typeof fetch;
154
+ try {
155
+ const provider = await resolveSessionProvider(session, ctx);
156
+ await provider.evaluate({
157
+ state: "x",
158
+ questions: { q: { type: "noul", instructions: "Yes?" } },
159
+ });
160
+ modelAvailable = true;
161
+ await provider.evaluate({
162
+ state: "x",
163
+ questions: {
164
+ q: { type: "noul", instructions: { text: "Yes?" } },
165
+ },
166
+ });
167
+ await provider.evaluate({
168
+ state: "x",
169
+ questions: {
170
+ q: {
171
+ type: "noul",
172
+ instructions: "Yes?",
173
+ criteria: { true: "yes", false: "no", maybe: "unsure" },
174
+ },
175
+ },
176
+ });
177
+ assert.equal(calls, 3);
178
+ } finally {
179
+ globalThis.fetch = originalFetch;
180
+ }
181
+ });
182
+
183
+ it("auto does not retry native failures through HTTP", async () => {
184
+ const originalFetch = globalThis.fetch;
185
+ const session = createSessionConfig({}, { mode: "auto" });
186
+ const ctx = {
187
+ modelRegistry: {
188
+ findOfType: () => ({ id: "jev-latest" }),
189
+ classify: async () => ({
190
+ stopReason: "error",
191
+ errorMessage: "secret provider error",
192
+ }),
193
+ },
194
+ } as never;
195
+ globalThis.fetch = (async () => {
196
+ throw Error("HTTP fallback forbidden");
197
+ }) as typeof fetch;
198
+ try {
199
+ const provider = await resolveSessionProvider(session, ctx);
200
+ await assert.rejects(
201
+ provider.evaluate({
202
+ state: "x",
203
+ questions: { q: { type: "noul", instructions: "Is it?" } },
204
+ }),
205
+ (error: Error) => {
206
+ assert.match(error.message, /Pi native classifier failed/);
207
+ assert.doesNotMatch(error.message, /secret provider error/);
208
+ return true;
209
+ },
210
+ );
211
+ } finally {
212
+ globalThis.fetch = originalFetch;
213
+ }
214
+ });
215
+
7
216
  it("rejects malformed successful native probability before tool output", async () => {
8
217
  const session = createSessionConfig({});
9
218
  session.mode = "native";