pi-llama-cpp 0.4.0 → 0.5.1
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 +3 -2
- package/package.json +4 -5
- package/src/commands/models.ts +39 -4
- package/src/constants.ts +5 -5
- package/src/index.ts +20 -0
- package/src/manager.ts +11 -2
- package/src/models/baseModel.ts +2 -2
- package/src/tools/resolver.ts +3 -8
- package/tests/commandManager.test.ts +37 -6
- package/tests/modelsCommand.test.ts +138 -2
- package/tests/singleModel.test.ts +3 -1
package/README.md
CHANGED
|
@@ -111,6 +111,7 @@ The extension determines the context size as follows:
|
|
|
111
111
|
| ---------------- | ------------------------------------------------------------------------------------------ |
|
|
112
112
|
| `/models` | Browse your models with live status. Select a model to load, switch, or unload it. |
|
|
113
113
|
| `/models info` | Show detailed information for all available models at once. |
|
|
114
|
+
| `/models unload` | Unload all loaded models at once (Note: this only makes sense in router mode). |
|
|
114
115
|
|
|
115
116
|
> **Note:** When the llama.cpp server is unreachable, `/models` displays an error notification with the configured server URL.
|
|
116
117
|
|
|
@@ -142,9 +143,9 @@ When you trigger a load, switch, or retry action, the extension polls the server
|
|
|
142
143
|
|
|
143
144
|
Each model exposed to Pi includes the following defaults:
|
|
144
145
|
|
|
145
|
-
- **`maxTokens`** —
|
|
146
|
+
- **`maxTokens`** — dynamically set to the model's context window (detected from llama-server)
|
|
146
147
|
- **`reasoning`** — `true` (assumed, as llama.cpp's `/models` endpoint does not expose it)
|
|
147
|
-
- **`cost`** — all zero (local
|
|
148
|
+
- **`cost`** — all zero (local models)
|
|
148
149
|
|
|
149
150
|
## Dependencies
|
|
150
151
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-llama-cpp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.1",
|
|
4
4
|
"description": "Pi extension for llama.cpp integration. Supports both router and single modes.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi",
|
|
@@ -24,8 +24,7 @@
|
|
|
24
24
|
]
|
|
25
25
|
},
|
|
26
26
|
"scripts": {
|
|
27
|
-
"test": "vitest"
|
|
28
|
-
"test:run": "vitest run"
|
|
27
|
+
"test": "vitest run"
|
|
29
28
|
},
|
|
30
29
|
"prettier": {
|
|
31
30
|
"plugins": [
|
|
@@ -36,8 +35,8 @@
|
|
|
36
35
|
"@earendil-works/pi-coding-agent": "*"
|
|
37
36
|
},
|
|
38
37
|
"devDependencies": {
|
|
39
|
-
"@types/node": "^25.
|
|
38
|
+
"@types/node": "^25.9.1",
|
|
40
39
|
"prettier-plugin-organize-imports": "^4.3.0",
|
|
41
|
-
"vitest": "^4.1.
|
|
40
|
+
"vitest": "^4.1.7"
|
|
42
41
|
}
|
|
43
42
|
}
|
package/src/commands/models.ts
CHANGED
|
@@ -1,14 +1,43 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
ExtensionAPI,
|
|
3
3
|
ExtensionCommandContext,
|
|
4
|
+
ExtensionContext,
|
|
5
|
+
SessionBeforeSwitchEvent,
|
|
4
6
|
} from "@earendil-works/pi-coding-agent";
|
|
5
|
-
import { PROVIDER_ID, PROVIDER_NAME } from "../constants";
|
|
7
|
+
import { PROVIDER_ID, PROVIDER_NAME, READABLE_TIMEOUT } from "../constants";
|
|
6
8
|
import { Action } from "../enums/action";
|
|
7
9
|
import { Mode } from "../enums/mode";
|
|
8
10
|
import { Status } from "../enums/status";
|
|
9
11
|
import { BaseModel } from "../models/baseModel";
|
|
10
12
|
import { resolveUrl } from "../tools/resolver";
|
|
11
13
|
|
|
14
|
+
// In-flight model reference — handler gates on this.
|
|
15
|
+
let inflightModel: BaseModel | null = null;
|
|
16
|
+
|
|
17
|
+
export const resetInflightModel = () => (inflightModel = null);
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Session-switch handler. Registered once at extension init.
|
|
21
|
+
* Only notifies if a model load is actually in-flight.
|
|
22
|
+
*/
|
|
23
|
+
export const onSessionBeforeSwitch = async (
|
|
24
|
+
_event: SessionBeforeSwitchEvent,
|
|
25
|
+
ctx: ExtensionContext,
|
|
26
|
+
) => {
|
|
27
|
+
if (!inflightModel) return;
|
|
28
|
+
|
|
29
|
+
const messages = [
|
|
30
|
+
`Session change detected while model '${inflightModel.name}' was still loading.`,
|
|
31
|
+
"Model load will continue in the background, but UI might not update.",
|
|
32
|
+
"",
|
|
33
|
+
"Verify that your new model is loaded, or use /models to re-select it afterwards.",
|
|
34
|
+
];
|
|
35
|
+
ctx.ui.notify(messages.join("\n"), "warning");
|
|
36
|
+
|
|
37
|
+
// Show the notification for a reasonable amount of time
|
|
38
|
+
await new Promise((r) => setTimeout(r, READABLE_TIMEOUT));
|
|
39
|
+
};
|
|
40
|
+
|
|
12
41
|
/**
|
|
13
42
|
* Select a model from the list. Returns null if user cancels.
|
|
14
43
|
*
|
|
@@ -167,6 +196,7 @@ export const modelsCommand = async (
|
|
|
167
196
|
const loadActions = [Action.LOAD, Action.SWITCH, Action.RETRY];
|
|
168
197
|
if (loadActions.includes(action)) {
|
|
169
198
|
ctx.ui.notify(`Loading ${model.name}...`, "info");
|
|
199
|
+
inflightModel = model;
|
|
170
200
|
|
|
171
201
|
const onSuccess = async () => {
|
|
172
202
|
const piModel = ctx.modelRegistry.find(PROVIDER_ID, model.id);
|
|
@@ -175,7 +205,7 @@ export const modelsCommand = async (
|
|
|
175
205
|
}
|
|
176
206
|
|
|
177
207
|
if ((await model.getStatus()) === Status.FAILED) {
|
|
178
|
-
throw new Error(`Failed to load model
|
|
208
|
+
throw new Error(`Failed to load model ${model.name}`);
|
|
179
209
|
}
|
|
180
210
|
|
|
181
211
|
await pi.setModel(piModel);
|
|
@@ -184,10 +214,15 @@ export const modelsCommand = async (
|
|
|
184
214
|
|
|
185
215
|
const onFailure = (err: any) => {
|
|
186
216
|
const message = err instanceof Error ? err.message : String(err);
|
|
187
|
-
|
|
217
|
+
|
|
218
|
+
try {
|
|
219
|
+
ctx.ui.notify(message, "error");
|
|
220
|
+
} catch {
|
|
221
|
+
// ctx went stale between error and notification
|
|
222
|
+
}
|
|
188
223
|
};
|
|
189
224
|
|
|
190
225
|
// Load the model without blocking the UI
|
|
191
|
-
model.load().then(onSuccess).catch(onFailure);
|
|
226
|
+
model.load().then(onSuccess).catch(onFailure).finally(resetInflightModel);
|
|
192
227
|
}
|
|
193
228
|
};
|
package/src/constants.ts
CHANGED
|
@@ -23,11 +23,6 @@ export const API_KEY_PLACEHOLDER = "sk-placeholder";
|
|
|
23
23
|
*/
|
|
24
24
|
export const DEFAULT_CTX = 128000;
|
|
25
25
|
|
|
26
|
-
/**
|
|
27
|
-
* Maximum number of tokens a model can generate in a single response
|
|
28
|
-
*/
|
|
29
|
-
export const MAX_TOKENS = 32000;
|
|
30
|
-
|
|
31
26
|
/**
|
|
32
27
|
* Polling interval (ms) for checking model load status
|
|
33
28
|
*/
|
|
@@ -37,3 +32,8 @@ export const POLLING_INTERVAL = 500;
|
|
|
37
32
|
* Maximum time (ms) to wait for model loading before giving up
|
|
38
33
|
*/
|
|
39
34
|
export const POLLING_TIMEOUT = 60000;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Reasonable time to read notifications if context goes stale
|
|
38
|
+
*/
|
|
39
|
+
export const READABLE_TIMEOUT = 15000;
|
package/src/index.ts
CHANGED
|
@@ -2,6 +2,8 @@ import type {
|
|
|
2
2
|
ExtensionAPI,
|
|
3
3
|
ExtensionCommandContext,
|
|
4
4
|
} from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import type { AutocompleteItem } from "@earendil-works/pi-tui";
|
|
6
|
+
import { onSessionBeforeSwitch } from "./commands/models";
|
|
5
7
|
import { PROVIDER_NAME } from "./constants";
|
|
6
8
|
import { onModelSelect } from "./events";
|
|
7
9
|
import { CommandManager } from "./manager";
|
|
@@ -13,10 +15,28 @@ export default async function (pi: ExtensionAPI) {
|
|
|
13
15
|
// Command: /models
|
|
14
16
|
pi.registerCommand("models", {
|
|
15
17
|
description: `Browse ${PROVIDER_NAME} models`,
|
|
18
|
+
getArgumentCompletions: (prefix: string): AutocompleteItem[] | null => {
|
|
19
|
+
const available = [
|
|
20
|
+
{
|
|
21
|
+
value: "info",
|
|
22
|
+
label: "info",
|
|
23
|
+
description: "Show information of all models",
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
value: "unload",
|
|
27
|
+
label: "unload",
|
|
28
|
+
description: "Unload all models",
|
|
29
|
+
},
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
const filtered = available.filter((a) => a.value.startsWith(prefix));
|
|
33
|
+
return filtered.length > 0 ? filtered : null;
|
|
34
|
+
},
|
|
16
35
|
handler: async (args: string, ctx: ExtensionCommandContext) =>
|
|
17
36
|
await manager.run(args, ctx),
|
|
18
37
|
});
|
|
19
38
|
|
|
20
39
|
// Events registration
|
|
21
40
|
pi.on("model_select", onModelSelect);
|
|
41
|
+
pi.on("session_before_switch", onSessionBeforeSwitch);
|
|
22
42
|
}
|
package/src/manager.ts
CHANGED
|
@@ -66,13 +66,15 @@ export class CommandManager {
|
|
|
66
66
|
* @param args Arguments passed to the command
|
|
67
67
|
* @param ctx The context used by Pi
|
|
68
68
|
* @param pi The Pi extension
|
|
69
|
-
* @returns A command handler
|
|
70
69
|
*/
|
|
71
70
|
async run(args: string, ctx: ExtensionCommandContext) {
|
|
72
71
|
if (!(await isServerReady())) {
|
|
73
72
|
return await notFoundCommand(ctx);
|
|
74
73
|
}
|
|
75
74
|
|
|
75
|
+
// Refresh the model list from the server
|
|
76
|
+
await this.update();
|
|
77
|
+
|
|
76
78
|
// Command: `/models info`
|
|
77
79
|
if (args === "info") {
|
|
78
80
|
const info = await Promise.all(this.serverModels.map((m) => m.getInfo()));
|
|
@@ -81,7 +83,14 @@ export class CommandManager {
|
|
|
81
83
|
return;
|
|
82
84
|
}
|
|
83
85
|
|
|
84
|
-
// Command: `/models`
|
|
86
|
+
// Command: `/models unload`
|
|
87
|
+
if (args === "unload") {
|
|
88
|
+
await Promise.all(this.serverModels.map((m) => m.unload()));
|
|
89
|
+
ctx.ui.notify(`Unloaded all ${PROVIDER_NAME} models`, "info");
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Command: `/models` (interactive menu)
|
|
85
94
|
return await modelsCommand(ctx, this.pi, this.serverModels);
|
|
86
95
|
}
|
|
87
96
|
}
|
package/src/models/baseModel.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ProviderModelConfig } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import {
|
|
2
|
+
import { POLLING_INTERVAL, POLLING_TIMEOUT } from "../constants";
|
|
3
3
|
import { Mode } from "../enums/mode";
|
|
4
4
|
import { Status } from "../enums/status";
|
|
5
5
|
import { DataProperty, ModelsEndpoint } from "../interfaces/endpoints/models";
|
|
@@ -127,7 +127,7 @@ export abstract class BaseModel {
|
|
|
127
127
|
input: await this.getCapabilities(),
|
|
128
128
|
contextWindow: await this.getContextSize(),
|
|
129
129
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
130
|
-
maxTokens:
|
|
130
|
+
maxTokens: await this.getContextSize(),
|
|
131
131
|
};
|
|
132
132
|
|
|
133
133
|
return response;
|
package/src/tools/resolver.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
1
2
|
import { access, constants, readFile } from "node:fs/promises";
|
|
2
3
|
import { join } from "node:path";
|
|
3
4
|
import {
|
|
@@ -59,7 +60,7 @@ const readConfigValue = async <T>(
|
|
|
59
60
|
* @returns The API key, as defined by the auth.json file
|
|
60
61
|
*/
|
|
61
62
|
export const resolveApiKey = async (): Promise<string> => {
|
|
62
|
-
const authPath = join(
|
|
63
|
+
const authPath = join(getAgentDir(), "settings.json");
|
|
63
64
|
if (!(await fileExists(authPath))) return API_KEY_PLACEHOLDER;
|
|
64
65
|
|
|
65
66
|
const cfg = await readConfigValue<AuthFile>(authPath, PROVIDER_ID);
|
|
@@ -71,13 +72,7 @@ export const resolveApiKey = async (): Promise<string> => {
|
|
|
71
72
|
* @returns The URL, if found.
|
|
72
73
|
*/
|
|
73
74
|
const resolveGlobalUrl = async (): Promise<string | null> => {
|
|
74
|
-
const globalPath = join(
|
|
75
|
-
process.env.HOME || ".",
|
|
76
|
-
".pi",
|
|
77
|
-
"agent",
|
|
78
|
-
"settings.json",
|
|
79
|
-
);
|
|
80
|
-
|
|
75
|
+
const globalPath = join(getAgentDir(), "settings.json");
|
|
81
76
|
if (!(await fileExists(globalPath))) return null;
|
|
82
77
|
|
|
83
78
|
return readConfigValue<Record<string, string>>(globalPath, "llamaServerUrl");
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { describe, expect, it, vi
|
|
2
|
-
import { CommandManager } from "../src/manager";
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
2
|
import { PROVIDER_ID, PROVIDER_NAME } from "../src/constants";
|
|
3
|
+
import { CommandManager } from "../src/manager";
|
|
4
4
|
|
|
5
5
|
// Mock modules at top level (vi.mock is hoisted)
|
|
6
6
|
vi.mock("../src/tools/retriever", () => ({
|
|
@@ -14,8 +14,8 @@ vi.mock("../src/tools/resolver", () => ({
|
|
|
14
14
|
}));
|
|
15
15
|
|
|
16
16
|
// Import mocked functions after vi.mock
|
|
17
|
+
import { resolveApiKey, resolveUrl } from "../src/tools/resolver";
|
|
17
18
|
import { isServerReady, listModels } from "../src/tools/retriever";
|
|
18
|
-
import { resolveUrl, resolveApiKey } from "../src/tools/resolver";
|
|
19
19
|
|
|
20
20
|
const mockPi = {
|
|
21
21
|
registerProvider: vi.fn(),
|
|
@@ -47,7 +47,9 @@ describe("CommandManager", () => {
|
|
|
47
47
|
const mockModel = {
|
|
48
48
|
name: "test-model",
|
|
49
49
|
id: "test-model",
|
|
50
|
-
toProviderConfig: vi
|
|
50
|
+
toProviderConfig: vi
|
|
51
|
+
.fn()
|
|
52
|
+
.mockResolvedValue({ id: "test-model", maxTokens: 32000 }),
|
|
51
53
|
};
|
|
52
54
|
(isServerReady as any).mockResolvedValue(true);
|
|
53
55
|
(listModels as any).mockResolvedValue([mockModel]);
|
|
@@ -92,11 +94,40 @@ describe("CommandManager", () => {
|
|
|
92
94
|
ui: { notify: notifyFn, theme: { fg: (_c: string, t: string) => t } },
|
|
93
95
|
} as any);
|
|
94
96
|
|
|
97
|
+
expect(notifyFn).toHaveBeenCalledWith("Model info for test-model", "info");
|
|
98
|
+
// Called once in initialize() and once in run() to refresh the model list
|
|
99
|
+
expect(listModels).toHaveBeenCalledTimes(2);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("should unload all models when args is 'unload'", async () => {
|
|
103
|
+
const mockModel1 = {
|
|
104
|
+
name: "model-1",
|
|
105
|
+
id: "model-1",
|
|
106
|
+
unload: vi.fn().mockResolvedValue(undefined),
|
|
107
|
+
toProviderConfig: vi.fn().mockResolvedValue({ id: "model-1" }),
|
|
108
|
+
};
|
|
109
|
+
const mockModel2 = {
|
|
110
|
+
name: "model-2",
|
|
111
|
+
id: "model-2",
|
|
112
|
+
unload: vi.fn().mockResolvedValue(undefined),
|
|
113
|
+
toProviderConfig: vi.fn().mockResolvedValue({ id: "model-2" }),
|
|
114
|
+
};
|
|
115
|
+
(isServerReady as any).mockResolvedValue(true);
|
|
116
|
+
(listModels as any).mockResolvedValue([mockModel1, mockModel2]);
|
|
117
|
+
|
|
118
|
+
const notifyFn = vi.fn();
|
|
119
|
+
const manager = new CommandManager(mockPi as any);
|
|
120
|
+
await manager.initialize();
|
|
121
|
+
await manager.run("unload", {
|
|
122
|
+
ui: { notify: notifyFn },
|
|
123
|
+
} as any);
|
|
124
|
+
|
|
125
|
+
expect(mockModel1.unload).toHaveBeenCalled();
|
|
126
|
+
expect(mockModel2.unload).toHaveBeenCalled();
|
|
95
127
|
expect(notifyFn).toHaveBeenCalledWith(
|
|
96
|
-
"
|
|
128
|
+
"Unloaded all Llama.cpp models",
|
|
97
129
|
"info",
|
|
98
130
|
);
|
|
99
|
-
expect(listModels).toHaveBeenCalledOnce();
|
|
100
131
|
});
|
|
101
132
|
|
|
102
133
|
it("should dispatch modelsCommand when args is empty", async () => {
|
|
@@ -1,5 +1,14 @@
|
|
|
1
|
-
import { describe, expect, it, vi } from "vitest";
|
|
2
|
-
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
// Set up fake timers before any imports so setTimeout is mocked globally
|
|
4
|
+
vi.useFakeTimers();
|
|
5
|
+
|
|
6
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import {
|
|
8
|
+
modelsCommand,
|
|
9
|
+
onSessionBeforeSwitch,
|
|
10
|
+
resetInflightModel,
|
|
11
|
+
} from "../src/commands/models";
|
|
3
12
|
import { Action } from "../src/enums/action";
|
|
4
13
|
import { Mode } from "../src/enums/mode";
|
|
5
14
|
import { Status } from "../src/enums/status";
|
|
@@ -48,11 +57,27 @@ const createMockCtx = (
|
|
|
48
57
|
},
|
|
49
58
|
});
|
|
50
59
|
|
|
60
|
+
const createMockPiContext = (notifyFn: ReturnType<typeof vi.fn>) =>
|
|
61
|
+
({
|
|
62
|
+
ui: {
|
|
63
|
+
notify: notifyFn,
|
|
64
|
+
},
|
|
65
|
+
}) as any as ExtensionContext;
|
|
66
|
+
|
|
51
67
|
const createMockPi = () => ({
|
|
52
68
|
setModel: vi.fn(),
|
|
53
69
|
registerProvider: vi.fn(),
|
|
54
70
|
});
|
|
55
71
|
|
|
72
|
+
beforeEach(() => {
|
|
73
|
+
vi.clearAllTimers();
|
|
74
|
+
resetInflightModel();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
afterEach(() => {
|
|
78
|
+
vi.clearAllTimers();
|
|
79
|
+
});
|
|
80
|
+
|
|
56
81
|
describe("modelsCommand", () => {
|
|
57
82
|
it("should return early on cancel (null model selection)", async () => {
|
|
58
83
|
const models = [createMockModel("model-a")];
|
|
@@ -113,6 +138,117 @@ describe("modelsCommand", () => {
|
|
|
113
138
|
await vi.waitFor(() => expect(pi.setModel).toHaveBeenCalled());
|
|
114
139
|
});
|
|
115
140
|
|
|
141
|
+
it("should show warning when session changes during model load", async () => {
|
|
142
|
+
// Create a deferred promise so we can control when the load completes
|
|
143
|
+
let resolveLoad: () => void;
|
|
144
|
+
const loadPromise = new Promise<void>((resolve) => {
|
|
145
|
+
resolveLoad = resolve;
|
|
146
|
+
});
|
|
147
|
+
const model = createMockModel("model-a", {
|
|
148
|
+
load: () => loadPromise,
|
|
149
|
+
getStatus: vi.fn().mockResolvedValue(Status.UNLOADED),
|
|
150
|
+
});
|
|
151
|
+
const models = [model];
|
|
152
|
+
|
|
153
|
+
let selectCallCount = 0;
|
|
154
|
+
const ctx = createMockCtx(() => {
|
|
155
|
+
selectCallCount++;
|
|
156
|
+
// 1st: select model, 2nd: select LOAD
|
|
157
|
+
if (selectCallCount === 1) return "model-a";
|
|
158
|
+
if (selectCallCount === 2) return Action.LOAD;
|
|
159
|
+
return null;
|
|
160
|
+
});
|
|
161
|
+
const pi = createMockPi();
|
|
162
|
+
|
|
163
|
+
// Start the load (non-blocking)
|
|
164
|
+
const modelsPromise = modelsCommand(ctx as any, pi as any, models);
|
|
165
|
+
|
|
166
|
+
// Advance past the microtask that sets inflightModel
|
|
167
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
168
|
+
|
|
169
|
+
// Simulate session switch while model is still loading
|
|
170
|
+
// onSessionBeforeSwitch awaits READABLE_TIMEOUT (15s) for the notification
|
|
171
|
+
const switchPromise = onSessionBeforeSwitch(
|
|
172
|
+
{} as any,
|
|
173
|
+
createMockPiContext(ctx.ui.notify as any),
|
|
174
|
+
);
|
|
175
|
+
await vi.advanceTimersByTimeAsync(15000);
|
|
176
|
+
await switchPromise;
|
|
177
|
+
|
|
178
|
+
// Should have shown a warning notification
|
|
179
|
+
expect(ctx.ui.notify).toHaveBeenCalledWith(
|
|
180
|
+
expect.stringContaining("Session change detected"),
|
|
181
|
+
"warning",
|
|
182
|
+
);
|
|
183
|
+
expect(ctx.ui.notify).toHaveBeenCalledWith(
|
|
184
|
+
expect.stringContaining("model-a"),
|
|
185
|
+
"warning",
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
// Complete the load so inflightModel is cleared
|
|
189
|
+
resolveLoad!();
|
|
190
|
+
await modelsPromise;
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("should not warn when no model is loading", async () => {
|
|
194
|
+
const notifyFn = vi.fn();
|
|
195
|
+
const ctx = createMockPiContext(notifyFn);
|
|
196
|
+
|
|
197
|
+
await onSessionBeforeSwitch({} as any, ctx);
|
|
198
|
+
|
|
199
|
+
expect(notifyFn).not.toHaveBeenCalled();
|
|
200
|
+
// No timers should be scheduled
|
|
201
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("should clear inflightModel after load completes successfully", async () => {
|
|
205
|
+
const loadFn = vi.fn().mockResolvedValue(undefined);
|
|
206
|
+
const model = createMockModel("model-a", {
|
|
207
|
+
load: loadFn,
|
|
208
|
+
getStatus: vi.fn().mockResolvedValue(Status.UNLOADED),
|
|
209
|
+
});
|
|
210
|
+
const models = [model];
|
|
211
|
+
const ctx = createMockCtx((prompt) => {
|
|
212
|
+
if (prompt.includes("models")) return "model-a";
|
|
213
|
+
return Action.LOAD;
|
|
214
|
+
});
|
|
215
|
+
const pi = createMockPi();
|
|
216
|
+
|
|
217
|
+
await modelsCommand(ctx as any, pi as any, models);
|
|
218
|
+
await vi.waitFor(() => expect(loadFn).toHaveBeenCalled());
|
|
219
|
+
await vi.waitFor(() => expect(pi.setModel).toHaveBeenCalled());
|
|
220
|
+
|
|
221
|
+
// inflightModel should be cleared after completion
|
|
222
|
+
// (verified indirectly: calling onSessionBeforeSwitch should not warn)
|
|
223
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
224
|
+
const notifyFn = vi.fn();
|
|
225
|
+
await onSessionBeforeSwitch({} as any, createMockPiContext(notifyFn));
|
|
226
|
+
expect(notifyFn).not.toHaveBeenCalled();
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("should clear inflightModel after load fails", async () => {
|
|
230
|
+
const loadFn = vi.fn().mockRejectedValue(new Error("Load failed"));
|
|
231
|
+
const model = createMockModel("model-a", {
|
|
232
|
+
load: loadFn,
|
|
233
|
+
getStatus: vi.fn().mockResolvedValue(Status.FAILED),
|
|
234
|
+
});
|
|
235
|
+
const models = [model];
|
|
236
|
+
const ctx = createMockCtx((prompt) => {
|
|
237
|
+
if (prompt.includes("models")) return "model-a";
|
|
238
|
+
return Action.RETRY;
|
|
239
|
+
});
|
|
240
|
+
const pi = createMockPi();
|
|
241
|
+
|
|
242
|
+
await modelsCommand(ctx as any, pi as any, models);
|
|
243
|
+
await vi.waitFor(() => expect(loadFn).toHaveBeenCalled());
|
|
244
|
+
|
|
245
|
+
// inflightModel should be cleared after failure
|
|
246
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
247
|
+
const notifyFn = vi.fn();
|
|
248
|
+
await onSessionBeforeSwitch({} as any, createMockPiContext(notifyFn));
|
|
249
|
+
expect(notifyFn).not.toHaveBeenCalled();
|
|
250
|
+
});
|
|
251
|
+
|
|
116
252
|
it("should loop back to model selection when action is cancelled", async () => {
|
|
117
253
|
const model = createMockModel("model-a");
|
|
118
254
|
const models = [model];
|
|
@@ -65,7 +65,9 @@ describe("SingleModel getStatus", () => {
|
|
|
65
65
|
const status = await model.getStatus();
|
|
66
66
|
|
|
67
67
|
expect(status).toBe(Status.LOADED);
|
|
68
|
-
expect(mockRpc).toHaveBeenCalledWith(
|
|
68
|
+
expect(mockRpc).toHaveBeenCalledWith(
|
|
69
|
+
`/props?model=${model.id}&autoload=false`,
|
|
70
|
+
);
|
|
69
71
|
});
|
|
70
72
|
|
|
71
73
|
it("should return SLEEPING when is_sleeping is true", async () => {
|