pi-hypercharm-provider 1.3.26 → 1.3.28
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/AGENTS.md +24 -0
- package/README.md +1 -1
- package/identity.ts +49 -0
- package/index.ts +123 -27
- package/models.json +5 -5
- package/notify.ts +50 -0
- package/package.json +15 -9
- package/prism.ts +43 -0
- package/tests/fixtures/official-surface.ts +42 -0
- package/tests/identity.test.ts +76 -0
- package/tests/notify.test.ts +111 -0
- package/tests/prism.test.ts +67 -0
- package/tests/provider.integration.test.ts +416 -0
- package/bun.lock +0 -342
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The warning sink is the difference between a degraded session and a silent
|
|
3
|
+
* one, so its three behaviours are pinned here: deduplication, UI routing once
|
|
4
|
+
* a UI session activates it, and a stderr fallback when the captured ctx has
|
|
5
|
+
* gone stale (a refresh landing after its session was replaced).
|
|
6
|
+
*/
|
|
7
|
+
import assert from "node:assert/strict";
|
|
8
|
+
import { test } from "node:test";
|
|
9
|
+
import { createNotifier } from "../notify.ts";
|
|
10
|
+
|
|
11
|
+
function captureStderr() {
|
|
12
|
+
const chunks = [];
|
|
13
|
+
const original = process.stderr.write;
|
|
14
|
+
process.stderr.write = (chunk) => {
|
|
15
|
+
chunks.push(String(chunk));
|
|
16
|
+
return true;
|
|
17
|
+
};
|
|
18
|
+
return {
|
|
19
|
+
chunks,
|
|
20
|
+
restore() {
|
|
21
|
+
process.stderr.write = original;
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function fakeContext(hasUI, notify) {
|
|
27
|
+
return { hasUI, ui: { notify: notify ?? (() => {}) } };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
test("warnings are deduplicated and reach stderr before any UI exists", () => {
|
|
31
|
+
const stderr = captureStderr();
|
|
32
|
+
try {
|
|
33
|
+
const notifier = createNotifier();
|
|
34
|
+
notifier.warn("first failure");
|
|
35
|
+
notifier.warn("first failure");
|
|
36
|
+
notifier.warn("second failure");
|
|
37
|
+
const lines = stderr.chunks.filter((chunk) => chunk.includes("HyperCharm warning:"));
|
|
38
|
+
assert.equal(lines.length, 2, "each distinct warning is emitted once");
|
|
39
|
+
assert.ok(lines[0].includes("first failure"));
|
|
40
|
+
assert.ok(lines[1].includes("second failure"));
|
|
41
|
+
} finally {
|
|
42
|
+
stderr.restore();
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("activation routes later warnings through the session UI as warnings", () => {
|
|
47
|
+
const stderr = captureStderr();
|
|
48
|
+
try {
|
|
49
|
+
const notifications = [];
|
|
50
|
+
const notifier = createNotifier();
|
|
51
|
+
notifier.activate(fakeContext(true, (message, type) => notifications.push([message, type])));
|
|
52
|
+
notifier.warn("catalog refresh failed");
|
|
53
|
+
notifier.warn("catalog refresh failed");
|
|
54
|
+
assert.deepEqual(notifications, [["catalog refresh failed", "warning"]]);
|
|
55
|
+
assert.equal(
|
|
56
|
+
stderr.chunks.some((chunk) => chunk.includes("catalog refresh failed")),
|
|
57
|
+
false,
|
|
58
|
+
"an activated notifier must not also write to stderr",
|
|
59
|
+
);
|
|
60
|
+
} finally {
|
|
61
|
+
stderr.restore();
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("a stale context falls back to stderr instead of throwing", () => {
|
|
66
|
+
const stderr = captureStderr();
|
|
67
|
+
try {
|
|
68
|
+
const notifier = createNotifier();
|
|
69
|
+
notifier.activate(
|
|
70
|
+
fakeContext(true, () => {
|
|
71
|
+
throw new Error("This extension ctx is stale");
|
|
72
|
+
}),
|
|
73
|
+
);
|
|
74
|
+
assert.doesNotThrow(() => notifier.warn("after session replacement"));
|
|
75
|
+
assert.ok(stderr.chunks.some((chunk) => chunk.includes("after session replacement")));
|
|
76
|
+
} finally {
|
|
77
|
+
stderr.restore();
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("a non-UI session never claims the warning channel", () => {
|
|
82
|
+
const stderr = captureStderr();
|
|
83
|
+
try {
|
|
84
|
+
const notifications = [];
|
|
85
|
+
const notifier = createNotifier();
|
|
86
|
+
notifier.activate(fakeContext(false, (message) => notifications.push(message)));
|
|
87
|
+
notifier.warn("headless failure");
|
|
88
|
+
assert.deepEqual(notifications, []);
|
|
89
|
+
assert.ok(stderr.chunks.some((chunk) => chunk.includes("headless failure")));
|
|
90
|
+
} finally {
|
|
91
|
+
stderr.restore();
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("a context that throws on hasUI is ignored rather than fatal", () => {
|
|
96
|
+
const stderr = captureStderr();
|
|
97
|
+
try {
|
|
98
|
+
const notifier = createNotifier();
|
|
99
|
+
const hostile = {
|
|
100
|
+
get hasUI() {
|
|
101
|
+
throw new Error("stale ctx");
|
|
102
|
+
},
|
|
103
|
+
ui: { notify: () => {} },
|
|
104
|
+
};
|
|
105
|
+
assert.doesNotThrow(() => notifier.activate(hostile));
|
|
106
|
+
notifier.warn("still reported");
|
|
107
|
+
assert.ok(stderr.chunks.some((chunk) => chunk.includes("still reported")));
|
|
108
|
+
} finally {
|
|
109
|
+
stderr.restore();
|
|
110
|
+
}
|
|
111
|
+
});
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
import { PRISM_ENTRY_TYPE, prismLabel, prismRouteFromHeaders, prismRouteLabel, readPrismRoute } from "../prism.ts";
|
|
4
|
+
|
|
5
|
+
test("prismLabel accepts trimmed printable labels", () => {
|
|
6
|
+
assert.equal(prismLabel(" GLM 5.3 Flash "), "GLM 5.3 Flash");
|
|
7
|
+
assert.equal(prismLabel("glm-5.3-flash"), "glm-5.3-flash");
|
|
8
|
+
assert.equal(prismLabel("model-\u30c7\u30fc\u30bf"), "model-\u30c7\u30fc\u30bf");
|
|
9
|
+
assert.equal(prismLabel("x".repeat(200)), "x".repeat(200));
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
test("prismLabel rejects unusable labels", () => {
|
|
13
|
+
const rejected = [
|
|
14
|
+
undefined,
|
|
15
|
+
null,
|
|
16
|
+
42,
|
|
17
|
+
{},
|
|
18
|
+
[],
|
|
19
|
+
"",
|
|
20
|
+
" ",
|
|
21
|
+
"x".repeat(201),
|
|
22
|
+
"bad\u001b[31m",
|
|
23
|
+
"bad\nline",
|
|
24
|
+
"bad\u202etext",
|
|
25
|
+
"bad\u0000",
|
|
26
|
+
];
|
|
27
|
+
for (const value of rejected) {
|
|
28
|
+
assert.equal(prismLabel(value), undefined, JSON.stringify(value));
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("prismRouteFromHeaders keeps whichever routing headers are usable", () => {
|
|
33
|
+
assert.deepEqual(
|
|
34
|
+
prismRouteFromHeaders({ "x-prism-model-name": " GLM 5.3 Flash ", "x-prism-model-id": "glm-5.3-flash" }),
|
|
35
|
+
{ modelName: "GLM 5.3 Flash", modelId: "glm-5.3-flash" },
|
|
36
|
+
);
|
|
37
|
+
assert.deepEqual(prismRouteFromHeaders({ "x-prism-model-name": "GLM 5.3 Flash" }), {
|
|
38
|
+
modelName: "GLM 5.3 Flash",
|
|
39
|
+
modelId: undefined,
|
|
40
|
+
});
|
|
41
|
+
assert.deepEqual(prismRouteFromHeaders({ "x-prism-model-id": "glm-5.3-flash" }), {
|
|
42
|
+
modelName: undefined,
|
|
43
|
+
modelId: "glm-5.3-flash",
|
|
44
|
+
});
|
|
45
|
+
assert.equal(prismRouteFromHeaders({}), undefined);
|
|
46
|
+
assert.equal(prismRouteFromHeaders({ "x-prism-model-name": " " }), undefined);
|
|
47
|
+
assert.equal(prismRouteFromHeaders({ "x-prism-model-name": "bad\u001b[31m" }), undefined);
|
|
48
|
+
assert.equal(prismRouteFromHeaders({ "x-other-header": "glm-5.3-flash" }), undefined);
|
|
49
|
+
assert.equal(prismRouteFromHeaders(undefined), undefined);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("persisted entry data is re-validated before rendering", () => {
|
|
53
|
+
assert.deepEqual(readPrismRoute({ modelName: "GLM 5.3 Flash" }), { modelName: "GLM 5.3 Flash", modelId: undefined });
|
|
54
|
+
assert.deepEqual(readPrismRoute({ modelId: "glm-5.3-flash" }), { modelName: undefined, modelId: "glm-5.3-flash" });
|
|
55
|
+
const rejected = [null, undefined, 42, "glm-5.3", [], { modelName: 42 }, { modelName: "bad\u001b[31m" }, { modelId: "bad\nline" }, {}];
|
|
56
|
+
for (const data of rejected) {
|
|
57
|
+
assert.equal(readPrismRoute(data), undefined, JSON.stringify(data));
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("the route label prefers the human name and the entry type stays namespaced", () => {
|
|
62
|
+
assert.equal(prismRouteLabel({ modelName: "GLM 5.3 Flash", modelId: "glm-5.3-flash" }), "GLM 5.3 Flash");
|
|
63
|
+
assert.equal(prismRouteLabel({ modelId: "glm-5.3-flash" }), "glm-5.3-flash");
|
|
64
|
+
assert.equal(prismRouteLabel({}), undefined);
|
|
65
|
+
assert.ok(PRISM_ENTRY_TYPE.startsWith("hypercharm"));
|
|
66
|
+
assert.notEqual(PRISM_ENTRY_TYPE, "hyper-prism-route");
|
|
67
|
+
});
|
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Real-Pi integration test. Loads this extension through Pi's own extension
|
|
3
|
+
* loader into an isolated agent dir, with the network stubbed, and drives it
|
|
4
|
+
* through pi's ExtensionRunner — the same path the TUI uses.
|
|
5
|
+
*
|
|
6
|
+
* Covers what unit tests cannot: provider registration from the embedded
|
|
7
|
+
* catalog, catalog hot-swap + caching + retention, deprecated-model grace,
|
|
8
|
+
* warning surfacing, prism entry durability, and co-installation with the
|
|
9
|
+
* official provider's identifier surface (tests/fixtures/official-surface.ts).
|
|
10
|
+
*/
|
|
11
|
+
import assert from "node:assert/strict";
|
|
12
|
+
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
13
|
+
import { tmpdir } from "node:os";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
import { after, test } from "node:test";
|
|
16
|
+
import { fileURLToPath } from "node:url";
|
|
17
|
+
|
|
18
|
+
const agentDir = mkdtempSync(path.join(tmpdir(), "pi-hypercharm-test-"));
|
|
19
|
+
process.env.PI_CODING_AGENT_DIR = agentDir;
|
|
20
|
+
delete process.env.PI_OFFLINE;
|
|
21
|
+
delete process.env.HYPERCHARM_API_KEY;
|
|
22
|
+
|
|
23
|
+
const extensionPath = fileURLToPath(new URL("../index.ts", import.meta.url));
|
|
24
|
+
const officialSurfacePath = fileURLToPath(new URL("./fixtures/official-surface.ts", import.meta.url));
|
|
25
|
+
const deprecatedModelsPath = fileURLToPath(new URL("../deprecated-models.json", import.meta.url));
|
|
26
|
+
const embeddedModelsPath = fileURLToPath(new URL("../models.json", import.meta.url));
|
|
27
|
+
|
|
28
|
+
const EMBEDDED_AUTHORITY_NOTE = "tests must run against the embedded catalog";
|
|
29
|
+
const CATALOG_URL = "https://hyper.charm.land/v1/provider";
|
|
30
|
+
const PRISM_ENTRY_TYPE = "hypercharm-prism-route";
|
|
31
|
+
const DEPRECATED_TTL_MS = 14 * 24 * 60 * 60 * 1000;
|
|
32
|
+
|
|
33
|
+
const FIXTURE_MODEL = {
|
|
34
|
+
id: "fixture-model",
|
|
35
|
+
name: "Fixture model",
|
|
36
|
+
cost_per_1m_in: 2,
|
|
37
|
+
cost_per_1m_out: 7,
|
|
38
|
+
cost_per_1m_in_cached: 1,
|
|
39
|
+
cost_per_1m_out_cached: 0.5,
|
|
40
|
+
context_window: 8192,
|
|
41
|
+
default_max_tokens: 1024,
|
|
42
|
+
can_reason: false,
|
|
43
|
+
supports_attachments: true,
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const originalFetch = globalThis.fetch;
|
|
47
|
+
after(() => {
|
|
48
|
+
globalThis.fetch = originalFetch;
|
|
49
|
+
rmSync(agentDir, { recursive: true, force: true });
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
53
|
+
|
|
54
|
+
async function waitFor(label, probe, timeoutMs = 15000) {
|
|
55
|
+
const deadline = Date.now() + timeoutMs;
|
|
56
|
+
for (;;) {
|
|
57
|
+
const value = probe();
|
|
58
|
+
if (value !== undefined) return value;
|
|
59
|
+
if (Date.now() > deadline) throw new Error("Timed out waiting for " + label);
|
|
60
|
+
await sleep(25);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function captureStderr() {
|
|
65
|
+
const chunks = [];
|
|
66
|
+
const original = process.stderr.write;
|
|
67
|
+
process.stderr.write = (chunk) => {
|
|
68
|
+
chunks.push(String(chunk));
|
|
69
|
+
return true;
|
|
70
|
+
};
|
|
71
|
+
return {
|
|
72
|
+
chunks,
|
|
73
|
+
restore() {
|
|
74
|
+
process.stderr.write = original;
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function captureUI(runner) {
|
|
80
|
+
const notifications = [];
|
|
81
|
+
const statusKeys = [];
|
|
82
|
+
const widgetKeys = [];
|
|
83
|
+
const base = runner.createContext().ui ?? {};
|
|
84
|
+
const ui = {
|
|
85
|
+
...base,
|
|
86
|
+
theme: { ...(base.theme ?? {}), fg: (_color, text) => text },
|
|
87
|
+
notify: (message) => {
|
|
88
|
+
notifications.push(String(message));
|
|
89
|
+
},
|
|
90
|
+
setStatus: (key) => {
|
|
91
|
+
statusKeys.push(String(key));
|
|
92
|
+
},
|
|
93
|
+
setWidget: (key) => {
|
|
94
|
+
widgetKeys.push(String(key));
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
runner.setUIContext(ui, "tui");
|
|
98
|
+
return { notifications, statusKeys, widgetKeys };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function load(options = {}) {
|
|
102
|
+
const extensionPaths = options.extensionPaths ?? [extensionPath];
|
|
103
|
+
globalThis.fetch =
|
|
104
|
+
options.fetchImpl ??
|
|
105
|
+
(async (input) => {
|
|
106
|
+
throw new Error("network disabled in tests: " + String(input));
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
const { InMemoryCredentialStore } = await import("@earendil-works/pi-ai");
|
|
110
|
+
const { createAgentSession, DefaultResourceLoader, ModelRuntime, SessionManager } = await import(
|
|
111
|
+
"@earendil-works/pi-coding-agent"
|
|
112
|
+
);
|
|
113
|
+
const credentials = new InMemoryCredentialStore();
|
|
114
|
+
await credentials.modify("hypercharm", async () => ({ type: "api_key", key: "fixture-api-key" }));
|
|
115
|
+
|
|
116
|
+
const runtime = await ModelRuntime.create({
|
|
117
|
+
credentials,
|
|
118
|
+
modelsPath: path.join(agentDir, "models.json"),
|
|
119
|
+
refreshOnCreate: false,
|
|
120
|
+
});
|
|
121
|
+
const loader = new DefaultResourceLoader({
|
|
122
|
+
agentDir,
|
|
123
|
+
cwd: agentDir,
|
|
124
|
+
additionalExtensionPaths: extensionPaths,
|
|
125
|
+
noSkills: true,
|
|
126
|
+
noPromptTemplates: true,
|
|
127
|
+
noThemes: true,
|
|
128
|
+
noContextFiles: true,
|
|
129
|
+
});
|
|
130
|
+
await loader.reload();
|
|
131
|
+
assert.deepEqual(loader.getExtensions().errors, [], "extension must load without errors");
|
|
132
|
+
|
|
133
|
+
const sessionManager = options.sessionFile
|
|
134
|
+
? SessionManager.open(options.sessionFile)
|
|
135
|
+
: SessionManager.create(agentDir, path.join(agentDir, "sessions"));
|
|
136
|
+
const { session } = await createAgentSession({
|
|
137
|
+
agentDir,
|
|
138
|
+
cwd: agentDir,
|
|
139
|
+
modelRuntime: runtime,
|
|
140
|
+
resourceLoader: loader,
|
|
141
|
+
sessionManager,
|
|
142
|
+
noTools: "all",
|
|
143
|
+
});
|
|
144
|
+
// createAgentSession alone does not emit session_start (the pi CLI binds the
|
|
145
|
+
// session, and that binding emits it), so drive the lifecycle event here:
|
|
146
|
+
// the extension session path is part of what this suite verifies.
|
|
147
|
+
const runner = session.extensionRunner;
|
|
148
|
+
await runner.emit({ type: "session_start", reason: "startup" });
|
|
149
|
+
return { credentials, runtime, loader, session, sessionManager, runner };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function assistantMessage(overrides = {}) {
|
|
153
|
+
return {
|
|
154
|
+
role: "assistant",
|
|
155
|
+
content: [{ type: "text", text: "fixture response" }],
|
|
156
|
+
api: "hypercharm",
|
|
157
|
+
provider: "hypercharm",
|
|
158
|
+
model: "fixture-model",
|
|
159
|
+
usage: {
|
|
160
|
+
input: 1,
|
|
161
|
+
output: 2,
|
|
162
|
+
cacheRead: 0,
|
|
163
|
+
cacheWrite: 0,
|
|
164
|
+
totalTokens: 3,
|
|
165
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
166
|
+
},
|
|
167
|
+
stopReason: "stop",
|
|
168
|
+
timestamp: 1,
|
|
169
|
+
...overrides,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
test("loads offline: embedded catalog, deprecated grace, namespaced registrations, no silent failure", async () => {
|
|
174
|
+
const stderr = captureStderr();
|
|
175
|
+
let harness;
|
|
176
|
+
try {
|
|
177
|
+
harness = await load();
|
|
178
|
+
|
|
179
|
+
const embedded = harness.runtime.getModel("hypercharm", "deepseek-v4-flash");
|
|
180
|
+
assert.ok(embedded, "embedded catalog must register without any network");
|
|
181
|
+
assert.equal(embedded.contextWindow, 1000000);
|
|
182
|
+
assert.equal(embedded.maxTokens, 384000);
|
|
183
|
+
assert.equal(embedded.cost.input, 0.2);
|
|
184
|
+
assert.equal(EMBEDDED_AUTHORITY_NOTE, "tests must run against the embedded catalog");
|
|
185
|
+
|
|
186
|
+
// Deprecated grace: entries inside the 14-day TTL are still served,
|
|
187
|
+
// entries past it are evicted. Derived from the data, not today's date.
|
|
188
|
+
const deprecated = JSON.parse(readFileSync(deprecatedModelsPath, "utf8"));
|
|
189
|
+
const embeddedIds = new Set(
|
|
190
|
+
(Array.isArray(JSON.parse(readFileSync(embeddedModelsPath, "utf8")))
|
|
191
|
+
? JSON.parse(readFileSync(embeddedModelsPath, "utf8"))
|
|
192
|
+
: []
|
|
193
|
+
).map((model) => model.id),
|
|
194
|
+
);
|
|
195
|
+
const deprecatedOnly = Object.values(deprecated).filter((entry) => !embeddedIds.has(entry.id));
|
|
196
|
+
const fresh = deprecatedOnly.filter((entry) => Date.now() - Date.parse(entry.deprecatedAt) <= DEPRECATED_TTL_MS);
|
|
197
|
+
const stale = deprecatedOnly.filter((entry) => Date.now() - Date.parse(entry.deprecatedAt) > DEPRECATED_TTL_MS);
|
|
198
|
+
assert.ok(fresh.length > 0, "expected at least one model inside the deprecated grace window");
|
|
199
|
+
for (const entry of fresh) {
|
|
200
|
+
assert.ok(harness.runtime.getModel("hypercharm", entry.id), entry.id + " must be served during its grace period");
|
|
201
|
+
}
|
|
202
|
+
for (const entry of stale) {
|
|
203
|
+
assert.equal(harness.runtime.getModel("hypercharm", entry.id), undefined, entry.id + " must be evicted after its grace period");
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Namespacing: nothing is registered on the official provider's surfaces.
|
|
207
|
+
assert.equal(harness.runtime.getModel("hyper", "deepseek-v4-flash"), undefined, "must not register the official provider id");
|
|
208
|
+
assert.equal(harness.runner.getEntryRenderer("hyper-prism-route"), undefined, "must not register the official entry type");
|
|
209
|
+
assert.ok(harness.runner.getEntryRenderer(PRISM_ENTRY_TYPE), "prism entry renderer registered");
|
|
210
|
+
const commandNames = harness.runner.getRegisteredCommands().map((command) => command.name);
|
|
211
|
+
assert.ok(commandNames.includes("hypercharm-status"), "namespaced status command registered");
|
|
212
|
+
assert.equal(commandNames.includes("hyper-status"), false, "must not register the official command");
|
|
213
|
+
|
|
214
|
+
// Failures are surfaced, not swallowed.
|
|
215
|
+
const warning = await waitFor("catalog failure warning", () =>
|
|
216
|
+
stderr.chunks.find((chunk) => chunk.includes("model catalog")),
|
|
217
|
+
);
|
|
218
|
+
assert.match(warning, /network disabled/);
|
|
219
|
+
} finally {
|
|
220
|
+
stderr.restore();
|
|
221
|
+
harness?.session.dispose();
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test("refreshes from /v1/provider, caches the catalog, and retains it when refresh fails", async () => {
|
|
226
|
+
const requests = [];
|
|
227
|
+
const catalogOk = async (input, init) => {
|
|
228
|
+
const url = String(input);
|
|
229
|
+
requests.push(url);
|
|
230
|
+
assert.equal(url, CATALOG_URL);
|
|
231
|
+
assert.equal(new Headers(init?.headers).get("Authorization"), "Bearer fixture-api-key");
|
|
232
|
+
return new Response(JSON.stringify({ models: [FIXTURE_MODEL] }), {
|
|
233
|
+
status: 200,
|
|
234
|
+
headers: { "content-type": "application/json" },
|
|
235
|
+
});
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
const first = await load({ fetchImpl: catalogOk });
|
|
239
|
+
try {
|
|
240
|
+
const model = await waitFor("hot-swapped fixture model", () => first.runtime.getModel("hypercharm", "fixture-model"));
|
|
241
|
+
assert.equal(model.contextWindow, 8192);
|
|
242
|
+
assert.equal(model.maxTokens, 1024);
|
|
243
|
+
assert.deepEqual(model.input, ["text", "image"]);
|
|
244
|
+
assert.equal(model.cost.cacheRead, 0.5);
|
|
245
|
+
assert.equal(model.cost.cacheWrite, 1);
|
|
246
|
+
assert.equal(model.compat.supportsReasoningEffort, false);
|
|
247
|
+
assert.ok(requests.length >= 1, "the catalog endpoint must be the refresh source");
|
|
248
|
+
|
|
249
|
+
const cachePath = path.join(agentDir, "cache", "hypercharm-models.json");
|
|
250
|
+
const cached = JSON.parse(readFileSync(cachePath, "utf8"));
|
|
251
|
+
assert.ok(Array.isArray(cached), "catalog cache written as an array");
|
|
252
|
+
assert.ok(cached.some((entry) => entry.id === "fixture-model"), "cache holds the refreshed catalog");
|
|
253
|
+
} finally {
|
|
254
|
+
first.session.dispose();
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// A later session keeps serving the cached catalog even though Hyper is down.
|
|
258
|
+
const stderr = captureStderr();
|
|
259
|
+
const second = await load({ fetchImpl: async () => new Response("Unavailable", { status: 503 }) });
|
|
260
|
+
try {
|
|
261
|
+
assert.ok(second.runtime.getModel("hypercharm", "fixture-model"), "cached catalog retained across a failed refresh");
|
|
262
|
+
const warning = await waitFor("failed refresh warning", () => stderr.chunks.find((chunk) => chunk.includes("HTTP 503")));
|
|
263
|
+
assert.match(warning, /model catalog/);
|
|
264
|
+
} finally {
|
|
265
|
+
stderr.restore();
|
|
266
|
+
second.session.dispose();
|
|
267
|
+
}
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
test("records prism routing as durable session entries", async () => {
|
|
271
|
+
const harness = await load();
|
|
272
|
+
const { runner, sessionManager } = harness;
|
|
273
|
+
let disposed = false;
|
|
274
|
+
try {
|
|
275
|
+
const routes = () =>
|
|
276
|
+
sessionManager.getEntries().filter((entry) => entry.type === "custom" && entry.customType === PRISM_ENTRY_TYPE);
|
|
277
|
+
const message = assistantMessage();
|
|
278
|
+
// pi appends the assistant message as part of a real turn, and that write
|
|
279
|
+
// is what flushes buffered custom entries through to the session file.
|
|
280
|
+
sessionManager.appendMessage(message);
|
|
281
|
+
const ui = captureUI(runner);
|
|
282
|
+
|
|
283
|
+
const cases = [
|
|
284
|
+
[{ "x-prism-model-name": " GLM 5.3 Flash ", "x-prism-model-id": "glm-5.3-flash" }, { modelName: "GLM 5.3 Flash", modelId: "glm-5.3-flash" }],
|
|
285
|
+
[{ "x-prism-model-name": "GLM 5.3 Flash" }, { modelName: "GLM 5.3 Flash", modelId: undefined }],
|
|
286
|
+
[{ "x-prism-model-id": "glm-5.3-flash" }, { modelName: undefined, modelId: "glm-5.3-flash" }],
|
|
287
|
+
[{}, undefined],
|
|
288
|
+
[{ "x-prism-model-name": " " }, undefined],
|
|
289
|
+
[{ "x-prism-model-name": "bad\u001b[31m" }, undefined],
|
|
290
|
+
[{ "x-prism-model-name": "bad\nline" }, undefined],
|
|
291
|
+
[{ "x-prism-model-name": "bad\u202etext" }, undefined],
|
|
292
|
+
[{ "x-prism-model-name": "x".repeat(201) }, undefined],
|
|
293
|
+
];
|
|
294
|
+
let expectedEntries = 0;
|
|
295
|
+
let turnIndex = 0;
|
|
296
|
+
for (const [headers, expected] of cases) {
|
|
297
|
+
await runner.emit({ type: "turn_start", turnIndex, timestamp: 1 });
|
|
298
|
+
await runner.emit({ type: "after_provider_response", status: 200, headers });
|
|
299
|
+
await runner.emitMessageEnd({ type: "message_end", message });
|
|
300
|
+
await runner.emit({ type: "turn_end", turnIndex, message, toolResults: [] });
|
|
301
|
+
turnIndex += 1;
|
|
302
|
+
if (expected === undefined) {
|
|
303
|
+
assert.equal(routes().length, expectedEntries, "unusable headers must not record a route: " + JSON.stringify(headers));
|
|
304
|
+
} else {
|
|
305
|
+
expectedEntries += 1;
|
|
306
|
+
assert.deepEqual(routes().at(-1)?.data, expected);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
assert.equal(routes().length, 3, "exactly the three usable routes are recorded");
|
|
310
|
+
|
|
311
|
+
// Auxiliary responses outside the assistant request must not leak.
|
|
312
|
+
const before = routes().length;
|
|
313
|
+
const headers = { "x-prism-model-name": "Do not display" };
|
|
314
|
+
await runner.emit({ type: "after_provider_response", status: 200, headers });
|
|
315
|
+
await runner.emit({ type: "turn_start", turnIndex, timestamp: 2 });
|
|
316
|
+
await runner.emitMessageEnd({ type: "message_end", message });
|
|
317
|
+
await runner.emit({ type: "after_provider_response", status: 200, headers });
|
|
318
|
+
await runner.emit({ type: "turn_end", turnIndex, message, toolResults: [] });
|
|
319
|
+
assert.equal(routes().length, before, "requests between turns must not record a route");
|
|
320
|
+
|
|
321
|
+
// Cancelled, failed, and other-provider turns are dropped.
|
|
322
|
+
for (const stopReason of ["aborted", "error"]) {
|
|
323
|
+
await runner.emit({ type: "turn_start", turnIndex, timestamp: 3 });
|
|
324
|
+
await runner.emit({ type: "after_provider_response", status: 200, headers: { "x-prism-model-name": "GLM 5.3 Flash" } });
|
|
325
|
+
await runner.emitMessageEnd({ type: "message_end", message: { ...message, stopReason } });
|
|
326
|
+
await runner.emit({ type: "turn_end", turnIndex, message: { ...message, stopReason }, toolResults: [] });
|
|
327
|
+
}
|
|
328
|
+
await runner.emit({ type: "turn_start", turnIndex, timestamp: 4 });
|
|
329
|
+
await runner.emit({ type: "after_provider_response", status: 200, headers: { "x-prism-model-name": "GLM 5.3 Flash" } });
|
|
330
|
+
await runner.emit({ type: "turn_end", turnIndex, message: { ...message, provider: "other" }, toolResults: [] });
|
|
331
|
+
assert.equal(routes().length, before, "aborted, failed, and other-provider turns must not record a route");
|
|
332
|
+
assert.equal(
|
|
333
|
+
ui.notifications.some((notification) => notification.includes("Prism")),
|
|
334
|
+
false,
|
|
335
|
+
"routing must use durable entries, not notifications",
|
|
336
|
+
);
|
|
337
|
+
|
|
338
|
+
// The renderer is registered and rejects unsafe saved labels.
|
|
339
|
+
const renderer = runner.getEntryRenderer(PRISM_ENTRY_TYPE);
|
|
340
|
+
assert.ok(renderer);
|
|
341
|
+
const theme = { fg: (_color, text) => text };
|
|
342
|
+
const [firstRoute] = routes();
|
|
343
|
+
assert.equal(renderer({ ...firstRoute, data: { modelName: 42 } }, { expanded: false }, theme), undefined);
|
|
344
|
+
assert.equal(renderer({ ...firstRoute, data: { modelName: "bad\u001b[31m" } }, { expanded: false }, theme), undefined);
|
|
345
|
+
const component = renderer(firstRoute, { expanded: false }, theme);
|
|
346
|
+
assert.ok(component);
|
|
347
|
+
assert.ok(component.render(80).join("\n").includes("Prism \u2192 GLM 5.3 Flash"));
|
|
348
|
+
|
|
349
|
+
// Routes are durable: dispose first (pi flushes on dispose), then reopen
|
|
350
|
+
// the session file in a fresh session and verify the entries and the
|
|
351
|
+
// renderer registration both survive.
|
|
352
|
+
const sessionFile = sessionManager.getSessionFile();
|
|
353
|
+
assert.ok(sessionFile);
|
|
354
|
+
harness.session.dispose();
|
|
355
|
+
disposed = true;
|
|
356
|
+
|
|
357
|
+
const restored = await load({ sessionFile });
|
|
358
|
+
try {
|
|
359
|
+
const persisted = restored.sessionManager
|
|
360
|
+
.getEntries()
|
|
361
|
+
.filter((entry) => entry.type === "custom" && entry.customType === PRISM_ENTRY_TYPE);
|
|
362
|
+
assert.equal(persisted.length, 3, "routes survive reopening the session");
|
|
363
|
+
assert.deepEqual(persisted[0]?.data, { modelName: "GLM 5.3 Flash", modelId: "glm-5.3-flash" });
|
|
364
|
+
assert.equal(
|
|
365
|
+
restored.sessionManager.buildSessionContext().messages.some((entry) => entry.role === "custom"),
|
|
366
|
+
false,
|
|
367
|
+
"routing entries must not enter model context",
|
|
368
|
+
);
|
|
369
|
+
const restoredRenderer = restored.runner.getEntryRenderer(PRISM_ENTRY_TYPE);
|
|
370
|
+
assert.ok(restoredRenderer, "renderer registration survives a session reload");
|
|
371
|
+
const restoredComponent = restoredRenderer(persisted[0], { expanded: false }, theme);
|
|
372
|
+
assert.ok(restoredComponent);
|
|
373
|
+
assert.ok(restoredComponent.render(80).join("\n").includes("Prism \u2192 GLM 5.3 Flash"));
|
|
374
|
+
} finally {
|
|
375
|
+
restored.session.dispose();
|
|
376
|
+
}
|
|
377
|
+
} finally {
|
|
378
|
+
if (!disposed) harness.session.dispose();
|
|
379
|
+
}
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
test("co-installs with the official identifier surface without interference", async () => {
|
|
383
|
+
const harness = await load({ extensionPaths: [extensionPath, officialSurfacePath] });
|
|
384
|
+
try {
|
|
385
|
+
// Same model id, two providers: provider-scoped resolution must not collide.
|
|
386
|
+
const ours = harness.runtime.getModel("hypercharm", "glm-5.3");
|
|
387
|
+
const official = harness.runtime.getModel("hyper", "glm-5.3");
|
|
388
|
+
assert.ok(ours, "our provider serves glm-5.3");
|
|
389
|
+
assert.ok(official, "the official-surface provider serves its own glm-5.3");
|
|
390
|
+
assert.equal(ours.provider, "hypercharm");
|
|
391
|
+
assert.equal(official.provider, "hyper");
|
|
392
|
+
assert.equal(official.name, "Official fixture GLM 5.3");
|
|
393
|
+
assert.notEqual(ours.name, official.name);
|
|
394
|
+
|
|
395
|
+
const commandNames = harness.runner.getRegisteredCommands().map((command) => command.name);
|
|
396
|
+
assert.ok(commandNames.includes("hypercharm-status"));
|
|
397
|
+
assert.ok(commandNames.includes("hyper-status"));
|
|
398
|
+
assert.ok(harness.runner.getEntryRenderer(PRISM_ENTRY_TYPE));
|
|
399
|
+
assert.ok(harness.runner.getEntryRenderer("hyper-prism-route"));
|
|
400
|
+
|
|
401
|
+
// Status writes stay in their own namespaces: neither extension can
|
|
402
|
+
// cross-clear the other's footer slots.
|
|
403
|
+
const ui = captureUI(harness.runner);
|
|
404
|
+
await harness.runner.emit({ type: "turn_end", turnIndex: 0, message: assistantMessage(), toolResults: [] });
|
|
405
|
+
const ownKeys = ui.statusKeys.filter((key) => key.startsWith("hypercharm"));
|
|
406
|
+
const officialKeys = ui.statusKeys.filter((key) => key === "hyper");
|
|
407
|
+
assert.ok(officialKeys.length > 0, "the official fixture writes its own status key");
|
|
408
|
+
assert.ok(ownKeys.length > 0, "our extension writes its namespaced status keys");
|
|
409
|
+
for (const key of ui.widgetKeys) {
|
|
410
|
+
assert.ok(key.startsWith("hypercharm"), "widget key must stay namespaced: " + key);
|
|
411
|
+
}
|
|
412
|
+
assert.equal(ui.statusKeys.includes("hyper") && ownKeys.includes("hyper"), false);
|
|
413
|
+
} finally {
|
|
414
|
+
harness.session.dispose();
|
|
415
|
+
}
|
|
416
|
+
});
|