@pi-in-go/pigpen-pi-typesafe 0.1.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.
Files changed (189) hide show
  1. package/CREDITS.md +14 -0
  2. package/LICENSE +22 -0
  3. package/README.md +45 -0
  4. package/extensions/pi-typesafe/branches_test.go +185 -0
  5. package/extensions/pi-typesafe/command.go +319 -0
  6. package/extensions/pi-typesafe/export_test.go +9 -0
  7. package/extensions/pi-typesafe/extension.go +188 -0
  8. package/extensions/pi-typesafe/extension_test.go +321 -0
  9. package/extensions/pi-typesafe/fakehost_test.go +548 -0
  10. package/extensions/pi-typesafe/format.go +191 -0
  11. package/extensions/pi-typesafe/format_test.go +75 -0
  12. package/extensions/pi-typesafe/go.mod +10 -0
  13. package/extensions/pi-typesafe/go.sum +2 -0
  14. package/extensions/pi-typesafe/go.work +11 -0
  15. package/extensions/pi-typesafe/harness_test.go +200 -0
  16. package/extensions/pi-typesafe/ownmodel_test.go +100 -0
  17. package/extensions/pi-typesafe/review_test.go +134 -0
  18. package/extensions/pi-typesafe/tool.go +193 -0
  19. package/extensions/pi-typesafe/twin_test.go +28 -0
  20. package/libs/pi-typesafe-api/CREDITS.md +14 -0
  21. package/libs/pi-typesafe-api/LICENSE +22 -0
  22. package/libs/pi-typesafe-api/README.md +30 -0
  23. package/libs/pi-typesafe-api/ask.go +62 -0
  24. package/libs/pi-typesafe-api/ask_test.go +76 -0
  25. package/libs/pi-typesafe-api/auth.go +249 -0
  26. package/libs/pi-typesafe-api/auth_test.go +131 -0
  27. package/libs/pi-typesafe-api/backends.go +336 -0
  28. package/libs/pi-typesafe-api/backends_test.go +404 -0
  29. package/libs/pi-typesafe-api/batch.go +202 -0
  30. package/libs/pi-typesafe-api/batch_test.go +202 -0
  31. package/libs/pi-typesafe-api/battery_test.go +41 -0
  32. package/libs/pi-typesafe-api/calibrate.go +354 -0
  33. package/libs/pi-typesafe-api/calibrate_test.go +186 -0
  34. package/libs/pi-typesafe-api/client.go +615 -0
  35. package/libs/pi-typesafe-api/client_test.go +490 -0
  36. package/libs/pi-typesafe-api/credentials.go +252 -0
  37. package/libs/pi-typesafe-api/credentials_test.go +216 -0
  38. package/libs/pi-typesafe-api/doc.go +14 -0
  39. package/libs/pi-typesafe-api/errors.go +143 -0
  40. package/libs/pi-typesafe-api/evaluation.go +86 -0
  41. package/libs/pi-typesafe-api/evaluation_schema.json +264 -0
  42. package/libs/pi-typesafe-api/gaps_test.go +77 -0
  43. package/libs/pi-typesafe-api/go.mod +9 -0
  44. package/libs/pi-typesafe-api/go.sum +2 -0
  45. package/libs/pi-typesafe-api/helpers_test.go +169 -0
  46. package/libs/pi-typesafe-api/hostmodel/hostmodel.go +87 -0
  47. package/libs/pi-typesafe-api/json.go +299 -0
  48. package/libs/pi-typesafe-api/json_test.go +92 -0
  49. package/libs/pi-typesafe-api/ownmodel_test.go +79 -0
  50. package/libs/pi-typesafe-api/package.json +40 -0
  51. package/libs/pi-typesafe-api/provenance.json +18 -0
  52. package/libs/pi-typesafe-api/review_test.go +23 -0
  53. package/libs/pi-typesafe-api/schema.go +473 -0
  54. package/libs/pi-typesafe-api/schema_test.go +262 -0
  55. package/libs/pi-typesafe-api/testdata/tools/typebox-messages.mts +5 -0
  56. package/libs/pi-typesafe-api/testdata/typebox-messages.json +285 -0
  57. package/libs/pi-typesafe-api/twin_test.go +28 -0
  58. package/libs/pi-typesafe-api/ui/fakehost_test.go +548 -0
  59. package/libs/pi-typesafe-api/ui/keyprompt.go +115 -0
  60. package/libs/pi-typesafe-api/ui/login.go +106 -0
  61. package/libs/pi-typesafe-api/ui/twin_test.go +28 -0
  62. package/libs/pi-typesafe-api/ui/ui_test.go +285 -0
  63. package/libs/pi-typesafe-api/usage.go +366 -0
  64. package/libs/pi-typesafe-api/usage_test.go +139 -0
  65. package/libs/typesafe/CONTRACT.md +125 -0
  66. package/libs/typesafe/CREDITS.md +37 -0
  67. package/libs/typesafe/LICENSE +23 -0
  68. package/libs/typesafe/README.md +19 -0
  69. package/libs/typesafe/go.mod +3 -0
  70. package/libs/typesafe/libraries/ownmodel/backend_test.go +496 -0
  71. package/libs/typesafe/libraries/ownmodel/canon.go +190 -0
  72. package/libs/typesafe/libraries/ownmodel/convert.go +199 -0
  73. package/libs/typesafe/libraries/ownmodel/doc.go +15 -0
  74. package/libs/typesafe/libraries/ownmodel/equivalence_test.go +199 -0
  75. package/libs/typesafe/libraries/ownmodel/helpers_test.go +155 -0
  76. package/libs/typesafe/libraries/ownmodel/mutation_test.go +31 -0
  77. package/libs/typesafe/libraries/ownmodel/ownmodel.go +225 -0
  78. package/libs/typesafe/libraries/ownmodel/plan.go +442 -0
  79. package/libs/typesafe/libraries/ownmodel/run.go +288 -0
  80. package/libs/typesafe/libraries/ownmodel/schema_test.go +254 -0
  81. package/libs/typesafe/libraries/ownmodel/twins_test.go +169 -0
  82. package/libs/typesafe/libraries/ownmodel/utils_test.go +125 -0
  83. package/libs/typesafe/libraries/pigmodel/pigmodel.go +264 -0
  84. package/libs/typesafe/libraries/pigmodel/pigmodel_test.go +410 -0
  85. package/libs/typesafe/libraries/typesafe/answers.go +268 -0
  86. package/libs/typesafe/libraries/typesafe/api_response_test.go +113 -0
  87. package/libs/typesafe/libraries/typesafe/batch.go +80 -0
  88. package/libs/typesafe/libraries/typesafe/batch_test.go +133 -0
  89. package/libs/typesafe/libraries/typesafe/bench_test.go +71 -0
  90. package/libs/typesafe/libraries/typesafe/client.go +561 -0
  91. package/libs/typesafe/libraries/typesafe/client_test.go +495 -0
  92. package/libs/typesafe/libraries/typesafe/crosscheck_test.go +464 -0
  93. package/libs/typesafe/libraries/typesafe/crosscheck_workflowevals_test.go +219 -0
  94. package/libs/typesafe/libraries/typesafe/doc.go +27 -0
  95. package/libs/typesafe/libraries/typesafe/entry.go +142 -0
  96. package/libs/typesafe/libraries/typesafe/env.go +11 -0
  97. package/libs/typesafe/libraries/typesafe/errors.go +310 -0
  98. package/libs/typesafe/libraries/typesafe/errors_test.go +175 -0
  99. package/libs/typesafe/libraries/typesafe/helpers_test.go +294 -0
  100. package/libs/typesafe/libraries/typesafe/live_test.go +96 -0
  101. package/libs/typesafe/libraries/typesafe/logging.go +160 -0
  102. package/libs/typesafe/libraries/typesafe/logging_test.go +259 -0
  103. package/libs/typesafe/libraries/typesafe/marshal_test.go +112 -0
  104. package/libs/typesafe/libraries/typesafe/mutation_test.go +39 -0
  105. package/libs/typesafe/libraries/typesafe/questions.go +490 -0
  106. package/libs/typesafe/libraries/typesafe/questions_test.go +166 -0
  107. package/libs/typesafe/libraries/typesafe/regressions_test.go +159 -0
  108. package/libs/typesafe/libraries/typesafe/reliability_test.go +649 -0
  109. package/libs/typesafe/libraries/typesafe/retry.go +350 -0
  110. package/libs/typesafe/libraries/typesafe/retry_test.go +297 -0
  111. package/libs/typesafe/libraries/typesafe/runtime_test.go +26 -0
  112. package/libs/typesafe/libraries/typesafe/transport_test.go +163 -0
  113. package/libs/typesafe/libraries/typesafe/twins_test.go +127 -0
  114. package/libs/typesafe/libraries/typesafe/types_test.go +165 -0
  115. package/libs/typesafe/libraries/typesafe/version.go +10 -0
  116. package/libs/typesafe/package.json +37 -0
  117. package/libs/typesafe/provenance.json +49 -0
  118. package/package.json +42 -0
  119. package/port/PORT.md +98 -0
  120. package/port/accepted-gaps.json +3 -0
  121. package/port/golden/enable-confirm.jsonl +11 -0
  122. package/port/golden/enable-decline.jsonl +20 -0
  123. package/port/golden/enable-missing-key.jsonl +4 -0
  124. package/port/golden/login-shadow.jsonl +4 -0
  125. package/port/golden/logout-env-key.jsonl +6 -0
  126. package/port/golden/playground-cancel.jsonl +4 -0
  127. package/port/golden/playground-invalid-json.jsonl +5 -0
  128. package/port/golden/playground-invalid-questions.jsonl +5 -0
  129. package/port/golden/status-env-key.jsonl +6 -0
  130. package/port/golden/status-no-key.jsonl +6 -0
  131. package/port/golden/tool-disabled.jsonl +18 -0
  132. package/port/golden/trailing-words.jsonl +10 -0
  133. package/port/library-mutations.py +44 -0
  134. package/port/mutations.json +302 -0
  135. package/port/oracle/.env.example +4 -0
  136. package/port/oracle/CHANGELOG.md +91 -0
  137. package/port/oracle/CONTRIBUTING.md +35 -0
  138. package/port/oracle/LICENSE +21 -0
  139. package/port/oracle/README.md +159 -0
  140. package/port/oracle/docs/api.md +143 -0
  141. package/port/oracle/docs/ci-cd.md +97 -0
  142. package/port/oracle/examples/decision-extension.ts +41 -0
  143. package/port/oracle/extensions/index.js +2 -0
  144. package/port/oracle/package.json +89 -0
  145. package/port/oracle/scripts/dev-pi.mjs +23 -0
  146. package/port/oracle/scripts/live-smoke.mjs +35 -0
  147. package/port/oracle/src/ask.ts +42 -0
  148. package/port/oracle/src/auth.ts +171 -0
  149. package/port/oracle/src/backends.ts +196 -0
  150. package/port/oracle/src/batch.ts +170 -0
  151. package/port/oracle/src/calibrate.ts +237 -0
  152. package/port/oracle/src/client.ts +310 -0
  153. package/port/oracle/src/credentials.ts +136 -0
  154. package/port/oracle/src/errors.ts +53 -0
  155. package/port/oracle/src/extension.ts +204 -0
  156. package/port/oracle/src/index.ts +31 -0
  157. package/port/oracle/src/key-prompt.ts +51 -0
  158. package/port/oracle/src/login.ts +60 -0
  159. package/port/oracle/src/schema.ts +158 -0
  160. package/port/oracle/src/ui.ts +4 -0
  161. package/port/oracle/src/usage.ts +258 -0
  162. package/port/oracle/tests/ask.test.ts +63 -0
  163. package/port/oracle/tests/auth.test.ts +141 -0
  164. package/port/oracle/tests/backends.test.ts +380 -0
  165. package/port/oracle/tests/batch.test.ts +156 -0
  166. package/port/oracle/tests/calibrate.test.ts +144 -0
  167. package/port/oracle/tests/client.test.ts +499 -0
  168. package/port/oracle/tests/credentials.test.ts +144 -0
  169. package/port/oracle/tests/extension.test.ts +276 -0
  170. package/port/oracle/tests/key-prompt.test.ts +47 -0
  171. package/port/oracle/tests/login.test.ts +101 -0
  172. package/port/oracle/tests/schema.test.ts +85 -0
  173. package/port/oracle/tests/usage.test.ts +106 -0
  174. package/port/oracle/tsconfig.build.json +10 -0
  175. package/port/oracle/tsconfig.json +14 -0
  176. package/port/scenarios/enable-confirm.json +5 -0
  177. package/port/scenarios/enable-decline.json +3 -0
  178. package/port/scenarios/enable-missing-key.json +2 -0
  179. package/port/scenarios/login-shadow.json +2 -0
  180. package/port/scenarios/logout-env-key.json +3 -0
  181. package/port/scenarios/playground-cancel.json +2 -0
  182. package/port/scenarios/playground-invalid-json.json +2 -0
  183. package/port/scenarios/playground-invalid-questions.json +2 -0
  184. package/port/scenarios/status-env-key.json +3 -0
  185. package/port/scenarios/status-no-key.json +3 -0
  186. package/port/scenarios/tool-disabled.json +2 -0
  187. package/port/scenarios/trailing-words.json +5 -0
  188. package/port/upstream-tests.json +160 -0
  189. package/provenance.json +18 -0
