gentle-pi 3.7.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.
- package/README.md +37 -6
- package/assets/agents/gentle-ai-explore.md +4 -4
- package/assets/agents/gentle-ai-verify.md +6 -4
- package/assets/agents/gentle-ai-worker.md +7 -9
- package/assets/orchestrator-delegation.md +40 -41
- package/assets/orchestrator-memory.md +1 -22
- package/assets/orchestrator-skills.md +1 -1
- package/assets/orchestrator.md +12 -24
- package/assets/support/strict-tdd-verify.md +4 -266
- package/assets/support/strict-tdd.md +8 -360
- package/bin/gentle-shell.mjs +254 -29
- package/docs/delegated-verification.md +26 -1
- package/docs/gentle-agents-activity.md +24 -0
- package/docs/gentle-shell.md +92 -28
- package/docs/native-authority-architecture.md +2 -2
- package/docs/prompt-history.md +280 -0
- package/docs/readme-reference.md +112 -206
- package/docs/telemetry.md +1 -1
- package/docs/yolo-mode.md +86 -0
- package/extensions/child-context.ts +26 -0
- package/extensions/child-safety.ts +23 -0
- package/extensions/gentle-agents.ts +429 -262
- package/extensions/gentle-ai.ts +781 -683
- package/extensions/gentle-shell.ts +1375 -88
- package/extensions/gentle-stats.ts +101 -0
- package/extensions/gentle-todo.ts +18 -7
- package/extensions/history/atomic-write.ts +38 -0
- package/extensions/history/hide-prompts.ts +183 -0
- package/extensions/history/index.ts +1419 -0
- package/extensions/history/load-shared-history.ts +39 -0
- package/extensions/history/selector-helpers.ts +538 -0
- package/extensions/history/session-scan.ts +233 -0
- package/extensions/history/store.ts +1119 -0
- package/extensions/nan-provider.ts +6 -0
- package/extensions/quiet-tools.ts +179 -87
- package/extensions/resume-hint.ts +60 -0
- package/extensions/skill-registry.ts +16 -12
- package/extensions/startup-banner.ts +60 -31
- package/lib/agent-assets.ts +604 -0
- package/lib/agent-profile-pin.ts +12 -0
- package/lib/agents-message-delivery.ts +181 -0
- package/lib/agents-protocol.ts +60 -0
- package/lib/agents-runner.ts +96 -98
- package/lib/agents-view.ts +26 -3
- package/lib/agents-widget.ts +16 -9
- package/lib/append-system-prompt.ts +21 -0
- package/lib/bounded-writer-admission.ts +147 -0
- package/lib/card-style-policy.ts +60 -0
- package/lib/child-context-files.ts +166 -0
- package/lib/codemode-renderer.ts +185 -0
- package/lib/command-palette-catalog.ts +3 -9
- package/lib/command-palette.ts +25 -14
- package/lib/destructive-command-guard.ts +144 -0
- package/lib/gentle-ai-elapsed-store.ts +87 -0
- package/lib/gentle-ai-renderer.ts +196 -39
- package/lib/gentle-shell-launcher.ts +24 -13
- package/lib/gentle-shell-resume-hint.ts +176 -0
- package/lib/history-capture-policy.ts +95 -0
- package/lib/model-routing-authority.ts +5 -1
- package/lib/nan-provider.ts +227 -0
- package/lib/native-review-cli.ts +49 -108
- package/lib/odd-phase-inference.ts +231 -0
- package/lib/odd-phase.ts +141 -0
- package/lib/overlay-repaint.ts +26 -0
- package/lib/pi-tui-keys.ts +53 -0
- package/lib/review-candidate-view-owner.ts +67 -17
- package/lib/review-candidate-view.ts +112 -25
- package/lib/review-reminder-receipt.ts +48 -8
- package/lib/review-risk-assessment.ts +156 -11
- package/lib/review-sidebar-state.ts +223 -0
- package/lib/selection-engine.ts +515 -0
- package/lib/session-messaging-grants.ts +135 -0
- package/lib/session-worktree-registry.ts +14 -2
- package/lib/shell-bar.ts +179 -24
- package/lib/shell-card.ts +287 -18
- package/lib/shell-changes-view.ts +2 -1
- package/lib/shell-prompt.ts +102 -6
- package/lib/shell-sidebar-layout.ts +78 -14
- package/lib/shell-sidebar.ts +15 -1
- package/lib/shell-todo.ts +23 -13
- package/lib/shell-usage-view.ts +9 -4
- package/lib/shell-usage.ts +66 -10
- package/lib/stats-collector.ts +381 -0
- package/lib/stats-view.ts +431 -0
- package/lib/theme-customization.ts +52 -0
- package/lib/vim-editor-adapter.ts +379 -0
- package/lib/vim-normal-engine.ts +154 -0
- package/lib/vim-operator-engine.ts +416 -0
- package/lib/vim-policy.ts +49 -0
- package/lib/vim-visual-engine.ts +107 -0
- package/lib/visual-customization-policy.ts +108 -0
- package/lib/visual-customize-view.ts +330 -0
- package/lib/visual-profiles.ts +228 -0
- package/lib/yolo-session-policy.ts +240 -0
- package/package.json +20 -8
- package/runtime/gentle-shell-launcher.mjs +23 -12
- package/runtime/gentle-shell-resume-hint.mjs +177 -0
- package/runtime/native-review-cli.mjs +49 -108
- package/runtime/review-risk-assessment.mjs +154 -9
- package/scripts/build-runtime-modules.mjs +1 -0
- package/scripts/gentle-ai-installer.mjs +14 -13
- package/scripts/mirror-odd-routing.mjs +2 -2
- package/scripts/run-test-suite.mjs +76 -0
- package/scripts/test-packed-runner.mjs +31 -14
- package/scripts/verify-package-files.mjs +11 -22
- package/skills/branch-pr/SKILL.md +24 -52
- package/skills/chained-pr/SKILL.md +31 -15
- package/skills/chained-pr/references/chaining-details.md +31 -20
- package/skills/gentle-ai/SKILL.md +9 -15
- package/skills/issue-creation/SKILL.md +8 -2
- package/skills/issue-creation/references/delegated-workflow-actions.md +19 -0
- package/skills/work-unit-commits/SKILL.md +4 -3
- package/tests/agent-profiles.test.ts +18 -0
- package/tests/agents-fake-child.ts +2 -2
- package/tests/agents-message-delivery.test.ts +106 -0
- package/tests/agents-protocol.test.ts +40 -0
- package/tests/agents-runner.test.ts +378 -89
- package/tests/agents-view-thread-identity.test.ts +169 -0
- package/tests/agents-view.test.ts +8 -2
- package/tests/agents-widget.test.ts +154 -15
- package/tests/append-system-prompt-route.test.ts +160 -0
- package/tests/append-system-prompt.test.ts +46 -0
- package/tests/artifact-language.test.ts +19 -213
- package/tests/ask-user-question.test.ts +44 -1
- package/tests/asset-installation-runtime.test.ts +5 -16
- package/tests/autonomous-guard.test.ts +69 -1
- package/tests/bounded-writer-admission.test.ts +95 -0
- package/tests/branch-pr-skill.test.ts +43 -0
- package/tests/card-style-policy.test.ts +55 -0
- package/tests/chained-pr-skill.test.ts +124 -0
- package/tests/child-context-files.test.ts +255 -0
- package/tests/child-safety.test.ts +82 -0
- package/tests/codemode-rendering.test.ts +491 -0
- package/tests/command-palette.test.ts +39 -3
- package/tests/delegated-key-learnings-contract.test.ts +0 -76
- package/tests/destructive-command-guard.test.ts +84 -0
- package/tests/devbinary/native-review-parity.devtest.ts +170 -2
- package/tests/devbinary/non-git-subagent-bootstrap.devtest.ts +193 -0
- package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-28T10-00-00-000Z_aaa.jsonl +7 -0
- package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-29T23-00-00-000Z_bbb.jsonl +3 -0
- package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-30T08-00-00-000Z_ddd.jsonl +2 -0
- package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-30T09-00-00-000Z_eee.jsonl +3 -0
- package/tests/fixtures/stats/sessions/--work-alpha--/run-1/session.jsonl +2 -0
- package/tests/fixtures/stats/sessions/--work-beta--/2026-09-01T12-00-00-000Z_ccc.jsonl +2 -0
- package/tests/fixtures/stats/user-pi/sessions/--work-alpha--/2026-09-28T10-00-00-000Z_aaa.jsonl +2 -0
- package/tests/fixtures/stats/user-pi/sessions/--work-alpha--/2026-09-29T23-00-00-000Z_bbb.jsonl +4 -0
- package/tests/fixtures/stats/user-pi/sessions/--work-gamma--/2026-09-20T09-00-00-000Z_fff.jsonl +3 -0
- package/tests/generic-agent-tools.test.ts +54 -0
- package/tests/gentle-agents.test.ts +1047 -252
- package/tests/gentle-ai-binary.test.ts +3 -3
- package/tests/gentle-ai-elapsed-store.test.ts +68 -0
- package/tests/gentle-ai-installer.test.ts +68 -54
- package/tests/gentle-ai-renderer.test.ts +487 -8
- package/tests/gentle-ai.test.ts +288 -65
- package/tests/gentle-card-text.ts +2 -1
- package/tests/gentle-shell-bin.test.ts +651 -114
- package/tests/gentle-shell-launcher.test.ts +99 -52
- package/tests/gentle-shell-resume-hint.test.ts +270 -0
- package/tests/gentle-shell.test.ts +3839 -187
- package/tests/gentle-stats.test.ts +152 -0
- package/tests/gentle-theme.test.ts +4 -1
- package/tests/gentle-todo.test.ts +80 -8
- package/tests/history-atomic-write.test.ts +57 -0
- package/tests/history-capture-policy.test.ts +102 -0
- package/tests/history-command-registration.test.ts +164 -0
- package/tests/history-dedupe-entries.test.ts +123 -0
- package/tests/history-delete-backfill.test.ts +190 -0
- package/tests/history-delete-confirm.test.ts +460 -0
- package/tests/history-dispatch.test.ts +180 -0
- package/tests/history-drain-hidden.test.ts +110 -0
- package/tests/history-drain-order.test.ts +98 -0
- package/tests/history-expanded-globals.test.ts +62 -0
- package/tests/history-gc.test.ts +832 -0
- package/tests/history-header-layout.test.ts +265 -0
- package/tests/history-hide-prompts.test.ts +275 -0
- package/tests/history-lazy-windowing.test.ts +508 -0
- package/tests/history-legacy-migrate-v2.test.ts +297 -0
- package/tests/history-load-shared-history.test.ts +53 -0
- package/tests/history-max-results-cap.test.ts +76 -0
- package/tests/history-multi-reader.test.ts +203 -0
- package/tests/history-off-path.test.ts +170 -0
- package/tests/history-openflow-integration.test.ts +173 -0
- package/tests/history-overlay-margin.test.ts +326 -0
- package/tests/history-preview-layout.test.ts +93 -0
- package/tests/history-registry.test.ts +143 -0
- package/tests/history-scope-delete.test.ts +411 -0
- package/tests/history-search-caret-keys.test.ts +142 -0
- package/tests/history-seed-bootstrap.test.ts +170 -0
- package/tests/history-seed-regen.test.ts +129 -0
- package/tests/history-selector-windowing.test.ts +94 -0
- package/tests/history-session-scan-directory.test.ts +87 -0
- package/tests/history-session-scan-extract.test.ts +583 -0
- package/tests/history-session-writer.test.ts +351 -0
- package/tests/history-store-paths.test.ts +79 -0
- package/tests/history-tombstone-exact.test.ts +139 -0
- package/tests/history-wheel-mouse.test.ts +242 -0
- package/tests/inprocess-reviewer.test.ts +29 -19
- package/tests/issue-creation-skill.test.ts +61 -0
- package/tests/model-routing-authority.test.ts +16 -0
- package/tests/nan-provider.test.ts +471 -0
- package/tests/native-review-capability-contract.test.ts +7 -1
- package/tests/native-review-cli.test.ts +6 -120
- package/tests/native-review-parity-runtime.test.ts +100 -3
- package/tests/odd-integration.test.ts +67 -0
- package/tests/odd-phase-inference.test.ts +213 -0
- package/tests/odd-phase-loader.test.ts +253 -0
- package/tests/odd-phase.test.ts +307 -0
- package/tests/odd-routing-canonical-ratchet.test.ts +11 -6
- package/tests/odd-routing-contract.test.ts +86 -35
- package/tests/orchestrator-budget.test.ts +14 -39
- package/tests/orchestrator-rdd-ownership.test.ts +3 -3
- package/tests/overlay-repaint.test.ts +74 -0
- package/tests/package-manifest.test.ts +252 -115
- package/tests/packed-runner-owned-path.test.ts +46 -0
- package/tests/persona-single-channel.test.ts +6 -6
- package/tests/provider-defect-handoff.test.ts +3 -11
- package/tests/quiet-bash-runtime.test.ts +76 -0
- package/tests/quiet-tool-rendering.test.ts +409 -184
- package/tests/rdd-aware-verification-contract.test.ts +76 -1
- package/tests/rdd-status-line.test.ts +9 -4
- package/tests/resume-hint-extension.test.ts +122 -0
- package/tests/review-agent-end-preflight.test.ts +176 -12
- package/tests/review-candidate-owner-retry.test.ts +22 -1
- package/tests/review-candidate-view.test.ts +298 -0
- package/tests/review-contract-prompt.test.ts +108 -43
- package/tests/review-controller-lock-status.test.ts +0 -1
- package/tests/review-controller-native-routing.test.ts +611 -5
- package/tests/review-controller-workspace-root.test.ts +163 -4
- package/tests/review-host-relay-routing.test.ts +338 -2
- package/tests/review-integration-v2-forward.test.ts +200 -0
- package/tests/review-ledger-contract.test.ts +10 -34
- package/tests/review-reminder-receipt.test.ts +47 -1
- package/tests/review-risk-assessment.test.ts +498 -6
- package/tests/review-sidebar-state.test.ts +402 -0
- package/tests/run-test-suite.test.ts +124 -0
- package/tests/runtime-harness.mjs +145 -786
- package/tests/runtime-metrics-children.test.ts +16 -23
- package/tests/selection-engine.test.ts +421 -0
- package/tests/session-messaging-grants.test.ts +255 -0
- package/tests/session-worktree-registry.test.ts +77 -0
- package/tests/shell-bar.test.ts +382 -1
- package/tests/shell-card.test.ts +353 -1
- package/tests/shell-changes-view.test.ts +52 -0
- package/tests/shell-prompt.test.ts +94 -2
- package/tests/shell-sidebar-layout.test.ts +325 -21
- package/tests/shell-sidebar-scroll-benchmark.test.ts +255 -0
- package/tests/shell-todo.test.ts +87 -1
- package/tests/shell-usage-view.test.ts +27 -0
- package/tests/shell-usage.test.ts +73 -0
- package/tests/skill-registry.test.ts +50 -1
- package/tests/startup-banner.test.ts +130 -2
- package/tests/stats-collector.test.ts +195 -0
- package/tests/stats-view.test.ts +202 -0
- package/tests/telemetry-trigger.test.ts +81 -20
- package/tests/theme-customization.test.ts +72 -0
- package/tests/vim-editor-adapter-host-resolution.test.ts +37 -0
- package/tests/vim-editor-adapter.test.ts +804 -0
- package/tests/vim-normal-engine.test.ts +101 -0
- package/tests/vim-operator-engine.test.ts +215 -0
- package/tests/vim-policy.test.ts +19 -0
- package/tests/vim-visual-engine.test.ts +52 -0
- package/tests/visual-customization-policy.test.ts +110 -0
- package/tests/visual-customize-view.test.ts +418 -0
- package/tests/visual-profiles.test.ts +87 -0
- package/tests/yolo-customize.test.ts +256 -0
- package/tests/yolo-mode-runtime.test.ts +161 -0
- package/tests/yolo-mode.test.ts +261 -0
- package/tests/yolo-session-policy.test.ts +59 -0
- package/themes/Gentle.json +2 -1
- package/themes/Gentleman-Cute.json +2 -1
- package/themes/Gentleman-Sexy.json +2 -1
- package/assets/agents/sdd-apply.md +0 -159
- package/assets/agents/sdd-archive.md +0 -228
- package/assets/agents/sdd-design.md +0 -49
- package/assets/agents/sdd-explore.md +0 -48
- package/assets/agents/sdd-init.md +0 -56
- package/assets/agents/sdd-onboard.md +0 -52
- package/assets/agents/sdd-proposal.md +0 -64
- package/assets/agents/sdd-remediate.md +0 -37
- package/assets/agents/sdd-research.md +0 -49
- package/assets/agents/sdd-spec.md +0 -192
- package/assets/agents/sdd-status.md +0 -54
- package/assets/agents/sdd-tasks.md +0 -108
- package/assets/agents/sdd-verify.md +0 -124
- package/assets/chains/sdd-full.chain.md +0 -83
- package/assets/chains/sdd-plan.chain.md +0 -56
- package/assets/chains/sdd-verify.chain.md +0 -43
- package/assets/sdd-orchestrator-workflow.md +0 -319
- package/assets/support/sdd-status-contract.md +0 -77
- package/docs/assets/diagrams/sdd-cycle.svg +0 -14
- package/extensions/sdd-init.ts +0 -816
- package/lib/openspec-deltas.ts +0 -156
- package/lib/sdd-preflight.ts +0 -1066
- package/lib/sdd-research-capabilities.ts +0 -94
- package/lib/sdd-status.ts +0 -26
- package/tests/fixtures/legacy/sdd-research-v2.5.0.md +0 -54
- package/tests/fixtures/native-review-cli/v2.1.3/bind-sdd.json +0 -25
- package/tests/fixtures/v0.10.7/assets/agents/sdd-apply.md +0 -132
- package/tests/openspec-deltas.test.ts +0 -209
- package/tests/sdd-agent-tools.test.ts +0 -156
- package/tests/sdd-archive-replay.test.ts +0 -82
- package/tests/sdd-classical-continuation.test.ts +0 -74
- package/tests/sdd-execution-routing-contract.test.ts +0 -44
- package/tests/sdd-managed-runtime-settlement.test.ts +0 -155
- package/tests/sdd-native-managed-uptake.test.ts +0 -243
- package/tests/sdd-no-attempts-contract.test.ts +0 -15
- package/tests/sdd-odd-integration.test.ts +0 -33
- package/tests/sdd-optional-research.test.ts +0 -124
- package/tests/sdd-planning-routing-contract.test.ts +0 -45
- package/tests/sdd-preflight-rpc-input.test.ts +0 -125
- package/tests/sdd-preflight.test.ts +0 -541
- package/tests/sdd-research-capabilities.test.ts +0 -114
- package/tests/sdd-research-live.test.ts +0 -241
- package/tests/sdd-selection-transport.test.ts +0 -653
- package/tests/sdd-status.test.ts +0 -9
- 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
|
+
});
|
|
@@ -367,11 +367,17 @@ test("3.7.0 explicitly repeats 3.6.1 because provider contract 1.2.0 is unchange
|
|
|
367
367
|
assert.deepEqual(contract, NATIVE_CLI_CONTRACTS["3.6.1"] as Record<string, boolean>);
|
|
368
368
|
});
|
|
369
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
|
+
|
|
370
376
|
test("no shipped version key was added beyond the pin bump", () => {
|
|
371
377
|
// Rows are promises to consumers, so a new key only ever appears in a
|
|
372
378
|
// dedicated commit alongside a pin bump, never as a side effect. v2.2.4 and
|
|
373
379
|
// v2.3.0 shipped upstream while Pi stayed on 2.2.3 and were never pinned,
|
|
374
380
|
// so they get no row: a row asserts ground truth measured against a binary
|
|
375
381
|
// Pi actually ran, and the table only has to be ascending, not gapless.
|
|
376
|
-
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"]);
|
|
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"]);
|
|
377
383
|
});
|
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
decodeReviewConsentV3,
|
|
8
8
|
} from "../lib/review-integration-v2.ts";
|
|
9
9
|
import {
|
|
10
|
-
|
|
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
|
|
790
|
-
assert.equal(
|
|
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
|
-
});
|