gentle-pi 3.6.0 → 4.0.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 (320) hide show
  1. package/README.md +37 -6
  2. package/assets/agents/gentle-ai-explore.md +4 -4
  3. package/assets/agents/gentle-ai-verify.md +6 -4
  4. package/assets/agents/gentle-ai-worker.md +7 -9
  5. package/assets/orchestrator-delegation.md +40 -41
  6. package/assets/orchestrator-memory.md +1 -22
  7. package/assets/orchestrator-skills.md +1 -1
  8. package/assets/orchestrator.md +12 -24
  9. package/assets/support/strict-tdd-verify.md +4 -266
  10. package/assets/support/strict-tdd.md +8 -360
  11. package/bin/gentle-shell.mjs +254 -29
  12. package/docs/delegated-verification.md +26 -1
  13. package/docs/gentle-agents-activity.md +24 -0
  14. package/docs/gentle-shell.md +95 -30
  15. package/docs/native-authority-architecture.md +2 -2
  16. package/docs/prompt-history.md +280 -0
  17. package/docs/readme-reference.md +117 -235
  18. package/docs/telemetry.md +1 -1
  19. package/docs/yolo-mode.md +86 -0
  20. package/extensions/child-context.ts +26 -0
  21. package/extensions/child-safety.ts +23 -0
  22. package/extensions/gentle-agents.ts +509 -275
  23. package/extensions/gentle-ai.ts +781 -683
  24. package/extensions/gentle-shell.ts +1375 -88
  25. package/extensions/gentle-stats.ts +101 -0
  26. package/extensions/gentle-todo.ts +18 -7
  27. package/extensions/history/atomic-write.ts +38 -0
  28. package/extensions/history/hide-prompts.ts +183 -0
  29. package/extensions/history/index.ts +1419 -0
  30. package/extensions/history/load-shared-history.ts +39 -0
  31. package/extensions/history/selector-helpers.ts +538 -0
  32. package/extensions/history/session-scan.ts +233 -0
  33. package/extensions/history/store.ts +1119 -0
  34. package/extensions/nan-provider.ts +6 -0
  35. package/extensions/quiet-tools.ts +179 -87
  36. package/extensions/resume-hint.ts +60 -0
  37. package/extensions/skill-registry.ts +16 -12
  38. package/extensions/startup-banner.ts +60 -31
  39. package/lib/agent-assets.ts +604 -0
  40. package/lib/agent-profile-pin.ts +12 -0
  41. package/lib/agents-message-delivery.ts +181 -0
  42. package/lib/agents-protocol.ts +60 -0
  43. package/lib/agents-runner.ts +105 -98
  44. package/lib/agents-view.ts +26 -3
  45. package/lib/agents-widget.ts +16 -9
  46. package/lib/append-system-prompt.ts +21 -0
  47. package/lib/bounded-writer-admission.ts +147 -0
  48. package/lib/card-style-policy.ts +60 -0
  49. package/lib/child-context-files.ts +166 -0
  50. package/lib/codemode-renderer.ts +185 -0
  51. package/lib/command-palette-catalog.ts +3 -9
  52. package/lib/command-palette.ts +25 -14
  53. package/lib/destructive-command-guard.ts +144 -0
  54. package/lib/foreign-target-grants.ts +32 -0
  55. package/lib/gentle-ai-elapsed-store.ts +87 -0
  56. package/lib/gentle-ai-renderer.ts +196 -39
  57. package/lib/gentle-shell-launcher.ts +24 -13
  58. package/lib/gentle-shell-resume-hint.ts +176 -0
  59. package/lib/history-capture-policy.ts +95 -0
  60. package/lib/model-routing-authority.ts +5 -1
  61. package/lib/nan-provider.ts +227 -0
  62. package/lib/native-review-cli.ts +53 -108
  63. package/lib/odd-phase-inference.ts +231 -0
  64. package/lib/odd-phase.ts +141 -0
  65. package/lib/overlay-repaint.ts +26 -0
  66. package/lib/pi-tui-keys.ts +53 -0
  67. package/lib/review-candidate-view-owner.ts +67 -17
  68. package/lib/review-candidate-view.ts +112 -25
  69. package/lib/review-reminder-receipt.ts +48 -8
  70. package/lib/review-risk-assessment.ts +156 -11
  71. package/lib/review-sidebar-state.ts +223 -0
  72. package/lib/selection-engine.ts +515 -0
  73. package/lib/session-change-capture.ts +10 -1
  74. package/lib/session-messaging-grants.ts +135 -0
  75. package/lib/session-worktree-registry.ts +14 -2
  76. package/lib/shell-bar.ts +179 -24
  77. package/lib/shell-card.ts +287 -18
  78. package/lib/shell-changes-view.ts +2 -1
  79. package/lib/shell-prompt.ts +102 -6
  80. package/lib/shell-sidebar-layout.ts +78 -14
  81. package/lib/shell-sidebar.ts +15 -1
  82. package/lib/shell-todo.ts +23 -13
  83. package/lib/shell-usage-view.ts +9 -4
  84. package/lib/shell-usage.ts +66 -10
  85. package/lib/stats-collector.ts +381 -0
  86. package/lib/stats-view.ts +431 -0
  87. package/lib/theme-customization.ts +52 -0
  88. package/lib/vim-editor-adapter.ts +379 -0
  89. package/lib/vim-normal-engine.ts +154 -0
  90. package/lib/vim-operator-engine.ts +416 -0
  91. package/lib/vim-policy.ts +49 -0
  92. package/lib/vim-visual-engine.ts +107 -0
  93. package/lib/visual-customization-policy.ts +108 -0
  94. package/lib/visual-customize-view.ts +330 -0
  95. package/lib/visual-profiles.ts +228 -0
  96. package/lib/yolo-session-policy.ts +240 -0
  97. package/package.json +20 -8
  98. package/runtime/gentle-shell-launcher.mjs +23 -12
  99. package/runtime/gentle-shell-resume-hint.mjs +177 -0
  100. package/runtime/native-review-cli.mjs +53 -108
  101. package/runtime/review-risk-assessment.mjs +154 -9
  102. package/scripts/build-runtime-modules.mjs +1 -0
  103. package/scripts/gentle-ai-installer.mjs +14 -13
  104. package/scripts/mirror-odd-routing.mjs +2 -2
  105. package/scripts/run-test-suite.mjs +76 -0
  106. package/scripts/test-packed-runner.mjs +31 -14
  107. package/scripts/verify-package-files.mjs +11 -22
  108. package/skills/branch-pr/SKILL.md +24 -52
  109. package/skills/chained-pr/SKILL.md +31 -15
  110. package/skills/chained-pr/references/chaining-details.md +31 -20
  111. package/skills/gentle-ai/SKILL.md +9 -15
  112. package/skills/issue-creation/SKILL.md +8 -2
  113. package/skills/issue-creation/references/delegated-workflow-actions.md +19 -0
  114. package/skills/work-unit-commits/SKILL.md +4 -3
  115. package/tests/agent-profiles.test.ts +18 -0
  116. package/tests/agents-fake-child.ts +2 -2
  117. package/tests/agents-message-delivery.test.ts +106 -0
  118. package/tests/agents-protocol.test.ts +40 -0
  119. package/tests/agents-runner.test.ts +400 -89
  120. package/tests/agents-view-thread-identity.test.ts +169 -0
  121. package/tests/agents-view.test.ts +8 -2
  122. package/tests/agents-widget.test.ts +154 -15
  123. package/tests/append-system-prompt-route.test.ts +160 -0
  124. package/tests/append-system-prompt.test.ts +46 -0
  125. package/tests/artifact-language.test.ts +19 -213
  126. package/tests/ask-user-question.test.ts +44 -1
  127. package/tests/asset-installation-runtime.test.ts +5 -16
  128. package/tests/autonomous-guard.test.ts +69 -1
  129. package/tests/bounded-writer-admission.test.ts +95 -0
  130. package/tests/branch-pr-skill.test.ts +43 -0
  131. package/tests/card-style-policy.test.ts +55 -0
  132. package/tests/chained-pr-skill.test.ts +124 -0
  133. package/tests/child-context-files.test.ts +255 -0
  134. package/tests/child-safety.test.ts +82 -0
  135. package/tests/codemode-rendering.test.ts +491 -0
  136. package/tests/command-palette.test.ts +39 -3
  137. package/tests/delegated-key-learnings-contract.test.ts +0 -76
  138. package/tests/destructive-command-guard.test.ts +84 -0
  139. package/tests/devbinary/native-review-parity.devtest.ts +170 -2
  140. package/tests/devbinary/non-git-subagent-bootstrap.devtest.ts +193 -0
  141. package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-28T10-00-00-000Z_aaa.jsonl +7 -0
  142. package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-29T23-00-00-000Z_bbb.jsonl +3 -0
  143. package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-30T08-00-00-000Z_ddd.jsonl +2 -0
  144. package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-30T09-00-00-000Z_eee.jsonl +3 -0
  145. package/tests/fixtures/stats/sessions/--work-alpha--/run-1/session.jsonl +2 -0
  146. package/tests/fixtures/stats/sessions/--work-beta--/2026-09-01T12-00-00-000Z_ccc.jsonl +2 -0
  147. package/tests/fixtures/stats/user-pi/sessions/--work-alpha--/2026-09-28T10-00-00-000Z_aaa.jsonl +2 -0
  148. package/tests/fixtures/stats/user-pi/sessions/--work-alpha--/2026-09-29T23-00-00-000Z_bbb.jsonl +4 -0
  149. package/tests/fixtures/stats/user-pi/sessions/--work-gamma--/2026-09-20T09-00-00-000Z_fff.jsonl +3 -0
  150. package/tests/foreign-target-grants.test.ts +58 -0
  151. package/tests/generic-agent-tools.test.ts +54 -0
  152. package/tests/gentle-agents.test.ts +1411 -256
  153. package/tests/gentle-ai-binary.test.ts +3 -3
  154. package/tests/gentle-ai-elapsed-store.test.ts +68 -0
  155. package/tests/gentle-ai-installer.test.ts +68 -54
  156. package/tests/gentle-ai-renderer.test.ts +487 -8
  157. package/tests/gentle-ai.test.ts +288 -65
  158. package/tests/gentle-card-text.ts +2 -1
  159. package/tests/gentle-shell-bin.test.ts +651 -114
  160. package/tests/gentle-shell-launcher.test.ts +99 -52
  161. package/tests/gentle-shell-resume-hint.test.ts +270 -0
  162. package/tests/gentle-shell.test.ts +3839 -187
  163. package/tests/gentle-stats.test.ts +152 -0
  164. package/tests/gentle-theme.test.ts +4 -1
  165. package/tests/gentle-todo.test.ts +80 -8
  166. package/tests/history-atomic-write.test.ts +57 -0
  167. package/tests/history-capture-policy.test.ts +102 -0
  168. package/tests/history-command-registration.test.ts +164 -0
  169. package/tests/history-dedupe-entries.test.ts +123 -0
  170. package/tests/history-delete-backfill.test.ts +190 -0
  171. package/tests/history-delete-confirm.test.ts +460 -0
  172. package/tests/history-dispatch.test.ts +180 -0
  173. package/tests/history-drain-hidden.test.ts +110 -0
  174. package/tests/history-drain-order.test.ts +98 -0
  175. package/tests/history-expanded-globals.test.ts +62 -0
  176. package/tests/history-gc.test.ts +832 -0
  177. package/tests/history-header-layout.test.ts +265 -0
  178. package/tests/history-hide-prompts.test.ts +275 -0
  179. package/tests/history-lazy-windowing.test.ts +508 -0
  180. package/tests/history-legacy-migrate-v2.test.ts +297 -0
  181. package/tests/history-load-shared-history.test.ts +53 -0
  182. package/tests/history-max-results-cap.test.ts +76 -0
  183. package/tests/history-multi-reader.test.ts +203 -0
  184. package/tests/history-off-path.test.ts +170 -0
  185. package/tests/history-openflow-integration.test.ts +173 -0
  186. package/tests/history-overlay-margin.test.ts +326 -0
  187. package/tests/history-preview-layout.test.ts +93 -0
  188. package/tests/history-registry.test.ts +143 -0
  189. package/tests/history-scope-delete.test.ts +411 -0
  190. package/tests/history-search-caret-keys.test.ts +142 -0
  191. package/tests/history-seed-bootstrap.test.ts +170 -0
  192. package/tests/history-seed-regen.test.ts +129 -0
  193. package/tests/history-selector-windowing.test.ts +94 -0
  194. package/tests/history-session-scan-directory.test.ts +87 -0
  195. package/tests/history-session-scan-extract.test.ts +583 -0
  196. package/tests/history-session-writer.test.ts +351 -0
  197. package/tests/history-store-paths.test.ts +79 -0
  198. package/tests/history-tombstone-exact.test.ts +139 -0
  199. package/tests/history-wheel-mouse.test.ts +242 -0
  200. package/tests/inprocess-reviewer.test.ts +29 -19
  201. package/tests/issue-creation-skill.test.ts +61 -0
  202. package/tests/model-routing-authority.test.ts +16 -0
  203. package/tests/nan-provider.test.ts +471 -0
  204. package/tests/native-review-capability-contract.test.ts +13 -1
  205. package/tests/native-review-cli.test.ts +6 -120
  206. package/tests/native-review-parity-runtime.test.ts +100 -3
  207. package/tests/odd-integration.test.ts +67 -0
  208. package/tests/odd-phase-inference.test.ts +213 -0
  209. package/tests/odd-phase-loader.test.ts +253 -0
  210. package/tests/odd-phase.test.ts +307 -0
  211. package/tests/odd-routing-canonical-ratchet.test.ts +11 -6
  212. package/tests/odd-routing-contract.test.ts +86 -35
  213. package/tests/orchestrator-budget.test.ts +14 -39
  214. package/tests/orchestrator-rdd-ownership.test.ts +3 -3
  215. package/tests/overlay-repaint.test.ts +74 -0
  216. package/tests/package-manifest.test.ts +252 -115
  217. package/tests/packed-runner-owned-path.test.ts +46 -0
  218. package/tests/persona-single-channel.test.ts +6 -6
  219. package/tests/provider-defect-handoff.test.ts +3 -11
  220. package/tests/quiet-bash-runtime.test.ts +76 -0
  221. package/tests/quiet-tool-rendering.test.ts +409 -184
  222. package/tests/rdd-aware-verification-contract.test.ts +76 -1
  223. package/tests/rdd-status-line.test.ts +9 -4
  224. package/tests/resume-hint-extension.test.ts +122 -0
  225. package/tests/review-agent-end-preflight.test.ts +176 -12
  226. package/tests/review-candidate-owner-retry.test.ts +22 -1
  227. package/tests/review-candidate-view.test.ts +298 -0
  228. package/tests/review-contract-prompt.test.ts +108 -43
  229. package/tests/review-controller-lock-status.test.ts +0 -1
  230. package/tests/review-controller-native-routing.test.ts +611 -5
  231. package/tests/review-controller-workspace-root.test.ts +163 -4
  232. package/tests/review-host-relay-routing.test.ts +338 -2
  233. package/tests/review-integration-v2-forward.test.ts +200 -0
  234. package/tests/review-ledger-contract.test.ts +10 -34
  235. package/tests/review-reminder-receipt.test.ts +47 -1
  236. package/tests/review-risk-assessment.test.ts +498 -6
  237. package/tests/review-sidebar-state.test.ts +402 -0
  238. package/tests/run-test-suite.test.ts +124 -0
  239. package/tests/runtime-harness.mjs +145 -786
  240. package/tests/runtime-metrics-children.test.ts +16 -23
  241. package/tests/selection-engine.test.ts +421 -0
  242. package/tests/session-change-capture.test.ts +12 -1
  243. package/tests/session-messaging-grants.test.ts +255 -0
  244. package/tests/session-worktree-registry.test.ts +77 -0
  245. package/tests/shell-bar.test.ts +382 -1
  246. package/tests/shell-card.test.ts +353 -1
  247. package/tests/shell-changes-view.test.ts +52 -0
  248. package/tests/shell-prompt.test.ts +94 -2
  249. package/tests/shell-sidebar-layout.test.ts +325 -21
  250. package/tests/shell-sidebar-scroll-benchmark.test.ts +255 -0
  251. package/tests/shell-todo.test.ts +87 -1
  252. package/tests/shell-usage-view.test.ts +27 -0
  253. package/tests/shell-usage.test.ts +73 -0
  254. package/tests/skill-registry.test.ts +50 -1
  255. package/tests/startup-banner.test.ts +130 -2
  256. package/tests/stats-collector.test.ts +195 -0
  257. package/tests/stats-view.test.ts +202 -0
  258. package/tests/telemetry-trigger.test.ts +81 -20
  259. package/tests/theme-customization.test.ts +72 -0
  260. package/tests/vim-editor-adapter-host-resolution.test.ts +37 -0
  261. package/tests/vim-editor-adapter.test.ts +804 -0
  262. package/tests/vim-normal-engine.test.ts +101 -0
  263. package/tests/vim-operator-engine.test.ts +215 -0
  264. package/tests/vim-policy.test.ts +19 -0
  265. package/tests/vim-visual-engine.test.ts +52 -0
  266. package/tests/visual-customization-policy.test.ts +110 -0
  267. package/tests/visual-customize-view.test.ts +418 -0
  268. package/tests/visual-profiles.test.ts +87 -0
  269. package/tests/yolo-customize.test.ts +256 -0
  270. package/tests/yolo-mode-runtime.test.ts +161 -0
  271. package/tests/yolo-mode.test.ts +261 -0
  272. package/tests/yolo-session-policy.test.ts +59 -0
  273. package/themes/Gentle.json +2 -1
  274. package/themes/Gentleman-Cute.json +2 -1
  275. package/themes/Gentleman-Sexy.json +2 -1
  276. package/assets/agents/sdd-apply.md +0 -159
  277. package/assets/agents/sdd-archive.md +0 -228
  278. package/assets/agents/sdd-design.md +0 -49
  279. package/assets/agents/sdd-explore.md +0 -48
  280. package/assets/agents/sdd-init.md +0 -56
  281. package/assets/agents/sdd-onboard.md +0 -52
  282. package/assets/agents/sdd-proposal.md +0 -64
  283. package/assets/agents/sdd-remediate.md +0 -37
  284. package/assets/agents/sdd-research.md +0 -49
  285. package/assets/agents/sdd-spec.md +0 -192
  286. package/assets/agents/sdd-status.md +0 -54
  287. package/assets/agents/sdd-tasks.md +0 -108
  288. package/assets/agents/sdd-verify.md +0 -124
  289. package/assets/chains/sdd-full.chain.md +0 -83
  290. package/assets/chains/sdd-plan.chain.md +0 -56
  291. package/assets/chains/sdd-verify.chain.md +0 -43
  292. package/assets/sdd-orchestrator-workflow.md +0 -319
  293. package/assets/support/sdd-status-contract.md +0 -77
  294. package/docs/assets/diagrams/sdd-cycle.svg +0 -14
  295. package/extensions/sdd-init.ts +0 -816
  296. package/lib/openspec-deltas.ts +0 -156
  297. package/lib/sdd-preflight.ts +0 -1066
  298. package/lib/sdd-research-capabilities.ts +0 -94
  299. package/lib/sdd-status.ts +0 -26
  300. package/tests/fixtures/legacy/sdd-research-v2.5.0.md +0 -54
  301. package/tests/fixtures/native-review-cli/v2.1.3/bind-sdd.json +0 -25
  302. package/tests/fixtures/v0.10.7/assets/agents/sdd-apply.md +0 -132
  303. package/tests/openspec-deltas.test.ts +0 -209
  304. package/tests/sdd-agent-tools.test.ts +0 -156
  305. package/tests/sdd-archive-replay.test.ts +0 -82
  306. package/tests/sdd-classical-continuation.test.ts +0 -74
  307. package/tests/sdd-execution-routing-contract.test.ts +0 -44
  308. package/tests/sdd-managed-runtime-settlement.test.ts +0 -155
  309. package/tests/sdd-native-managed-uptake.test.ts +0 -243
  310. package/tests/sdd-no-attempts-contract.test.ts +0 -15
  311. package/tests/sdd-odd-integration.test.ts +0 -33
  312. package/tests/sdd-optional-research.test.ts +0 -124
  313. package/tests/sdd-planning-routing-contract.test.ts +0 -45
  314. package/tests/sdd-preflight-rpc-input.test.ts +0 -125
  315. package/tests/sdd-preflight.test.ts +0 -541
  316. package/tests/sdd-research-capabilities.test.ts +0 -114
  317. package/tests/sdd-research-live.test.ts +0 -241
  318. package/tests/sdd-selection-transport.test.ts +0 -653
  319. package/tests/sdd-status.test.ts +0 -9
  320. package/tests/sdd-task-truth.test.ts +0 -43