@@ -0,0 +1,144 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdtempSync, readFileSync, statSync, writeFileSync, chmodSync, mkdirSync } from "node:fs";
3
+ import { rm } from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { after, before, beforeEach, test } from "node:test";
7
+ import { clearStoredApiKey, credentialsPath, keySituation, keySourceLabel, normalizeApiKey, readStoredApiKey, resolveApiKey, storeApiKey } from "../src/credentials.js";
8
+ import { createTypeSafe, TypeSafeIntegrationError } from "../src/index.js";
9
+
10
+ const savedAgentDir = process.env.PI_CODING_AGENT_DIR;
11
+ const savedKey = process.env.TYPESAFE_API_KEY;
12
+ let agentDir: string;
13
+ const validKey = "ts_test_key_0123456789abcdef";
14
+
15
+ before(() => {
16
+ agentDir = mkdtempSync(join(tmpdir(), "pi-typesafe-credentials-"));
17
+ process.env.PI_CODING_AGENT_DIR = agentDir;
18
+ });
19
+ beforeEach(() => {
20
+ delete process.env.TYPESAFE_API_KEY;
21
+ clearStoredApiKey();
22
+ });
23
+ after(async () => {
24
+ if (savedAgentDir === undefined) delete process.env.PI_CODING_AGENT_DIR; else process.env.PI_CODING_AGENT_DIR = savedAgentDir;
25
+ if (savedKey === undefined) delete process.env.TYPESAFE_API_KEY; else process.env.TYPESAFE_API_KEY = savedKey;
26
+ await rm(agentDir, { recursive: true, force: true });
27
+ });
28
+
29
+ test("credentials live under Pi's agent directory", () => {
30
+ assert.equal(credentialsPath(), join(agentDir, "pi-typesafe", "auth.json"));
31
+ });
32
+
33
+ test("store, read, and clear with owner-only permissions", () => {
34
+ assert.equal(resolveApiKey(), undefined);
35
+ const path = storeApiKey(` ${validKey}\n`);
36
+ assert.equal(path, credentialsPath());
37
+ assert.equal(statSync(path).mode & 0o777, 0o600);
38
+ assert.equal(statSync(join(agentDir, "pi-typesafe")).mode & 0o777, 0o700);
39
+ assert.deepEqual(JSON.parse(readFileSync(path, "utf8")), { apiKey: validKey });
40
+ assert.deepEqual(resolveApiKey(), { key: validKey, source: "stored" });
41
+ assert.equal(clearStoredApiKey(), true);
42
+ assert.equal(clearStoredApiKey(), false);
43
+ assert.equal(readStoredApiKey(), undefined);
44
+ });
45
+
46
+ test("environment variable takes precedence over the stored key", () => {
47
+ storeApiKey(validKey);
48
+ process.env.TYPESAFE_API_KEY = "env_key_0123456789abcdef";
49
+ assert.deepEqual(resolveApiKey(), { key: "env_key_0123456789abcdef", source: "environment" });
50
+ });
51
+
52
+ test("implausible keys are rejected without saving", () => {
53
+ for (const value of ["", "short", "has space in it 0123456789", "tab\tseparated0123456789", "ключ-with-non-ascii-0123456789", "x".repeat(513), 42, null]) {
54
+ assert.throws(() => storeApiKey(value), (error: unknown) => error instanceof TypeSafeIntegrationError && error.code === "validation" && (String(value).length < 4 || !error.message.includes(String(value))));
55
+ }
56
+ assert.equal(readStoredApiKey(), undefined);
57
+ assert.equal(normalizeApiKey(` ${validKey} `), validKey);
58
+ });
59
+
60
+ test("group- or world-readable credential files are refused", { skip: process.platform === "win32" }, () => {
61
+ storeApiKey(validKey);
62
+ chmodSync(credentialsPath(), 0o644);
63
+ assert.throws(() => resolveApiKey(), (error: unknown) => error instanceof TypeSafeIntegrationError && error.code === "configuration" && /chmod 600/.test(error.message));
64
+ });
65
+
66
+ test("corrupt or unexpected files are treated as no key", () => {
67
+ mkdirSync(join(agentDir, "pi-typesafe"), { recursive: true, mode: 0o700 });
68
+ for (const content of ["not json", "[]", "{\"apiKey\": 5}", "{}"]) {
69
+ writeFileSync(credentialsPath(), content, { mode: 0o600 });
70
+ assert.equal(readStoredApiKey(), undefined);
71
+ }
72
+ });
73
+
74
+ test("createTypeSafe uses the stored key and listModels verifies it without spending the request budget", async () => {
75
+ storeApiKey(validKey);
76
+ let authorized = false;
77
+ const client = createTypeSafe({ maxRequests: 1, fetch: async (url, init) => {
78
+ assert.equal(url, "https://api.typesafe.ai/v1/models");
79
+ authorized = Object.values(Object.fromEntries(new Headers(init?.headers))).some(value => value.includes(validKey));
80
+ return Response.json({ models: [{ name: "jev-latest", description: "", release_date: "2026-01-01" }, { name: 7 }] });
81
+ } });
82
+ assert.deepEqual(await client.listModels(), ["jev-latest"]);
83
+ assert.ok(authorized);
84
+ assert.equal(client.getUsage().requestsStarted, 0);
85
+ assert.throws(() => { clearStoredApiKey(); createTypeSafe(); }, (error: unknown) => error instanceof TypeSafeIntegrationError && /\/typesafe login/.test(error.message));
86
+ });
87
+
88
+ test("invalid keys fail verification with a safe message", async () => {
89
+ const client = createTypeSafe({ apiKey: validKey, fetch: async () => Response.json({ detail: "secret-body" }, { status: 401 }) });
90
+ await assert.rejects(client.listModels(), (error: unknown) => error instanceof TypeSafeIntegrationError && error.status === 401 && !error.message.includes("secret-body"));
91
+ });
92
+
93
+ test("keySituation is total and names every kind", { skip: process.platform === "win32" }, () => {
94
+ assert.deepEqual(keySituation(), { kind: "missing" });
95
+
96
+ storeApiKey(validKey);
97
+ assert.deepEqual(keySituation(), { kind: "stored", key: validKey, path: credentialsPath() });
98
+
99
+ chmodSync(credentialsPath(), 0o644);
100
+ const situation = keySituation();
101
+ assert.ok(situation.kind === "unusable");
102
+ assert.equal(situation.path, credentialsPath());
103
+ assert.match(situation.reason, /chmod 600/);
104
+ assert.throws(() => resolveApiKey(), (error: unknown) => error instanceof TypeSafeIntegrationError && error.message === situation.reason);
105
+
106
+ process.env.TYPESAFE_API_KEY = ` ${validKey} `;
107
+ assert.deepEqual(keySituation(), { kind: "environment", key: validKey, keyEnv: "TYPESAFE_API_KEY" });
108
+
109
+ // Environment values are trusted as-is; a wrong key fails at the API with its own advice.
110
+ process.env.TYPESAFE_API_KEY = "short";
111
+ assert.deepEqual(keySituation(), { kind: "environment", key: "short", keyEnv: "TYPESAFE_API_KEY" });
112
+ });
113
+
114
+ test("keySituation for another backend reads only that backend's variable, never the TypeSafe store", () => {
115
+ const savedOpenRouter = process.env.OPENROUTER_API_KEY;
116
+ try {
117
+ delete process.env.OPENROUTER_API_KEY;
118
+ storeApiKey(validKey);
119
+ process.env.TYPESAFE_API_KEY = validKey;
120
+ // A stored or TypeSafe-environment key is not an OpenRouter key.
121
+ assert.deepEqual(keySituation("openrouter"), { kind: "missing" });
122
+ assert.equal(resolveApiKey("openrouter"), undefined);
123
+
124
+ process.env.OPENROUTER_API_KEY = " sk-or-test-0123456789abcdef ";
125
+ const situation = keySituation("openrouter");
126
+ assert.deepEqual(situation, { kind: "environment", key: "sk-or-test-0123456789abcdef", keyEnv: "OPENROUTER_API_KEY" });
127
+ assert.equal(keySourceLabel(situation), "OPENROUTER_API_KEY");
128
+ assert.deepEqual(resolveApiKey("openrouter"), { key: "sk-or-test-0123456789abcdef", source: "environment" });
129
+ // The OpenRouter variable does not leak into the TypeSafe resolution either.
130
+ delete process.env.TYPESAFE_API_KEY;
131
+ assert.equal(keySituation().kind, "stored");
132
+ assert.throws(() => keySituation("bogus" as never), (error: unknown) => error instanceof TypeSafeIntegrationError && error.code === "configuration" && /Unknown judgment backend/.test(error.message));
133
+ } finally {
134
+ if (savedOpenRouter === undefined) delete process.env.OPENROUTER_API_KEY; else process.env.OPENROUTER_API_KEY = savedOpenRouter;
135
+ }
136
+ });
137
+
138
+ test("keySourceLabel names each source", () => {
139
+ assert.equal(keySourceLabel({ kind: "environment", key: "k" }), "TYPESAFE_API_KEY");
140
+ assert.equal(keySourceLabel({ kind: "environment", key: "k", keyEnv: "OPENROUTER_API_KEY" }), "OPENROUTER_API_KEY");
141
+ assert.equal(keySourceLabel({ kind: "stored", key: "k", path: "/tmp/auth.json" }), "/typesafe login");
142
+ assert.equal(keySourceLabel({ kind: "missing" }), "no key");
143
+ assert.equal(keySourceLabel({ kind: "unusable", path: "/tmp/auth.json", reason: "r" }), "unusable key");
144
+ });
@@ -0,0 +1,276 @@
1
+ import assert from "node:assert/strict";
2
+ import { existsSync, statSync } from "node:fs";
3
+ import { mkdtemp, rm } from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
5
+ import { join, resolve } from "node:path";
6
+ import { after, before, test } from "node:test";
7
+ import { DefaultResourceLoader, SettingsManager } from "@earendil-works/pi-coding-agent";
8
+ import type { Extension, RegisteredCommand, RegisteredTool } from "@earendil-works/pi-coding-agent";
9
+ import { visibleWidth } from "@earendil-works/pi-tui";
10
+ import { parseEvaluationRequest } from "../src/index.js";
11
+
12
+ let temporary: string;
13
+ let extension: Extension;
14
+ let tool: RegisteredTool;
15
+ let command: RegisteredCommand;
16
+ const savedKey = process.env.TYPESAFE_API_KEY;
17
+ const savedEnabled = process.env.PI_TYPESAFE_ENABLED;
18
+ const savedAgentDir = process.env.PI_CODING_AGENT_DIR;
19
+ const originalFetch = globalThis.fetch;
20
+ const notices: string[] = [];
21
+ let confirmResult = true;
22
+ let confirmations = 0;
23
+ let editorText: string | undefined;
24
+ let networkCalls = 0;
25
+ let modelListCalls = 0;
26
+ let customResult: string | undefined;
27
+ const ui = {
28
+ notify: (text: string) => { notices.push(text); },
29
+ confirm: async () => { confirmations++; return confirmResult; },
30
+ editor: async () => editorText,
31
+ custom: async () => customResult,
32
+ input: async () => { throw new Error("plain input must not be used when custom UI exists"); },
33
+ };
34
+ const ctx = { hasUI: true, ui };
35
+ const runCommand = (args: string, context = ctx) => Reflect.apply(command.handler, command, [args, context]);
36
+ const runTool = (signal?: AbortSignal) => Reflect.apply(tool.definition.execute, tool.definition, [
37
+ "test-call", { state: "synthetic", questions: { yes: { type: "noul", instructions: "Is this synthetic?" } } }, signal, undefined, ctx,
38
+ ]);
39
+
40
+ before(async () => {
41
+ temporary = await mkdtemp(join(tmpdir(), "pi-typesafe-test-"));
42
+ delete process.env.PI_TYPESAFE_ENABLED;
43
+ process.env.PI_CODING_AGENT_DIR = temporary;
44
+ process.env.TYPESAFE_API_KEY = "offline-test-key";
45
+ globalThis.fetch = async (input) => {
46
+ if (String(input).endsWith("/v1/models")) {
47
+ modelListCalls++;
48
+ return Response.json({ models: [{ name: "jev-latest", description: "", release_date: "2026-01-01" }] });
49
+ }
50
+ networkCalls++;
51
+ return Response.json({ model: "jev-test", answers: { yes: { type: "noul", noul: 0.9 } }, usage: { input_tokens: 12, output_tokens: 0 } });
52
+ };
53
+ const loader = new DefaultResourceLoader({
54
+ cwd: temporary,
55
+ agentDir: temporary,
56
+ settingsManager: SettingsManager.inMemory(),
57
+ noExtensions: true, noSkills: true, noPromptTemplates: true, noThemes: true, noContextFiles: true,
58
+ additionalExtensionPaths: [resolve("src/extension.ts")],
59
+ });
60
+ await loader.reload();
61
+ const result = loader.getExtensions();
62
+ assert.deepEqual(result.errors, [], "native Pi loader must accept the extension");
63
+ const loaded = result.extensions[0];
64
+ assert.ok(loaded);
65
+ extension = loaded;
66
+ const registeredTool = extension.tools.get("typesafe_evaluate");
67
+ const registeredCommand = extension.commands.get("typesafe");
68
+ assert.ok(registeredTool);
69
+ assert.ok(registeredCommand);
70
+ tool = registeredTool;
71
+ command = registeredCommand;
72
+ });
73
+
74
+ after(async () => {
75
+ globalThis.fetch = originalFetch;
76
+ if (savedKey === undefined) delete process.env.TYPESAFE_API_KEY; else process.env.TYPESAFE_API_KEY = savedKey;
77
+ if (savedEnabled === undefined) delete process.env.PI_TYPESAFE_ENABLED; else process.env.PI_TYPESAFE_ENABLED = savedEnabled;
78
+ if (savedAgentDir === undefined) delete process.env.PI_CODING_AGENT_DIR; else process.env.PI_CODING_AGENT_DIR = savedAgentDir;
79
+ if (temporary) await rm(temporary, { recursive: true, force: true });
80
+ });
81
+
82
+ test("Pi loads a tool, a slash command, and a result renderer without network calls", async () => {
83
+ assert.equal(networkCalls, 0);
84
+ assert.equal(tool.definition.name, "typesafe_evaluate");
85
+ const completions = await command.getArgumentCompletions?.("pla");
86
+ assert.ok(completions?.some(item => item.value === "playground"));
87
+ assert.ok(tool.definition.promptGuidelines?.some(text => /one question per item per dimension/.test(text)));
88
+ // Models that never saw a payload author questions as an array; the guidelines must show one that actually validates.
89
+ const example = tool.definition.promptGuidelines?.find(text => /"state":/.test(text));
90
+ assert.ok(example, "one guideline shows a request payload");
91
+ assert.doesNotMatch(example, /\n/, "the example stays on one line");
92
+ assert.ok(example.length < 1024, "the example is paid for on every tool listing, so it stays short");
93
+ const payload = parseEvaluationRequest(JSON.parse(example.slice(example.indexOf("{"))));
94
+ assert.deepEqual(Object.values(payload.questions).map(question => question.type).sort(), ["choice", "noul", "score"]);
95
+ assert.ok(/named state field/.test(tool.definition.description));
96
+ });
97
+
98
+ test("default-disabled tool cannot submit data", async () => {
99
+ await assert.rejects(runTool(), /disabled/);
100
+ assert.equal(networkCalls, 0);
101
+ });
102
+
103
+ test("setup and status never display the API key", async () => {
104
+ await runCommand("setup");
105
+ await runCommand("status");
106
+ assert.ok(notices.some(text => text.includes("TypeSafe key: TYPESAFE_API_KEY")));
107
+ assert.equal(notices.some(text => text.includes("offline-test-key")), false);
108
+ });
109
+
110
+ test("status names the model the configured backend actually sends", async () => {
111
+ await runCommand("status");
112
+ assert.ok(notices.at(-1)?.includes("Model: jev-latest."));
113
+ });
114
+
115
+ test("login refuses to shadow an environment key", async () => {
116
+ await runCommand("login");
117
+ assert.ok(notices.at(-1)?.includes("takes precedence"));
118
+ assert.equal(modelListCalls, 0);
119
+ });
120
+
121
+ test("declining consent keeps the tool disabled", async () => {
122
+ confirmResult = false;
123
+ await runCommand("enable");
124
+ await assert.rejects(runTool(), /disabled/);
125
+ assert.equal(networkCalls, 0);
126
+ });
127
+
128
+ test("explicit consent enables the real registered tool and returns structured results", async () => {
129
+ confirmResult = true;
130
+ await runCommand("enable");
131
+ const result = await runTool();
132
+ assert.equal(result.details.answers.yes.noul, 0.9);
133
+ assert.equal(networkCalls, 1);
134
+ assert.ok(confirmations >= 2);
135
+ const renderer = tool.definition.renderResult;
136
+ assert.ok(renderer);
137
+ const component = Reflect.apply(renderer, tool.definition, [result, { expanded: true, isPartial: false }, {}]);
138
+ for (const width of [40, 80, 120]) {
139
+ const lines: string[] = component.render(width);
140
+ assert.ok(lines.every(line => visibleWidth(line) <= width));
141
+ assert.ok(lines.join("\n").includes("P(yes)"));
142
+ }
143
+ });
144
+
145
+ test("disable stops future calls without resetting usage", async () => {
146
+ await runCommand("disable");
147
+ await assert.rejects(runTool(), /disabled/);
148
+ await runCommand("status");
149
+ assert.ok(notices.at(-1)?.includes("1/20 attempts"));
150
+ });
151
+
152
+ test("invalid playground JSON and cancellation do not submit data", async () => {
153
+ editorText = '{"broken":';
154
+ await runCommand("playground");
155
+ assert.ok(notices.at(-1)?.includes("Invalid JSON"));
156
+ editorText = undefined;
157
+ await runCommand("playground");
158
+ assert.equal(networkCalls, 1);
159
+ });
160
+
161
+ test("playground validates questions before requesting consent", async () => {
162
+ editorText = JSON.stringify({ state: "example", questions: {} });
163
+ const prior = confirmations;
164
+ await runCommand("playground");
165
+ assert.equal(confirmations, prior);
166
+ assert.equal(networkCalls, 1);
167
+ assert.ok(notices.at(-1)?.includes("Invalid evaluation request"));
168
+ });
169
+
170
+ test("test command requires confirmation and does not enable agent calls", async () => {
171
+ confirmResult = false;
172
+ await runCommand("test");
173
+ assert.equal(networkCalls, 1);
174
+ await assert.rejects(runTool(), /disabled/);
175
+ });
176
+
177
+ test("login verifies, stores with owner-only permissions, and never echoes the key", async () => {
178
+ delete process.env.TYPESAFE_API_KEY;
179
+ const storedPath = join(temporary, "pi-typesafe", "auth.json");
180
+ customResult = undefined;
181
+ await runCommand("login");
182
+ assert.ok(notices.at(-1)?.includes("cancelled"));
183
+ assert.equal(existsSync(storedPath), false);
184
+ customResult = "nope";
185
+ await runCommand("login");
186
+ assert.ok(notices.at(-1)?.includes("does not look like"));
187
+ assert.equal(modelListCalls, 0);
188
+ assert.equal(existsSync(storedPath), false);
189
+ customResult = "ts_live_key_0123456789abcdef";
190
+ await runCommand("login");
191
+ assert.equal(modelListCalls, 1);
192
+ assert.ok(notices.at(-1)?.includes("Key verified (1 model available)"));
193
+ assert.equal(notices.some(text => text.includes("ts_live_key")), false);
194
+ assert.equal(statSync(storedPath).mode & 0o777, 0o600);
195
+ await runCommand("status");
196
+ assert.ok(notices.at(-1)?.includes("TypeSafe key: /typesafe login"));
197
+ await runCommand("setup");
198
+ assert.ok(notices.at(-1)?.includes("configured via /typesafe login"));
199
+ // The stored key powers the real tool after consent.
200
+ confirmResult = true;
201
+ await runCommand("enable");
202
+ await runTool();
203
+ assert.equal(networkCalls, 2);
204
+ await runCommand("logout");
205
+ assert.equal(existsSync(storedPath), false);
206
+ await assert.rejects(runTool(), /disabled/);
207
+ await runCommand("status");
208
+ assert.ok(notices.at(-1)?.includes("TypeSafe key: missing"));
209
+ process.env.TYPESAFE_API_KEY = "offline-test-key";
210
+ });
211
+
212
+ test("new sessions reset opt-in; headless opt-in is explicit", async () => {
213
+ const handlers = extension.handlers.get("session_start");
214
+ assert.ok(handlers?.length);
215
+ for (const handler of handlers) await Reflect.apply(handler, extension, [{ reason: "new" }, ctx]);
216
+ await assert.rejects(runTool(), /disabled/);
217
+ process.env.PI_TYPESAFE_ENABLED = "1";
218
+ for (const handler of handlers) await Reflect.apply(handler, extension, [{ reason: "startup" }, ctx]);
219
+ await runTool();
220
+ assert.equal(networkCalls, 3);
221
+ await runCommand("status");
222
+ assert.ok(notices.at(-1)?.includes("1/20 attempts"));
223
+ });
224
+
225
+ test("an enabled session with no key announces that judgments are skipped", async () => {
226
+ delete process.env.TYPESAFE_API_KEY;
227
+ process.env.PI_TYPESAFE_ENABLED = "1";
228
+ const handlers = extension.handlers.get("session_start") ?? [];
229
+ const before = notices.length;
230
+ for (const handler of handlers) await Reflect.apply(handler, extension, [{ reason: "startup" }, ctx]);
231
+ const said = notices.slice(before).join("\n");
232
+ assert.ok(said.includes("judgments are skipped"));
233
+ assert.ok(said.includes("TypeSafe key: missing"));
234
+ // A key that appears later silences the next startup notice.
235
+ process.env.TYPESAFE_API_KEY = "offline-test-key";
236
+ const again = notices.length;
237
+ for (const handler of handlers) await Reflect.apply(handler, extension, [{ reason: "reload" }, ctx]);
238
+ assert.equal(notices.slice(again).some(text => text.includes("judgments are skipped")), false);
239
+ });
240
+
241
+ test("a rejected key is called out once per session and shows up in status", async () => {
242
+ process.env.PI_TYPESAFE_ENABLED = "1";
243
+ const handlers = extension.handlers.get("session_start") ?? [];
244
+ for (const handler of handlers) await Reflect.apply(handler, extension, [{ reason: "startup" }, ctx]);
245
+ const before = notices.length;
246
+ const offlineStub = globalThis.fetch;
247
+ globalThis.fetch = async () => Response.json({ error: { message: "invalid key" } }, { status: 401 });
248
+ try {
249
+ await assert.rejects(runTool(), /HTTP 401/);
250
+ await assert.rejects(runTool(), /HTTP 401/);
251
+ } finally {
252
+ globalThis.fetch = offlineStub;
253
+ }
254
+ // Two failed calls, one callout: the reason is loud once, not once per call.
255
+ assert.equal(notices.slice(before).filter(text => text.includes("not authenticated")).length, 1);
256
+ await runCommand("status");
257
+ assert.ok(notices.at(-1)?.includes("was rejected"));
258
+ assert.ok(notices.at(-1)?.includes("Today "));
259
+ assert.ok(notices.at(-1)?.includes("failed"));
260
+ });
261
+
262
+ test("the registered tool admits the same near-miss aliases as the library", async () => {
263
+ process.env.PI_TYPESAFE_ENABLED = "1";
264
+ const handlers = extension.handlers.get("session_start");
265
+ for (const handler of handlers ?? []) await Reflect.apply(handler, extension, [{ reason: "startup" }, ctx]);
266
+ const before = networkCalls;
267
+ const result = await Reflect.apply(tool.definition.execute, tool.definition, [
268
+ "test-call",
269
+ { state: "synthetic", questions: { yes: { type: "noul", instructions: "Is this synthetic?", criteria: "Is this synthetic data?" } } },
270
+ undefined,
271
+ undefined,
272
+ ctx,
273
+ ]);
274
+ assert.equal(networkCalls, before + 1);
275
+ assert.equal(result.details.answers.yes.noul, 0.9);
276
+ });
@@ -0,0 +1,47 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
4
+ import { promptForApiKey } from "../src/key-prompt.js";
5
+
6
+ const theme = { fg: (_color: string, text: string) => text, bold: (text: string) => text };
7
+
8
+ function fakeContext(drive: (component: { render(width: number): string[]; handleInput(data: string): void; focused: boolean }) => void): ExtensionCommandContext {
9
+ const ui = {
10
+ custom: async <T>(factory: (tui: unknown, theme: unknown, keybindings: unknown, done: (value: T) => void) => unknown) =>
11
+ new Promise<T>(resolve => {
12
+ const component = factory({}, theme, {}, resolve) as { render(width: number): string[]; handleInput(data: string): void; focused: boolean };
13
+ component.focused = true;
14
+ drive(component);
15
+ }),
16
+ input: async () => { throw new Error("must not fall back"); },
17
+ };
18
+ return { hasUI: true, ui } as unknown as ExtensionCommandContext;
19
+ }
20
+
21
+ test("typed key is masked on screen and returned on Enter", async () => {
22
+ let frame: string[] = [];
23
+ const key = await promptForApiKey(fakeContext(component => {
24
+ for (const character of "ts_secret_key_0123456789") component.handleInput(character);
25
+ frame = component.render(60);
26
+ component.handleInput("\r");
27
+ }));
28
+ assert.equal(key, "ts_secret_key_0123456789");
29
+ const screen = frame.join("\n");
30
+ assert.equal(screen.includes("ts_secret"), false);
31
+ assert.ok(screen.includes("•".repeat(24)));
32
+ assert.ok(screen.includes("TypeSafe API key"));
33
+ assert.ok(frame.every(line => line.replace(/\x1b\[[0-9;]*m/g, "").length <= 60 + 8));
34
+ });
35
+
36
+ test("Escape cancels without a value", async () => {
37
+ const key = await promptForApiKey(fakeContext(component => {
38
+ component.handleInput("a");
39
+ component.handleInput("\x1b");
40
+ }));
41
+ assert.equal(key, undefined);
42
+ });
43
+
44
+ test("falls back to Pi's plain input when custom UI is unavailable", async () => {
45
+ const ctx = { hasUI: true, ui: { input: async () => " ts_plain_key_0123456789 " } } as unknown as ExtensionCommandContext;
46
+ assert.equal(await promptForApiKey(ctx), " ts_plain_key_0123456789 ");
47
+ });
@@ -0,0 +1,101 @@
1
+ import assert from "node:assert/strict";
2
+ import { existsSync, statSync } from "node:fs";
3
+ import { mkdtemp, rm } from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { after, before, beforeEach, test } from "node:test";
7
+ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
8
+ import { TypeSafeIntegrationError } from "../src/errors.js";
9
+ import { ensureApiKey, loginWithPrompt } from "../src/login.js";
10
+
11
+ let temporary: string;
12
+ const savedKey = process.env.TYPESAFE_API_KEY;
13
+ const savedAgentDir = process.env.PI_CODING_AGENT_DIR;
14
+ const originalFetch = globalThis.fetch;
15
+ let customResult: string | undefined;
16
+ let modelListCalls = 0;
17
+ const storedPath = () => join(temporary, "pi-typesafe", "auth.json");
18
+ const ctx = (hasUI = true) => ({ hasUI, ui: { custom: async () => customResult, input: async () => { throw new Error("plain input must not be used"); } } }) as unknown as ExtensionCommandContext;
19
+
20
+ before(async () => {
21
+ temporary = await mkdtemp(join(tmpdir(), "pi-typesafe-login-"));
22
+ process.env.PI_CODING_AGENT_DIR = temporary;
23
+ globalThis.fetch = async (input) => {
24
+ if (String(input).endsWith("/v1/models")) {
25
+ modelListCalls++;
26
+ return Response.json({ models: [{ name: "jev-latest", description: "", release_date: "2026-01-01" }, { name: "jev-preview", description: "", release_date: "2026-01-01" }] });
27
+ }
28
+ throw new Error("unexpected request");
29
+ };
30
+ });
31
+ beforeEach(async () => {
32
+ delete process.env.TYPESAFE_API_KEY;
33
+ modelListCalls = 0;
34
+ customResult = undefined;
35
+ await rm(storedPath(), { force: true });
36
+ });
37
+ after(async () => {
38
+ globalThis.fetch = originalFetch;
39
+ if (savedKey === undefined) delete process.env.TYPESAFE_API_KEY; else process.env.TYPESAFE_API_KEY = savedKey;
40
+ if (savedAgentDir === undefined) delete process.env.PI_CODING_AGENT_DIR; else process.env.PI_CODING_AGENT_DIR = savedAgentDir;
41
+ await rm(temporary, { recursive: true, force: true });
42
+ });
43
+
44
+ test("loginWithPrompt cancels cleanly, rejects bad keys, and stores a verified key with owner-only permissions", async () => {
45
+ assert.equal(await loginWithPrompt(ctx()), undefined);
46
+ assert.equal(existsSync(storedPath()), false);
47
+
48
+ customResult = "nope";
49
+ await assert.rejects(loginWithPrompt(ctx()), (error: unknown) => error instanceof TypeSafeIntegrationError && error.code === "validation" && !error.message.includes("nope"));
50
+ assert.equal(modelListCalls, 0);
51
+
52
+ customResult = "ts_live_key_0123456789abcdef";
53
+ const result = await loginWithPrompt(ctx());
54
+ assert.deepEqual(result, { path: storedPath(), models: 2 });
55
+ assert.equal(modelListCalls, 1);
56
+ assert.equal(statSync(storedPath()).mode & 0o777, 0o600);
57
+ });
58
+
59
+ test("loginWithPrompt refuses when the environment key would shadow the store, or without a UI", async () => {
60
+ process.env.TYPESAFE_API_KEY = "env-key-0123456789";
61
+ await assert.rejects(loginWithPrompt(ctx()), (error: unknown) => error instanceof TypeSafeIntegrationError && error.code === "configuration" && /TYPESAFE_API_KEY/.test(error.message));
62
+ delete process.env.TYPESAFE_API_KEY;
63
+ await assert.rejects(loginWithPrompt(ctx(false)), (error: unknown) => error instanceof TypeSafeIntegrationError && /interactive/.test(error.message));
64
+ assert.equal(existsSync(storedPath()), false);
65
+ });
66
+
67
+ test("ensureApiKey reports an existing key without prompting, otherwise logs in", async () => {
68
+ process.env.TYPESAFE_API_KEY = "env-key-0123456789";
69
+ customResult = "must-not-be-used-0123456789";
70
+ assert.deepEqual(await ensureApiKey(ctx()), { source: "environment" });
71
+ assert.equal(existsSync(storedPath()), false);
72
+
73
+ delete process.env.TYPESAFE_API_KEY;
74
+ customResult = undefined;
75
+ assert.equal(await ensureApiKey(ctx()), undefined, "cancelled prompt");
76
+
77
+ customResult = "ts_live_key_0123456789abcdef";
78
+ const first = await ensureApiKey(ctx());
79
+ assert.deepEqual(first, { source: "stored", login: { path: storedPath(), models: 2 } });
80
+ customResult = "another-key-that-must-not-replace-it";
81
+ assert.deepEqual(await ensureApiKey(ctx()), { source: "stored" }, "second call reuses the stored key");
82
+ assert.equal(modelListCalls, 1);
83
+ });
84
+
85
+ test("ensureApiKey for another backend uses its environment variable and never opens the TypeSafe login", async () => {
86
+ const savedOpenRouter = process.env.OPENROUTER_API_KEY;
87
+ try {
88
+ delete process.env.OPENROUTER_API_KEY;
89
+ // A stored TypeSafe key does not satisfy OpenRouter, and the prompt must not run: it would verify against api.typesafe.ai.
90
+ customResult = "ts_live_key_0123456789abcdef";
91
+ assert.deepEqual(await ensureApiKey(ctx()), { source: "stored", login: { path: storedPath(), models: 2 } });
92
+ await assert.rejects(ensureApiKey(ctx(), { backend: "openrouter" }), (error: unknown) => error instanceof TypeSafeIntegrationError && error.code === "configuration" && /OPENROUTER_API_KEY/.test(error.message));
93
+ assert.equal(modelListCalls, 1, "no second verification");
94
+
95
+ process.env.OPENROUTER_API_KEY = "sk-or-0123456789abcdef";
96
+ assert.deepEqual(await ensureApiKey(ctx(), { backend: "openrouter" }), { source: "environment" });
97
+ assert.equal(modelListCalls, 1);
98
+ } finally {
99
+ if (savedOpenRouter === undefined) delete process.env.OPENROUTER_API_KEY; else process.env.OPENROUTER_API_KEY = savedOpenRouter;
100
+ }
101
+ });
@@ -0,0 +1,85 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+ import {
4
+ DEFAULT_MAX_INPUT_BYTES, evaluationSchema, normalizeEvaluationRequest, parseEvaluationRequest, prepareEvaluationRequest, TypeSafeIntegrationError,
5
+ } from "../src/index.js";
6
+
7
+ const hasCode = (code: string) => (error: unknown) => error instanceof TypeSafeIntegrationError && error.code === code;
8
+
9
+ const nearMiss = () => ({
10
+ state: "synthetic",
11
+ questions: {
12
+ team: { type: "choice", instructions: "Which team?", options: { billing: "Charges and payments", other: "None of these" } },
13
+ refund: { type: "noul", instructions: "Is a refund requested?", criteria: "The sender asks for money back" },
14
+ severity: { type: "score", instructions: "How severe?", levels: ["Cosmetic", "Blocking"] },
15
+ picks: { type: "choice", instructions: "Pick one", choices: ["a", "b"] },
16
+ },
17
+ });
18
+
19
+ const criteriaOf = (request: { questions: unknown }, id: string): unknown =>
20
+ (request.questions as Record<string, { criteria?: unknown }>)[id]?.criteria;
21
+
22
+ test("admission accepts the near-miss aliases the agent tool has always accepted", () => {
23
+ const prepared = prepareEvaluationRequest(nearMiss());
24
+ assert.deepEqual(criteriaOf(prepared, "team"), { billing: "Charges and payments", other: "None of these" });
25
+ assert.deepEqual(criteriaOf(prepared, "refund"), { true: "The sender asks for money back" });
26
+ assert.deepEqual(criteriaOf(prepared, "severity"), ["Cosmetic", "Blocking"]);
27
+ assert.deepEqual(criteriaOf(prepared, "picks"), { a: null, b: null });
28
+ });
29
+
30
+ test("admission normalizes before it validates", () => {
31
+ const onlyValidAfterNormalizing = { state: "s", questions: { q: { type: "choice", criteria: ["a", "b"] } } };
32
+ assert.throws(() => parseEvaluationRequest(onlyValidAfterNormalizing), hasCode("validation"));
33
+ assert.doesNotThrow(() => prepareEvaluationRequest(onlyValidAfterNormalizing));
34
+ });
35
+
36
+ test("normalization is idempotent", () => {
37
+ const once = normalizeEvaluationRequest(nearMiss());
38
+ assert.deepEqual(normalizeEvaluationRequest(once), once);
39
+ assert.doesNotThrow(() => prepareEvaluationRequest(once));
40
+ });
41
+
42
+ test("admission enforces the byte budget on the serialized request", () => {
43
+ const request = { state: "🙂".repeat(50), questions: { yes: { type: "noul", instructions: "Is this synthetic?" } } };
44
+ const bytes = Buffer.byteLength(JSON.stringify(request), "utf8");
45
+ assert.ok(bytes < DEFAULT_MAX_INPUT_BYTES);
46
+ assert.throws(() => prepareEvaluationRequest(request, { maxInputBytes: bytes - 1 }), hasCode("validation"));
47
+ assert.doesNotThrow(() => prepareEvaluationRequest(request, { maxInputBytes: bytes }));
48
+ });
49
+
50
+ test("the default byte budget is 64 KiB", () => {
51
+ const oversized = { state: "x".repeat(DEFAULT_MAX_INPUT_BYTES), questions: { yes: { type: "noul", instructions: "?" } } };
52
+ assert.equal(DEFAULT_MAX_INPUT_BYTES, 65_536);
53
+ assert.throws(() => prepareEvaluationRequest(oversized), hasCode("validation"));
54
+ assert.doesNotThrow(() => prepareEvaluationRequest(oversized, { maxInputBytes: DEFAULT_MAX_INPUT_BYTES * 2 }));
55
+ });
56
+
57
+ test("admission still rejects non-JSON state", () => {
58
+ const cycle: Record<string, unknown> = {};
59
+ cycle.self = cycle;
60
+ assert.throws(() => prepareEvaluationRequest({ state: cycle, questions: { yes: { type: "noul", instructions: "?" } } }), hasCode("validation"));
61
+ assert.throws(() => prepareEvaluationRequest({ state: { n: NaN }, questions: { yes: { type: "noul", instructions: "?" } } }), hasCode("validation"));
62
+ });
63
+
64
+ test("every field the agent authors carries a description, so a bare union is not its only guidance", () => {
65
+ // Walk the serialized schema rather than pin TypeBox's layout: collect every `properties` entry by name.
66
+ const authored = new Map<string, number>();
67
+ const undescribed: string[] = [];
68
+ const walk = (node: unknown): void => {
69
+ if (node === null || typeof node !== "object") return;
70
+ if (Array.isArray(node)) { node.forEach(walk); return; }
71
+ const record = node as Record<string, unknown>;
72
+ if (record.properties && typeof record.properties === "object") {
73
+ for (const [name, field] of Object.entries(record.properties as Record<string, Record<string, unknown>>)) {
74
+ authored.set(name, (authored.get(name) ?? 0) + 1);
75
+ if (typeof field.description !== "string" || field.description.length === 0) undescribed.push(name);
76
+ }
77
+ }
78
+ Object.values(record).forEach(walk);
79
+ };
80
+ walk(JSON.parse(JSON.stringify(evaluationSchema)));
81
+ assert.equal(authored.get("type"), 3, "the schema must still offer noul, choice, and score");
82
+ for (const name of ["state", "questions", "model", "instructions", "criteria"]) assert.ok(authored.has(name), `${name} is still authored`);
83
+ // `true` and `false` inside noul criteria are the only fields whose parent already explains them.
84
+ assert.deepEqual(undescribed.filter(name => name !== "true" && name !== "false"), [], "every authored field must say what it means");
85
+ });