pi-hypercharm-provider 1.3.27 → 1.3.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,485 @@
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
+ // Install (component factory) vs clear (undefined), split so a test can
84
+ // assert both "the widget popped up" and "the widget went away".
85
+ const widgetInstalls = [];
86
+ const widgetClears = [];
87
+ const widgetRenders = [];
88
+ const base = runner.createContext().ui ?? {};
89
+ const ui = {
90
+ ...base,
91
+ theme: { ...(base.theme ?? {}), fg: (_color, text) => text },
92
+ notify: (message) => {
93
+ notifications.push(String(message));
94
+ },
95
+ setStatus: (key) => {
96
+ statusKeys.push(String(key));
97
+ },
98
+ setWidget: (key, value) => {
99
+ const name = String(key);
100
+ widgetKeys.push(name);
101
+ if (value === undefined) {
102
+ widgetClears.push(name);
103
+ return;
104
+ }
105
+ widgetInstalls.push(name);
106
+ try {
107
+ const component = value(undefined, ui.theme);
108
+ widgetRenders.push(component?.render ? component.render(80).join("\n") : "");
109
+ } catch {
110
+ widgetRenders.push("");
111
+ }
112
+ },
113
+ };
114
+ runner.setUIContext(ui, "tui");
115
+ return { notifications, statusKeys, widgetKeys, widgetInstalls, widgetClears, widgetRenders };
116
+ }
117
+
118
+ async function load(options = {}) {
119
+ const extensionPaths = options.extensionPaths ?? [extensionPath];
120
+ globalThis.fetch =
121
+ options.fetchImpl ??
122
+ (async (input) => {
123
+ throw new Error("network disabled in tests: " + String(input));
124
+ });
125
+
126
+ const { InMemoryCredentialStore } = await import("@earendil-works/pi-ai");
127
+ const { createAgentSession, DefaultResourceLoader, ModelRuntime, SessionManager } = await import(
128
+ "@earendil-works/pi-coding-agent"
129
+ );
130
+ const credentials = new InMemoryCredentialStore();
131
+ await credentials.modify("hypercharm", async () => ({ type: "api_key", key: "fixture-api-key" }));
132
+
133
+ const runtime = await ModelRuntime.create({
134
+ credentials,
135
+ modelsPath: path.join(agentDir, "models.json"),
136
+ refreshOnCreate: false,
137
+ });
138
+ const loader = new DefaultResourceLoader({
139
+ agentDir,
140
+ cwd: agentDir,
141
+ additionalExtensionPaths: extensionPaths,
142
+ noSkills: true,
143
+ noPromptTemplates: true,
144
+ noThemes: true,
145
+ noContextFiles: true,
146
+ });
147
+ await loader.reload();
148
+ assert.deepEqual(loader.getExtensions().errors, [], "extension must load without errors");
149
+
150
+ const sessionManager = options.sessionFile
151
+ ? SessionManager.open(options.sessionFile)
152
+ : SessionManager.create(agentDir, path.join(agentDir, "sessions"));
153
+ const { session } = await createAgentSession({
154
+ agentDir,
155
+ cwd: agentDir,
156
+ modelRuntime: runtime,
157
+ resourceLoader: loader,
158
+ sessionManager,
159
+ noTools: "all",
160
+ });
161
+ // createAgentSession alone does not emit session_start (the pi CLI binds the
162
+ // session, and that binding emits it), so drive the lifecycle event here:
163
+ // the extension session path is part of what this suite verifies.
164
+ const runner = session.extensionRunner;
165
+ await runner.emit({ type: "session_start", reason: "startup" });
166
+ return { credentials, runtime, loader, session, sessionManager, runner };
167
+ }
168
+
169
+ function assistantMessage(overrides = {}) {
170
+ return {
171
+ role: "assistant",
172
+ content: [{ type: "text", text: "fixture response" }],
173
+ api: "hypercharm",
174
+ provider: "hypercharm",
175
+ model: "fixture-model",
176
+ usage: {
177
+ input: 1,
178
+ output: 2,
179
+ cacheRead: 0,
180
+ cacheWrite: 0,
181
+ totalTokens: 3,
182
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
183
+ },
184
+ stopReason: "stop",
185
+ timestamp: 1,
186
+ ...overrides,
187
+ };
188
+ }
189
+
190
+ test("loads offline: embedded catalog, deprecated grace, namespaced registrations, no silent failure", async () => {
191
+ const stderr = captureStderr();
192
+ let harness;
193
+ try {
194
+ harness = await load();
195
+
196
+ const embedded = harness.runtime.getModel("hypercharm", "deepseek-v4-flash");
197
+ assert.ok(embedded, "embedded catalog must register without any network");
198
+ assert.equal(embedded.contextWindow, 1000000);
199
+ assert.equal(embedded.maxTokens, 384000);
200
+ assert.equal(embedded.cost.input, 0.2);
201
+ assert.equal(EMBEDDED_AUTHORITY_NOTE, "tests must run against the embedded catalog");
202
+
203
+ // Deprecated grace: entries inside the 14-day TTL are still served,
204
+ // entries past it are evicted. Derived from the data, not today's date.
205
+ const deprecated = JSON.parse(readFileSync(deprecatedModelsPath, "utf8"));
206
+ const embeddedIds = new Set(
207
+ (Array.isArray(JSON.parse(readFileSync(embeddedModelsPath, "utf8")))
208
+ ? JSON.parse(readFileSync(embeddedModelsPath, "utf8"))
209
+ : []
210
+ ).map((model) => model.id),
211
+ );
212
+ const deprecatedOnly = Object.values(deprecated).filter((entry) => !embeddedIds.has(entry.id));
213
+ const fresh = deprecatedOnly.filter((entry) => Date.now() - Date.parse(entry.deprecatedAt) <= DEPRECATED_TTL_MS);
214
+ const stale = deprecatedOnly.filter((entry) => Date.now() - Date.parse(entry.deprecatedAt) > DEPRECATED_TTL_MS);
215
+ assert.ok(fresh.length > 0, "expected at least one model inside the deprecated grace window");
216
+ for (const entry of fresh) {
217
+ assert.ok(harness.runtime.getModel("hypercharm", entry.id), entry.id + " must be served during its grace period");
218
+ }
219
+ for (const entry of stale) {
220
+ assert.equal(harness.runtime.getModel("hypercharm", entry.id), undefined, entry.id + " must be evicted after its grace period");
221
+ }
222
+
223
+ // Namespacing: nothing is registered on the official provider's surfaces.
224
+ assert.equal(harness.runtime.getModel("hyper", "deepseek-v4-flash"), undefined, "must not register the official provider id");
225
+ assert.equal(harness.runner.getEntryRenderer("hyper-prism-route"), undefined, "must not register the official entry type");
226
+ assert.ok(harness.runner.getEntryRenderer(PRISM_ENTRY_TYPE), "prism entry renderer registered");
227
+ const commandNames = harness.runner.getRegisteredCommands().map((command) => command.name);
228
+ assert.ok(commandNames.includes("hypercharm-status"), "namespaced status command registered");
229
+ assert.equal(commandNames.includes("hyper-status"), false, "must not register the official command");
230
+
231
+ // Failures are surfaced, not swallowed.
232
+ const warning = await waitFor("catalog failure warning", () =>
233
+ stderr.chunks.find((chunk) => chunk.includes("model catalog")),
234
+ );
235
+ assert.match(warning, /network disabled/);
236
+ } finally {
237
+ stderr.restore();
238
+ harness?.session.dispose();
239
+ }
240
+ });
241
+
242
+ test("refreshes from /v1/provider, caches the catalog, and retains it when refresh fails", async () => {
243
+ const requests = [];
244
+ const catalogOk = async (input, init) => {
245
+ const url = String(input);
246
+ requests.push(url);
247
+ assert.equal(url, CATALOG_URL);
248
+ assert.equal(new Headers(init?.headers).get("Authorization"), "Bearer fixture-api-key");
249
+ return new Response(JSON.stringify({ models: [FIXTURE_MODEL] }), {
250
+ status: 200,
251
+ headers: { "content-type": "application/json" },
252
+ });
253
+ };
254
+
255
+ const first = await load({ fetchImpl: catalogOk });
256
+ try {
257
+ const model = await waitFor("hot-swapped fixture model", () => first.runtime.getModel("hypercharm", "fixture-model"));
258
+ assert.equal(model.contextWindow, 8192);
259
+ assert.equal(model.maxTokens, 1024);
260
+ assert.deepEqual(model.input, ["text", "image"]);
261
+ assert.equal(model.cost.cacheRead, 0.5);
262
+ assert.equal(model.cost.cacheWrite, 1);
263
+ assert.equal(model.compat.supportsReasoningEffort, false);
264
+ assert.ok(requests.length >= 1, "the catalog endpoint must be the refresh source");
265
+
266
+ const cachePath = path.join(agentDir, "cache", "hypercharm-models.json");
267
+ const cached = JSON.parse(readFileSync(cachePath, "utf8"));
268
+ assert.ok(Array.isArray(cached), "catalog cache written as an array");
269
+ assert.ok(cached.some((entry) => entry.id === "fixture-model"), "cache holds the refreshed catalog");
270
+ } finally {
271
+ first.session.dispose();
272
+ }
273
+
274
+ // A later session keeps serving the cached catalog even though Hyper is down.
275
+ const stderr = captureStderr();
276
+ const second = await load({ fetchImpl: async () => new Response("Unavailable", { status: 503 }) });
277
+ try {
278
+ assert.ok(second.runtime.getModel("hypercharm", "fixture-model"), "cached catalog retained across a failed refresh");
279
+ const warning = await waitFor("failed refresh warning", () => stderr.chunks.find((chunk) => chunk.includes("HTTP 503")));
280
+ assert.match(warning, /model catalog/);
281
+ } finally {
282
+ stderr.restore();
283
+ second.session.dispose();
284
+ }
285
+ });
286
+
287
+ test("records prism routing as durable session entries", async () => {
288
+ const harness = await load();
289
+ const { runner, sessionManager } = harness;
290
+ let disposed = false;
291
+ try {
292
+ const routes = () =>
293
+ sessionManager.getEntries().filter((entry) => entry.type === "custom" && entry.customType === PRISM_ENTRY_TYPE);
294
+ const message = assistantMessage();
295
+ // pi appends the assistant message as part of a real turn, and that write
296
+ // is what flushes buffered custom entries through to the session file.
297
+ sessionManager.appendMessage(message);
298
+ const ui = captureUI(runner);
299
+
300
+ const cases = [
301
+ [{ "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" }],
302
+ [{ "x-prism-model-name": "GLM 5.3 Flash" }, { modelName: "GLM 5.3 Flash", modelId: undefined }],
303
+ [{ "x-prism-model-id": "glm-5.3-flash" }, { modelName: undefined, modelId: "glm-5.3-flash" }],
304
+ [{}, undefined],
305
+ [{ "x-prism-model-name": " " }, undefined],
306
+ [{ "x-prism-model-name": "bad\u001b[31m" }, undefined],
307
+ [{ "x-prism-model-name": "bad\nline" }, undefined],
308
+ [{ "x-prism-model-name": "bad\u202etext" }, undefined],
309
+ [{ "x-prism-model-name": "x".repeat(201) }, undefined],
310
+ ];
311
+ let expectedEntries = 0;
312
+ let turnIndex = 0;
313
+ for (const [headers, expected] of cases) {
314
+ await runner.emit({ type: "turn_start", turnIndex, timestamp: 1 });
315
+ await runner.emit({ type: "after_provider_response", status: 200, headers });
316
+ await runner.emitMessageEnd({ type: "message_end", message });
317
+ await runner.emit({ type: "turn_end", turnIndex, message, toolResults: [] });
318
+ turnIndex += 1;
319
+ if (expected === undefined) {
320
+ assert.equal(routes().length, expectedEntries, "unusable headers must not record a route: " + JSON.stringify(headers));
321
+ } else {
322
+ expectedEntries += 1;
323
+ assert.deepEqual(routes().at(-1)?.data, expected);
324
+ }
325
+ }
326
+ assert.equal(routes().length, 3, "exactly the three usable routes are recorded");
327
+
328
+ // Auxiliary responses outside the assistant request must not leak.
329
+ const before = routes().length;
330
+ const headers = { "x-prism-model-name": "Do not display" };
331
+ await runner.emit({ type: "after_provider_response", status: 200, headers });
332
+ await runner.emit({ type: "turn_start", turnIndex, timestamp: 2 });
333
+ await runner.emitMessageEnd({ type: "message_end", message });
334
+ await runner.emit({ type: "after_provider_response", status: 200, headers });
335
+ await runner.emit({ type: "turn_end", turnIndex, message, toolResults: [] });
336
+ assert.equal(routes().length, before, "requests between turns must not record a route");
337
+
338
+ // Cancelled, failed, and other-provider turns are dropped.
339
+ for (const stopReason of ["aborted", "error"]) {
340
+ await runner.emit({ type: "turn_start", turnIndex, timestamp: 3 });
341
+ await runner.emit({ type: "after_provider_response", status: 200, headers: { "x-prism-model-name": "GLM 5.3 Flash" } });
342
+ await runner.emitMessageEnd({ type: "message_end", message: { ...message, stopReason } });
343
+ await runner.emit({ type: "turn_end", turnIndex, message: { ...message, stopReason }, toolResults: [] });
344
+ }
345
+ await runner.emit({ type: "turn_start", turnIndex, timestamp: 4 });
346
+ await runner.emit({ type: "after_provider_response", status: 200, headers: { "x-prism-model-name": "GLM 5.3 Flash" } });
347
+ await runner.emit({ type: "turn_end", turnIndex, message: { ...message, provider: "other" }, toolResults: [] });
348
+ assert.equal(routes().length, before, "aborted, failed, and other-provider turns must not record a route");
349
+ assert.equal(
350
+ ui.notifications.some((notification) => notification.includes("Prism")),
351
+ false,
352
+ "routing must use durable entries, not notifications",
353
+ );
354
+
355
+ // The renderer is registered and rejects unsafe saved labels.
356
+ const renderer = runner.getEntryRenderer(PRISM_ENTRY_TYPE);
357
+ assert.ok(renderer);
358
+ const theme = { fg: (_color, text) => text };
359
+ const [firstRoute] = routes();
360
+ assert.equal(renderer({ ...firstRoute, data: { modelName: 42 } }, { expanded: false }, theme), undefined);
361
+ assert.equal(renderer({ ...firstRoute, data: { modelName: "bad\u001b[31m" } }, { expanded: false }, theme), undefined);
362
+ const component = renderer(firstRoute, { expanded: false }, theme);
363
+ assert.ok(component);
364
+ assert.ok(component.render(80).join("\n").includes("Prism \u2192 GLM 5.3 Flash"));
365
+
366
+ // Routes are durable: dispose first (pi flushes on dispose), then reopen
367
+ // the session file in a fresh session and verify the entries and the
368
+ // renderer registration both survive.
369
+ const sessionFile = sessionManager.getSessionFile();
370
+ assert.ok(sessionFile);
371
+ harness.session.dispose();
372
+ disposed = true;
373
+
374
+ const restored = await load({ sessionFile });
375
+ try {
376
+ const persisted = restored.sessionManager
377
+ .getEntries()
378
+ .filter((entry) => entry.type === "custom" && entry.customType === PRISM_ENTRY_TYPE);
379
+ assert.equal(persisted.length, 3, "routes survive reopening the session");
380
+ assert.deepEqual(persisted[0]?.data, { modelName: "GLM 5.3 Flash", modelId: "glm-5.3-flash" });
381
+ assert.equal(
382
+ restored.sessionManager.buildSessionContext().messages.some((entry) => entry.role === "custom"),
383
+ false,
384
+ "routing entries must not enter model context",
385
+ );
386
+ const restoredRenderer = restored.runner.getEntryRenderer(PRISM_ENTRY_TYPE);
387
+ assert.ok(restoredRenderer, "renderer registration survives a session reload");
388
+ const restoredComponent = restoredRenderer(persisted[0], { expanded: false }, theme);
389
+ assert.ok(restoredComponent);
390
+ assert.ok(restoredComponent.render(80).join("\n").includes("Prism \u2192 GLM 5.3 Flash"));
391
+ } finally {
392
+ restored.session.dispose();
393
+ }
394
+ } finally {
395
+ if (!disposed) harness.session.dispose();
396
+ }
397
+ });
398
+
399
+ test("co-installs with the official identifier surface without interference", async () => {
400
+ const harness = await load({ extensionPaths: [extensionPath, officialSurfacePath] });
401
+ try {
402
+ // Same model id, two providers: provider-scoped resolution must not collide.
403
+ const ours = harness.runtime.getModel("hypercharm", "glm-5.3");
404
+ const official = harness.runtime.getModel("hyper", "glm-5.3");
405
+ assert.ok(ours, "our provider serves glm-5.3");
406
+ assert.ok(official, "the official-surface provider serves its own glm-5.3");
407
+ assert.equal(ours.provider, "hypercharm");
408
+ assert.equal(official.provider, "hyper");
409
+ assert.equal(official.name, "Official fixture GLM 5.3");
410
+ assert.notEqual(ours.name, official.name);
411
+
412
+ const commandNames = harness.runner.getRegisteredCommands().map((command) => command.name);
413
+ assert.ok(commandNames.includes("hypercharm-status"));
414
+ assert.ok(commandNames.includes("hyper-status"));
415
+ assert.ok(harness.runner.getEntryRenderer(PRISM_ENTRY_TYPE));
416
+ assert.ok(harness.runner.getEntryRenderer("hyper-prism-route"));
417
+
418
+ // Status writes stay in their own namespaces: neither extension can
419
+ // cross-clear the other's footer slots.
420
+ const ui = captureUI(harness.runner);
421
+ await harness.runner.emit({ type: "turn_end", turnIndex: 0, message: assistantMessage(), toolResults: [] });
422
+ const ownKeys = ui.statusKeys.filter((key) => key.startsWith("hypercharm"));
423
+ const officialKeys = ui.statusKeys.filter((key) => key === "hyper");
424
+ assert.ok(officialKeys.length > 0, "the official fixture writes its own status key");
425
+ assert.ok(ownKeys.length > 0, "our extension writes its namespaced status keys");
426
+ for (const key of ui.widgetKeys) {
427
+ assert.ok(key.startsWith("hypercharm"), "widget key must stay namespaced: " + key);
428
+ }
429
+ assert.equal(ui.statusKeys.includes("hyper") && ownKeys.includes("hyper"), false);
430
+ } finally {
431
+ harness.session.dispose();
432
+ }
433
+ });
434
+
435
+ test("shows the footer widget on model selection and clears it when the provider is deselected", async () => {
436
+ const requests = [];
437
+ const json = (body) => new Response(JSON.stringify(body), { status: 200, headers: { "content-type": "application/json" } });
438
+ const accountApi = async (input) => {
439
+ const url = String(input);
440
+ requests.push(url);
441
+ if (url.endsWith("/credits")) return json({ balance: 249 });
442
+ if (url.endsWith("/teams")) return json({ items: [{ name: "Xu's Team" }] });
443
+ if (url.endsWith("/devices")) return json({ items: [] });
444
+ if (url.endsWith("/provider")) return json({ models: [FIXTURE_MODEL] });
445
+ throw new Error("unexpected request: " + url);
446
+ };
447
+ const harness = await load({ extensionPaths: [extensionPath, officialSurfacePath], fetchImpl: accountApi });
448
+ try {
449
+ // The model_select handler only refreshes credits once session_start has
450
+ // cached a key, so wait for the catalog refresh that follows resolution.
451
+ await waitFor("session-start catalog refresh", () =>
452
+ requests.some((url) => url.endsWith("/provider")) ? true : undefined,
453
+ );
454
+
455
+ const ui = captureUI(harness.runner);
456
+ const ours = harness.runtime.getModel("hypercharm", "deepseek-v4-flash");
457
+ assert.ok(ours, "embedded catalog serves deepseek-v4-flash");
458
+ await harness.session.setModel(ours);
459
+ // No turn_end is ever emitted in this test: selection alone must install
460
+ // the widget, with the account side already filled in.
461
+ await waitFor("widget installed on selection", () => (ui.widgetInstalls.includes("hypercharm") ? true : undefined));
462
+ const filled = await waitFor("account side in the widget", () =>
463
+ ui.widgetRenders.find((line) => line.includes("Xu's Team")),
464
+ );
465
+ assert.match(filled, /249 hc/, "balance renders from the credits prefetch");
466
+
467
+ const installsWhileSelected = ui.widgetInstalls.length;
468
+ await harness.credentials.modify("hyper", async () => ({ type: "api_key", key: "fixture-official-key" }));
469
+ const official = harness.runtime.getModel("hyper", "glm-5.3");
470
+ assert.ok(official, "the official-surface fixture serves glm-5.3");
471
+ await harness.session.setModel(official);
472
+ await waitFor("widget cleared on deselect", () => (ui.widgetClears.includes("hypercharm") ? true : undefined));
473
+
474
+ // Deselected stays deselected: another provider's turn must not bring the
475
+ // line back (hideOnOtherProvider defaults to true).
476
+ await harness.runner.emit({ type: "turn_end", turnIndex: 0, message: assistantMessage(), toolResults: [] });
477
+ assert.equal(
478
+ ui.widgetInstalls.length,
479
+ installsWhileSelected,
480
+ "no reinstall while another provider's model is active",
481
+ );
482
+ } finally {
483
+ harness.session.dispose();
484
+ }
485
+ });