@@ -0,0 +1,471 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import type { RefreshModelsContext } from "@earendil-works/pi-ai";
4
+ import { clampThinkingLevel, createModels, getSupportedThinkingLevels, InMemoryCredentialStore } from "@earendil-works/pi-ai";
5
+ import type { Provider } from "@earendil-works/pi-ai";
6
+ import nanProviderExtension from "../extensions/nan-provider.ts";
7
+ import { createNanProviderConfig as createNativeProvider, NAN_PROVIDER_BASE_URL, NAN_PROVIDER_ID } from "../lib/nan-provider.ts";
8
+
9
+ // Keep catalog assertions independent of the native refresh's void return contract.
10
+ function createNanProviderConfig(options: Parameters<typeof createNativeProvider>[0] = {}) {
11
+ const provider = createNativeProvider(options);
12
+ return {
13
+ ...provider,
14
+ models: provider.getModels(),
15
+ async refreshModels(context: RefreshModelsContext) {
16
+ await provider.refreshModels!(context);
17
+ return provider.getModels();
18
+ },
19
+ };
20
+ }
21
+
22
+ const DOCUMENTED_CHAT_IDS = [
23
+ "glm5.3", "deepseek-v4-flash", "glm5.3-flash", "qwen3.8-flash",
24
+ "mimo-v2.6-flash", "gemma4", "qwen3.6",
25
+ ];
26
+
27
+ function jsonResponse(body: unknown, status = 200): Response {
28
+ return new Response(JSON.stringify(body), {
29
+ status,
30
+ headers: { "content-type": "application/json" },
31
+ });
32
+ }
33
+
34
+ function refreshContext(credential?: RefreshModelsContext["credential"]): RefreshModelsContext {
35
+ return {
36
+ credential,
37
+ stored: undefined,
38
+ allowNetwork: true,
39
+ signal: new AbortController().signal,
40
+ async publish(publication) {
41
+ publication.update?.();
42
+ return true;
43
+ },
44
+ };
45
+ }
46
+
47
+ test("native login rejects an explicitly submitted empty key", async () => {
48
+ const provider = createNativeProvider();
49
+ await assert.rejects(async () => provider.auth.apiKey!.login!({
50
+ signal: new AbortController().signal,
51
+ prompt: async () => "",
52
+ notify() {},
53
+ }), /non-empty/);
54
+ });
55
+
56
+ test("extension registers NaN with Pi's OpenAI-compatible and native API-key configuration", () => {
57
+ let registeredConfig: Provider | undefined;
58
+ nanProviderExtension({
59
+ registerProvider(provider: Provider) { registeredConfig = provider; },
60
+ } as never);
61
+ assert.equal(registeredConfig?.id, NAN_PROVIDER_ID);
62
+ assert.equal(registeredConfig?.name, "NaN");
63
+ assert.equal(registeredConfig?.baseUrl, NAN_PROVIDER_BASE_URL);
64
+ assert.equal(registeredConfig?.getModels()[0]?.api, "openai-completions");
65
+ assert.equal(typeof registeredConfig?.auth.apiKey?.login, "function");
66
+ assert.equal(typeof registeredConfig?.streamSimple, "function");
67
+ assert.equal(typeof registeredConfig?.refreshModels, "function");
68
+ });
69
+
70
+ test("native login awaits input, trims keys, and rejects whitespace or cancellation", async () => {
71
+ const login = createNativeProvider().auth.apiKey!.login!;
72
+ const controller = new AbortController();
73
+ let submit!: (key: string) => void;
74
+ let prompted = false;
75
+ let finished = false;
76
+ const pending = login({ signal: controller.signal, notify() {}, prompt: async (prompt) => {
77
+ prompted = true;
78
+ assert.equal(prompt.type, "secret");
79
+ assert.equal(prompt.signal, controller.signal);
80
+ return new Promise<string>((resolve) => { submit = resolve; });
81
+ } }).then((result) => { finished = true; return result; });
82
+ assert.equal(prompted, true);
83
+ assert.equal(finished, false);
84
+ submit(" synthetic-key \t");
85
+ assert.deepEqual(await pending, { type: "api_key", key: "synthetic-key" });
86
+ await assert.rejects(login({ signal: controller.signal, notify() {}, prompt: async () => " \t " }), /non-empty/);
87
+ await assert.rejects(login({ signal: controller.signal, notify() {}, prompt: async () => { throw new Error("cancelled"); } }), /cancelled/);
88
+ await assert.rejects(login({ signal: controller.signal, notify() {}, prompt: async () => {
89
+ controller.abort(); return "synthetic-key";
90
+ } }), { name: "AbortError" });
91
+ let calls = 0;
92
+ await assert.rejects(login({ signal: controller.signal, notify() {}, prompt: async () => { calls++; return "key"; } }), { name: "AbortError" });
93
+ assert.equal(calls, 0);
94
+ });
95
+
96
+ test("native Models login never persists blank credentials and uses saved auth for requests", async () => {
97
+ const credentials = new InMemoryCredentialStore();
98
+ const models = createModels({ credentials, authContext: {
99
+ env: async () => "env-key", fileExists: async () => false,
100
+ } });
101
+ models.setProvider(createNativeProvider());
102
+ const interaction = { prompt: async () => "", notify() {} };
103
+ await assert.rejects(models.login("nan", "api_key", interaction), /non-empty/);
104
+ assert.equal(await credentials.read("nan"), undefined);
105
+ await models.login("nan", "api_key", { ...interaction, prompt: async () => " saved-key " });
106
+ assert.deepEqual((await models.getAuth("nan"))?.auth, { apiKey: "saved-key" });
107
+ await assert.rejects(models.login("nan", "api_key", interaction), /non-empty/);
108
+ assert.deepEqual(await credentials.read("nan"), { type: "api_key", key: "saved-key" });
109
+ await models.logout("nan");
110
+ assert.deepEqual((await models.getAuth("nan"))?.auth, { apiKey: "env-key" });
111
+ });
112
+
113
+ test("native auth resolves stored keys before environment and rejects empty configuration", async () => {
114
+ const resolve = createNativeProvider().auth.apiKey!.resolve;
115
+ let envCalls = 0;
116
+ const input = {
117
+ ctx: { async env(name: string) { envCalls++; assert.equal(name, "NAN_API_KEY"); return " env-key "; }, async fileExists() { return false; } },
118
+ signal: new AbortController().signal,
119
+ };
120
+ assert.deepEqual(await resolve({ ...input, credential: { type: "api_key", key: " stored-key " } }), { auth: { apiKey: "stored-key" }, source: "API key" });
121
+ assert.equal(envCalls, 0);
122
+ assert.deepEqual(await resolve({ ...input, credential: { type: "api_key", key: " " } }), { auth: { apiKey: "env-key" }, source: "NAN_API_KEY" });
123
+ assert.equal(await resolve({ ...input, ctx: { ...input.ctx, env: async () => " " } }), undefined);
124
+ });
125
+
126
+ test("native publication exposes the new catalog synchronously, including an empty catalog", async () => {
127
+ for (const ids of [["glm5.3"], []]) {
128
+ const provider = createNativeProvider({
129
+ fetchImpl: async () => jsonResponse({ data: ids.map((id) => ({ id })) }),
130
+ });
131
+ let publications = 0;
132
+ await provider.refreshModels!({
133
+ ...refreshContext({ type: "api_key", key: "snapshot-key" }),
134
+ async publish(publication) {
135
+ publications++;
136
+ publication.update?.();
137
+ assert.deepEqual(provider.getModels().map((model) => model.id), ids);
138
+ return true;
139
+ },
140
+ });
141
+ assert.equal(publications, 1);
142
+ }
143
+ });
144
+
145
+ test("stale and aborted publication updates cannot replace the current key's catalog", async () => {
146
+ for (const mode of ["changed-key", "aborted"] as const) {
147
+ const provider = createNativeProvider({
148
+ fetchImpl: async () => jsonResponse({ data: [{ id: "glm5.3" }] }),
149
+ });
150
+ const controller = new AbortController();
151
+ await provider.refreshModels!({
152
+ ...refreshContext({ type: "api_key", key: "old-key" }), signal: controller.signal,
153
+ async publish(publication) {
154
+ if (mode === "changed-key") {
155
+ await provider.refreshModels!({
156
+ ...refreshContext({ type: "api_key", key: "new-key" }), allowNetwork: false,
157
+ publish: async () => { assert.fail("offline refresh must not publish"); },
158
+ });
159
+ } else controller.abort();
160
+ publication.update?.();
161
+ assert.deepEqual(provider.getModels().map((model) => model.id), DOCUMENTED_CHAT_IDS);
162
+ return false;
163
+ },
164
+ });
165
+ assert.deepEqual(provider.getModels().map((model) => model.id), DOCUMENTED_CHAT_IDS);
166
+ }
167
+ });
168
+
169
+ test("offline and already-aborted refreshes invalidate keys without publication", async () => {
170
+ const provider = createNativeProvider({ fetchImpl: async () => jsonResponse({ data: [{ id: "glm5.3" }] }) });
171
+ await provider.refreshModels!(refreshContext({ type: "api_key", key: "first" }));
172
+ for (const mode of ["offline", "aborted"] as const) {
173
+ const controller = new AbortController();
174
+ if (mode === "aborted") controller.abort();
175
+ await provider.refreshModels!({
176
+ ...refreshContext({ type: "api_key", key: mode }),
177
+ allowNetwork: mode !== "offline", signal: controller.signal,
178
+ publish: async () => { assert.fail("refresh must not publish"); },
179
+ });
180
+ assert.deepEqual(provider.getModels().map((model) => model.id), DOCUMENTED_CHAT_IDS);
181
+ }
182
+ });
183
+
184
+ test("initial catalog contains all seven documented chat models with configured output caps", () => {
185
+ const models = createNanProviderConfig().models;
186
+ assert.deepEqual(models.map((model) => model.id), DOCUMENTED_CHAT_IDS);
187
+ const model = models.find((model) => model.id === "deepseek-v4-flash");
188
+ assert.equal(model?.id, "deepseek-v4-flash");
189
+ assert.equal(model?.api, "openai-completions");
190
+ assert.equal(model?.reasoning, true);
191
+ assert.deepEqual(model?.input, ["text", "image"]);
192
+ assert.equal(model?.contextWindow, 1_000_000);
193
+ assert.equal(models.length, 7);
194
+ assert.equal(model?.maxTokens, 16_384);
195
+ assert.deepEqual(model?.cost, { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 });
196
+ });
197
+
198
+ test("adjustable models map Pi thinking levels to accepted NaN efforts", () => {
199
+ const models = createNativeProvider().getModels();
200
+ for (const id of ["glm5.3", "glm5.3-flash"]) {
201
+ const model = models.find((model) => model.id === id);
202
+ assert.ok(model);
203
+ assert.deepEqual(model.thinkingLevelMap, { off: null, minimal: "low", xhigh: "max", max: "max" });
204
+ assert.deepEqual(getSupportedThinkingLevels(model), ["minimal", "low", "medium", "high", "xhigh", "max"]);
205
+ assert.equal(clampThinkingLevel(model, "off"), "minimal");
206
+ }
207
+ for (const id of ["qwen3.6", "gemma4"]) {
208
+ const model = models.find((model) => model.id === id);
209
+ assert.ok(model);
210
+ assert.deepEqual(model.thinkingLevelMap, { off: "none", xhigh: "max", max: "max" });
211
+ assert.deepEqual(getSupportedThinkingLevels(model), ["off", "minimal", "low", "medium", "high", "xhigh", "max"]);
212
+ assert.equal(clampThinkingLevel(model, "off"), "off");
213
+ }
214
+ });
215
+
216
+ test("fixed-depth models expose only medium and clamp unsupported thinking levels", () => {
217
+ const models = createNativeProvider().getModels();
218
+ for (const id of ["deepseek-v4-flash", "qwen3.8-flash", "mimo-v2.6-flash"]) {
219
+ const model = models.find((model) => model.id === id);
220
+ assert.ok(model);
221
+ assert.deepEqual(model.thinkingLevelMap, {
222
+ off: null, minimal: null, low: null, high: null, xhigh: null, max: null,
223
+ });
224
+ assert.deepEqual(getSupportedThinkingLevels(model), ["medium"]);
225
+ for (const level of ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const) {
226
+ assert.equal(clampThinkingLevel(model, level), "medium");
227
+ }
228
+ }
229
+ });
230
+
231
+ test("thinking maps are isolated across snapshots, providers, and live catalog refreshes", async () => {
232
+ const provider = createNativeProvider({
233
+ fetchImpl: async () => jsonResponse({ data: DOCUMENTED_CHAT_IDS.map((id) => ({ id })) }),
234
+ });
235
+ const baseline = provider.getModels();
236
+ for (const model of provider.getModels()) {
237
+ assert.ok(model.thinkingLevelMap);
238
+ model.thinkingLevelMap.off = "mutated";
239
+ model.thinkingLevelMap.medium = null;
240
+ }
241
+ assert.deepEqual(provider.getModels(), baseline);
242
+ assert.deepEqual(createNativeProvider().getModels(), baseline);
243
+ await provider.refreshModels!(refreshContext({ type: "api_key", key: "map-key" }));
244
+ assert.deepEqual(provider.getModels(), baseline);
245
+ for (const model of provider.getModels()) {
246
+ assert.ok(model.thinkingLevelMap);
247
+ model.thinkingLevelMap.xhigh = "mutated";
248
+ }
249
+ assert.deepEqual(provider.getModels(), baseline);
250
+ await provider.refreshModels!(refreshContext({ type: "api_key", key: "map-key" }));
251
+ assert.deepEqual(provider.getModels(), baseline);
252
+ });
253
+
254
+ test("catalog snapshots cannot mutate the offline baseline or another provider", async () => {
255
+ const provider = createNativeProvider();
256
+ const snapshot = [...provider.getModels()];
257
+ snapshot[0].id = "mutated";
258
+ snapshot[0].input.push("image");
259
+ snapshot[0].cost.input = 99;
260
+ snapshot.pop();
261
+ const fresh = provider.getModels();
262
+ assert.deepEqual(fresh.map((model) => model.id), DOCUMENTED_CHAT_IDS);
263
+ assert.deepEqual(fresh[0].input, ["text", "image"]);
264
+ assert.equal(fresh[0].cost.input, 0);
265
+ assert.deepEqual(createNativeProvider().getModels(), fresh);
266
+ await provider.refreshModels!({
267
+ ...refreshContext({ type: "api_key", key: "changed-key" }), allowNetwork: false,
268
+ });
269
+ assert.deepEqual(provider.getModels(), fresh);
270
+ });
271
+
272
+ test("live discovery uses the key-scoped endpoint and replaces the fallback with listed models", async () => {
273
+ let request: { url: string; init?: RequestInit } | undefined;
274
+ const config = createNanProviderConfig({
275
+ fetchImpl: async (input, init) => {
276
+ request = { url: String(input), init };
277
+ return jsonResponse({ data: [{ id: " glm5.3 " }, { id: "glm5.3" }, { id: "unknown-chat" }, { id: "embedding" }, { id: "image" }, { id: "speech" }, { id: "rerank" }] });
278
+ },
279
+ });
280
+ const fallbackId = "deepseek-v4-flash";
281
+ assert.ok(fallbackId);
282
+
283
+ const models = await config.refreshModels?.(refreshContext({ type: "api_key", key: "test-secret" }));
284
+
285
+ assert.equal(request?.url, `${NAN_PROVIDER_BASE_URL}/models`);
286
+ assert.equal(request?.init?.method, "GET");
287
+ assert.equal(new Headers(request?.init?.headers).get("authorization"), "Bearer test-secret");
288
+ assert.equal(request?.init?.redirect, "error");
289
+ assert.deepEqual(models?.map((model) => model.id), ["glm5.3"]);
290
+ assert.ok(!models?.some((model) => model.id === fallbackId));
291
+
292
+ const known = models?.[0];
293
+ assert.equal(known?.api, "openai-completions");
294
+ assert.equal(known?.reasoning, true);
295
+ assert.deepEqual(known?.input, ["text", "image"]);
296
+ assert.equal(known?.contextWindow, 1_000_000);
297
+ assert.equal(known?.maxTokens, 32_768);
298
+ assert.deepEqual(known?.cost, { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 });
299
+ });
300
+
301
+ test("known chat models retain documented capabilities without advertising audio", async () => {
302
+ const expected = [
303
+ ["glm5.3", 1_000_000, ["text", "image"], 32_768],
304
+ ["deepseek-v4-flash", 1_000_000, ["text", "image"], 16_384],
305
+ ["glm5.3-flash", 1_000_000, ["text", "image"], 32_768],
306
+ ["qwen3.8-flash", 1_048_576, ["text", "image"], 131_000],
307
+ ["mimo-v2.6-flash", 1_000_000, ["text", "image"], 32_768],
308
+ ["gemma4", 262_000, ["text", "image"], 65_536],
309
+ ["qwen3.6", 262_000, ["text", "image"], 65_536],
310
+ ] as const;
311
+ const config = createNanProviderConfig({
312
+ fetchImpl: async () => jsonResponse({ data: expected.map(([id]) => ({ id })) }),
313
+ });
314
+ const models = await config.refreshModels?.(refreshContext({ type: "api_key", key: "test-key" }));
315
+ assert.equal(models?.length, expected.length);
316
+ for (const [index, [id, contextWindow, input, maxTokens]] of expected.entries()) {
317
+ const model = models?.[index];
318
+ assert.equal(model?.id, id);
319
+ assert.equal(model?.reasoning, true);
320
+ assert.equal(model?.contextWindow, contextWindow);
321
+ assert.deepEqual(model?.input, input);
322
+ assert.equal(model?.maxTokens, maxTokens);
323
+ }
324
+ });
325
+
326
+ test("a successful list containing only unknown or non-chat IDs stays empty", async () => {
327
+ const config = createNanProviderConfig({
328
+ fetchImpl: async () => jsonResponse({ data: ["unknown", "embedding", "image", "speech", "rerank"].map((id) => ({ id })) }),
329
+ });
330
+ const context = refreshContext({ type: "api_key", key: "test-key" });
331
+ assert.deepEqual(await config.refreshModels?.(context), []);
332
+ assert.deepEqual(await config.refreshModels?.({ ...context, allowNetwork: false }), []);
333
+ });
334
+
335
+ test("a successful empty key-scoped catalog does not restore offline fallback models", async () => {
336
+ const config = createNanProviderConfig({ fetchImpl: async () => jsonResponse({ data: [] }) });
337
+ assert.ok(config.models && config.models.length > 0);
338
+
339
+ const models = await config.refreshModels?.(refreshContext({ type: "api_key", key: "test-secret" }));
340
+
341
+ assert.deepEqual(models, []);
342
+ assert.deepEqual(await config.refreshModels({
343
+ ...refreshContext({ type: "api_key", key: "test-secret" }), allowNetwork: false,
344
+ }), []);
345
+ });
346
+
347
+ test("failed or malformed discovery preserves the documented baseline or last successful catalog", async () => {
348
+ const failingFetches: Array<typeof fetch> = [
349
+ async () => jsonResponse({ error: "unavailable" }, 503),
350
+ async () => new Response("not-json", { status: 200 }),
351
+ async () => jsonResponse({ models: [{ id: "not-the-supported-shape" }] }),
352
+ async () => {
353
+ throw new Error("network unavailable");
354
+ },
355
+ ];
356
+
357
+ for (const fetchImpl of failingFetches) {
358
+ const config = createNanProviderConfig({ fetchImpl });
359
+ const baseline = config.models;
360
+ const afterFailure = await config.refreshModels?.(refreshContext({ type: "api_key", key: "test-secret" }));
361
+ assert.deepEqual(afterFailure, baseline);
362
+ }
363
+
364
+ let fail = false;
365
+ const config = createNanProviderConfig({
366
+ fetchImpl: async () => fail ? jsonResponse({ error: "unavailable" }, 503) : jsonResponse({ data: [{ id: "glm5.3" }] }),
367
+ });
368
+ const live = await config.refreshModels?.(refreshContext({ type: "api_key", key: "test-secret" }));
369
+ fail = true;
370
+ assert.deepEqual(await config.refreshModels?.(refreshContext({ type: "api_key", key: "test-secret" })), live);
371
+ assert.deepEqual(await config.refreshModels({
372
+ ...refreshContext({ type: "api_key", key: "test-secret" }), allowNetwork: false,
373
+ }), live);
374
+ });
375
+
376
+ test("offline model refresh does not make a network request", async () => {
377
+ let calls = 0;
378
+ const config = createNanProviderConfig({
379
+ fetchImpl: async () => {
380
+ calls++;
381
+ return jsonResponse({ data: [] });
382
+ },
383
+ });
384
+ const context = refreshContext({ type: "api_key", key: "test-secret" });
385
+ const models = await config.refreshModels?.({ ...context, allowNetwork: false });
386
+ assert.equal(calls, 0);
387
+ assert.deepEqual(models, config.models);
388
+ assert.deepEqual(models.map((model) => model.id), DOCUMENTED_CHAT_IDS);
389
+ const changed = await config.refreshModels({
390
+ ...refreshContext({ type: "api_key", key: "different-key" }), allowNetwork: false,
391
+ });
392
+ assert.deepEqual(changed.map((model) => model.id), DOCUMENTED_CHAT_IDS);
393
+ assert.equal(calls, 0);
394
+ });
395
+
396
+ test("credential changes discard previous live models before failed, offline, or cancelled discovery", async () => {
397
+ for (const mode of ["failure", "offline", "cancelled", "removed"] as const) {
398
+ let calls = 0;
399
+ const config = createNanProviderConfig({
400
+ fetchImpl: async () => ++calls === 1
401
+ ? jsonResponse({ data: [{ id: "glm5.3" }] })
402
+ : jsonResponse({ error: "denied" }, 401),
403
+ });
404
+ await config.refreshModels?.(refreshContext({ type: "api_key", key: "first-key" }));
405
+ const controller = new AbortController();
406
+ if (mode === "cancelled") controller.abort();
407
+ const context = refreshContext(mode === "removed" ? undefined : { type: "api_key", key: "second-key" });
408
+ const models = await config.refreshModels?.({
409
+ ...context,
410
+ allowNetwork: mode !== "offline",
411
+ signal: controller.signal,
412
+ });
413
+ assert.deepEqual(models, config.models, mode);
414
+ assert.equal(calls, mode === "offline" || mode === "cancelled" ? 1 : 2);
415
+ }
416
+ });
417
+
418
+ test("same-key failure retains an authoritative empty catalog", async () => {
419
+ let calls = 0;
420
+ const config = createNanProviderConfig({
421
+ fetchImpl: async () => ++calls === 1 ? jsonResponse({ data: [] }) : jsonResponse({}, 503),
422
+ });
423
+ const context = refreshContext({ type: "api_key", key: "empty-key" });
424
+ assert.deepEqual(await config.refreshModels?.(context), []);
425
+ assert.deepEqual(await config.refreshModels?.(context), []);
426
+ });
427
+
428
+ test("cancelling discovery aborts fetch and does not restore the previous key's catalog", async () => {
429
+ let calls = 0;
430
+ let requestSignal: AbortSignal | undefined;
431
+ let started!: () => void;
432
+ const pending = new Promise<void>((resolve) => { started = resolve; });
433
+ const config = createNanProviderConfig({
434
+ fetchImpl: async (_input, init) => {
435
+ if (++calls === 1) return jsonResponse({ data: [{ id: "glm5.3" }] });
436
+ requestSignal = init?.signal as AbortSignal;
437
+ return new Promise<Response>((_resolve, reject) => {
438
+ requestSignal!.addEventListener("abort", () => reject(new Error("cancelled")), { once: true });
439
+ started();
440
+ });
441
+ },
442
+ });
443
+ await config.refreshModels?.(refreshContext({ type: "api_key", key: "first-key" }));
444
+ const controller = new AbortController();
445
+ const result = config.refreshModels?.({
446
+ ...refreshContext({ type: "api_key", key: "second-key" }), signal: controller.signal,
447
+ });
448
+ await pending;
449
+ controller.abort();
450
+ assert.deepEqual(await result, config.models);
451
+ assert.equal(requestSignal?.aborted, true);
452
+ });
453
+
454
+ test("an old in-flight discovery cannot overwrite a changed credential's catalog", async () => {
455
+ let resolveOld!: (response: Response) => void;
456
+ const config = createNanProviderConfig({
457
+ fetchImpl: async (_input, init) => {
458
+ if (new Headers(init?.headers).get("authorization") === "Bearer first-key") {
459
+ return new Promise<Response>((resolve) => { resolveOld = resolve; });
460
+ }
461
+ return jsonResponse({ data: [] });
462
+ },
463
+ });
464
+ const old = config.refreshModels?.(refreshContext({ type: "api_key", key: "first-key" }));
465
+ assert.deepEqual(await config.refreshModels?.(refreshContext({ type: "api_key", key: "second-key" })), []);
466
+ resolveOld(jsonResponse({ data: [{ id: "glm5.3" }] }));
467
+ assert.deepEqual(await old, []);
468
+ assert.deepEqual(await config.refreshModels?.({
469
+ ...refreshContext({ type: "api_key", key: "second-key" }), allowNetwork: false,
470
+ }), []);
471
+ });
@@ -361,11 +361,23 @@ test("3.6.1 explicitly repeats 3.6.0 because the negotiated provider lane is unc
361
361
  assert.deepEqual(contract, NATIVE_CLI_CONTRACTS["3.6.0"] as Record<string, boolean>);
362
362
  });
363
363
 
364
+ test("3.7.0 explicitly repeats 3.6.1 because provider contract 1.2.0 is unchanged", () => {
365
+ const contract = NATIVE_CLI_CONTRACTS["3.7.0"] as Record<string, boolean>;
366
+ assert.ok(contract);
367
+ assert.deepEqual(contract, NATIVE_CLI_CONTRACTS["3.6.1"] as Record<string, boolean>);
368
+ });
369
+
370
+ test("4.0.0 explicitly repeats 3.7.0 because provider contract 1.2.0 and capabilities/v2.6 are unchanged", () => {
371
+ const contract = NATIVE_CLI_CONTRACTS["4.0.0"] as Record<string, boolean>;
372
+ assert.ok(contract);
373
+ assert.deepEqual(contract, NATIVE_CLI_CONTRACTS["3.7.0"] as Record<string, boolean>);
374
+ });
375
+
364
376
  test("no shipped version key was added beyond the pin bump", () => {
365
377
  // Rows are promises to consumers, so a new key only ever appears in a
366
378
  // dedicated commit alongside a pin bump, never as a side effect. v2.2.4 and
367
379
  // v2.3.0 shipped upstream while Pi stayed on 2.2.3 and were never pinned,
368
380
  // so they get no row: a row asserts ground truth measured against a binary
369
381
  // Pi actually ran, and the table only has to be ascending, not gapless.
370
- assert.deepEqual(Object.keys(NATIVE_CLI_CONTRACTS), [...DARK_VERSIONS, "2.2.0", "2.2.1", "2.2.2", "2.2.3", "2.4.0", "2.5.0-rc.3", "2.5.0", "2.6.0", "2.7.0", "2.8.0", "2.8.1", "2.8.2", "2.9.0", "2.9.1", "3.0.0", "3.0.1", "3.1.0", "3.2.1", "3.4.0", "3.5.0", "3.6.0", "3.6.1"]);
382
+ assert.deepEqual(Object.keys(NATIVE_CLI_CONTRACTS), [...DARK_VERSIONS, "2.2.0", "2.2.1", "2.2.2", "2.2.3", "2.4.0", "2.5.0-rc.3", "2.5.0", "2.6.0", "2.7.0", "2.8.0", "2.8.1", "2.8.2", "2.9.0", "2.9.1", "3.0.0", "3.0.1", "3.1.0", "3.2.1", "3.4.0", "3.5.0", "3.6.0", "3.6.1", "3.7.0", "4.0.0"]);
371
383
  });
@@ -7,7 +7,7 @@ import {
7
7
  decodeReviewConsentV3,
8
8
  } from "../lib/review-integration-v2.ts";
9
9
  import {
10
- decodeNativeSddStatusV2,
10
+ NATIVE_REVIEW_OPERATION,
11
11
  NATIVE_REVIEW_DEFAULT_MAX_BUFFER_BYTES,
12
12
  NATIVE_REVIEW_ERROR_CODE,
13
13
  NativeReviewCliError,
@@ -60,100 +60,6 @@ function client(adapter: ExecFileAdapter): NativeReviewCliV216 {
60
60
  return new NativeReviewCliV216(adapter, "/package/.gentle-ai/gentle-ai", 30_000, 1024 * 1024);
61
61
  }
62
62
 
63
- // Structural test data follows the 2026-09-11 native 2.7.1-0.20260911070137-350f31554a4b
64
- // capture: seven dependencies and four phaseInstructions groups, not a legacy alias.
65
- function nativeSddStatus(changeName = "complete-native-review-lifecycle", workspaceRoot = "/repo"): Record<string, unknown> {
66
- return {
67
- schemaName: "gentle-ai.sdd-status",
68
- schemaVersion: 2,
69
- changeName,
70
- artifactStore: "openspec",
71
- planningHome: { mode: "repo-local", path: `${workspaceRoot}/openspec` },
72
- changeRoot: `${workspaceRoot}/openspec/changes/${changeName}`,
73
- actionContext: { mode: "repo-local", workspaceRoot, allowedEditRoots: [workspaceRoot] },
74
- dependencies: { proposal: "all_done", specs: "all_done", design: "all_done", tasks: "all_done", apply: "all_done", verify: "all_done", archive: "ready" },
75
- phaseInstructions: {
76
- apply: ["Apply is complete."],
77
- verify: ["Verification is complete."],
78
- remediate: ["Bind remediation to failed evidence."],
79
- archive: ["Archive the selected change."],
80
- },
81
- blockedReasons: [],
82
- nextRecommended: "archive",
83
- };
84
- }
85
-
86
- test("native SDD status executes its exact selected-root argv and returns only a validated v2 authority", async () => {
87
- const body = nativeSddStatus();
88
- const queue = queuedAdapter([{ stdout: JSON.stringify(body) }]);
89
- const status = await (client(queue.adapter) as unknown as {
90
- sddStatus(request: { changeName: string; workspaceRoot: string }): Promise<Record<string, unknown>>;
91
- }).sddStatus({ changeName: "complete-native-review-lifecycle", workspaceRoot: "/repo" });
92
-
93
- assert.deepEqual(status, body);
94
- assert.deepEqual(queue.calls, [{
95
- file: "/package/.gentle-ai/gentle-ai",
96
- arguments: ["sdd-status", "complete-native-review-lifecycle", "--cwd", "/repo", "--json", "--instructions"],
97
- cwd: "/repo",
98
- timeoutMs: 30_000,
99
- }]);
100
- });
101
-
102
- test("native SDD status rejects malformed v2 identities, dependencies, instructions, and blockers", async () => {
103
- const invalidInstructions = { apply: ["ok"], verify: ["ok"], remediate: ["ok"], archive: [42] };
104
- assert.doesNotThrow(() => decodeNativeSddStatusV2(
105
- { ...nativeSddStatus(), phaseInstructions: { ...invalidInstructions, archive: ["ok"] } },
106
- { changeName: "complete-native-review-lifecycle", workspaceRoot: "/repo" },
107
- ));
108
- const malformed = [
109
- { ...nativeSddStatus(), schemaName: "gentle-pi.sdd-status" },
110
- { ...nativeSddStatus(), schemaVersion: 1 },
111
- { ...nativeSddStatus(), changeName: "other-change" },
112
- { ...nativeSddStatus(), artifactStore: "future-store" },
113
- { ...nativeSddStatus(), planningHome: { mode: "future-mode", path: "/repo/openspec" } },
114
- { ...nativeSddStatus(), planningHome: { mode: "repo-local", path: "/outside" } },
115
- { ...nativeSddStatus(), changeRoot: "bad\nroot" },
116
- { ...nativeSddStatus(), actionContext: { mode: "repo-local", workspaceRoot: "/other", allowedEditRoots: ["/other"] } },
117
- { ...nativeSddStatus(), actionContext: { mode: "future-mode", workspaceRoot: "/repo", allowedEditRoots: ["/repo"] } },
118
- { ...nativeSddStatus(), actionContext: { mode: "repo-local", workspaceRoot: "/repo", allowedEditRoots: ["/outside"] } },
119
- { ...nativeSddStatus(), dependencies: { apply: "all_done", verify: "all_done", archive: "future" } },
120
- { ...nativeSddStatus(), phaseInstructions: invalidInstructions },
121
- { ...nativeSddStatus(), blockedReasons: "not-an-array" },
122
- { ...nativeSddStatus(), nextRecommended: "unknown" },
123
- { ...nativeSddStatus(), instructions: {}, phaseInstructions: undefined },
124
- { ...nativeSddStatus(), phaseInstructions: null },
125
- ];
126
- for (const body of malformed) {
127
- await assert.rejects(
128
- () => (client(queuedAdapter([{ stdout: JSON.stringify(body) }]).adapter) as unknown as {
129
- sddStatus(request: { changeName: string; workspaceRoot: string }): Promise<Record<string, unknown>>;
130
- }).sddStatus({ changeName: "complete-native-review-lifecycle", workspaceRoot: "/repo" }),
131
- (error: unknown) => error instanceof NativeReviewCliError && error.code === NATIVE_REVIEW_ERROR_CODE.SCHEMA_INCOMPATIBLE,
132
- );
133
- }
134
- });
135
-
136
- test("native v2 read-only decoding preserves omitted optional phaseInstructions", () => {
137
- const body = nativeSddStatus();
138
- delete body.phaseInstructions;
139
- assert.equal(decodeNativeSddStatusV2(body, { changeName: "complete-native-review-lifecycle", workspaceRoot: "/repo" }), body);
140
- });
141
-
142
- test("native SDD status keeps malformed JSON, timeout, and nonzero execution fail-closed", async () => {
143
- for (const [result, code] of [
144
- [{ stdout: "not-json" }, NATIVE_REVIEW_ERROR_CODE.MALFORMED_JSON],
145
- [{ stdout: "", timedOut: true }, NATIVE_REVIEW_ERROR_CODE.TIMEOUT],
146
- [{ stdout: "{}", exitCode: 1 }, NATIVE_REVIEW_ERROR_CODE.NON_ZERO],
147
- ] as const) {
148
- await assert.rejects(
149
- () => (client(queuedAdapter([result]).adapter) as unknown as {
150
- sddStatus(request: { changeName: string; workspaceRoot: string }): Promise<Record<string, unknown>>;
151
- }).sddStatus({ changeName: "complete-native-review-lifecycle", workspaceRoot: "/repo" }),
152
- (error: unknown) => error instanceof NativeReviewCliError && error.code === code && error.mutationOutcome === "none",
153
- );
154
- }
155
- });
156
-
157
63
  test("negotiated STATUS accepts the pinned v5 receipt before routing its transition", async () => {
158
64
  const status = fixture("status-v5.captured.json");
159
65
  const queue = queuedAdapter([{ stdout: JSON.stringify(status) }]);
@@ -786,8 +692,11 @@ test("capture-result receives the controller AbortSignal without an automatic mu
786
692
  assert.equal(receivedTimeout, undefined);
787
693
  });
788
694
 
789
- test("native review client exposes native v2 SDD status without adding review lifecycle behavior", () => {
790
- assert.equal(typeof (client(queuedAdapter([]).adapter) as unknown as { sddStatus?: unknown }).sddStatus, "function");
695
+ test("native review transport has no SDD operations or methods", () => {
696
+ assert.equal(Object.values(NATIVE_REVIEW_OPERATION).some((operation) => operation.startsWith("sdd-")), false);
697
+ const transport = client(queuedAdapter([]).adapter) as unknown as Record<string, unknown>;
698
+ assert.equal("sddStatus" in transport, false);
699
+ assert.equal("sddContinue" in transport, false);
791
700
  });
792
701
 
793
702
  test("read-only authority inventory rejects a repository identity mismatch after decoding", async () => {
@@ -1000,26 +909,3 @@ test("acknowledge-approved keeps its fail-closed stderr and typed refusal discip
1000
909
  (error: unknown) => error instanceof NativeReviewCliError && error.code === NATIVE_REVIEW_ERROR_CODE.EMPTY_OUTPUT,
1001
910
  );
1002
911
  });
1003
-
1004
- test("native continuation uses a distinct mutating operation and the same v2 decoder", async () => {
1005
- const queue = queuedAdapter([{ stdout: JSON.stringify(nativeSddStatus()) }, { stdout: "{}" }]);
1006
- const cli = client(queue.adapter);
1007
- const request = { changeName: "complete-native-review-lifecycle", workspaceRoot: "/repo" };
1008
- assert.equal((await cli.sddContinue(request)).nextRecommended, nativeSddStatus().nextRecommended);
1009
- assert.deepEqual(queue.calls[0]!.arguments, ["sdd-continue", request.changeName, "--cwd", "/repo", "--json", "--instructions"]);
1010
- assert.equal(queue.calls[0]!.timeoutMs, undefined);
1011
- await assert.rejects(() => cli.sddContinue(request), (error: unknown) => error instanceof NativeReviewCliError && error.mutating === true);
1012
- });
1013
-
1014
- test("native discovery preserves nullable selection and rejects an invented selected identity", () => {
1015
- const discovery = { ...nativeSddStatus(), changeName: null, changeRoot: null, nextRecommended: "select-change" };
1016
- assert.equal(decodeNativeSddStatusV2(discovery, { workspaceRoot: "/repo" }).changeName, null);
1017
- assert.throws(() => decodeNativeSddStatusV2(discovery, { changeName: "alpha", workspaceRoot: "/repo" }), /identity/);
1018
- });
1019
-
1020
- test("all twelve native actions retain their exact tokens without prose routing", () => {
1021
- for (const nextRecommended of ["apply", "verify", "remediate", "archive", "archived", "resolve-blockers", "sdd-new", "select-change", "propose", "spec", "design", "tasks"]) {
1022
- const status = { ...nativeSddStatus(), nextRecommended, ...(nextRecommended === "remediate" ? { remediationState: { required: true, complete: false, failedEvidenceRevision: `sha256:${"a".repeat(64)}` } } : {}) };
1023
- assert.equal(decodeNativeSddStatusV2(status, { workspaceRoot: "/repo" }).nextRecommended, nextRecommended);
1024
- }
1025
- });