@eiei114/pi-sub-core 1.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/CHANGELOG.md +190 -0
- package/README.md +178 -0
- package/index.ts +540 -0
- package/package.json +35 -0
- package/src/cache.ts +546 -0
- package/src/config.ts +35 -0
- package/src/dependencies.ts +37 -0
- package/src/errors.ts +71 -0
- package/src/paths.ts +55 -0
- package/src/provider.ts +66 -0
- package/src/providers/detection.ts +51 -0
- package/src/providers/impl/anthropic.ts +174 -0
- package/src/providers/impl/antigravity.ts +226 -0
- package/src/providers/impl/codex.ts +186 -0
- package/src/providers/impl/copilot.ts +176 -0
- package/src/providers/impl/gemini.ts +130 -0
- package/src/providers/impl/kiro.ts +92 -0
- package/src/providers/impl/zai.ts +120 -0
- package/src/providers/index.ts +5 -0
- package/src/providers/metadata.ts +16 -0
- package/src/providers/registry.ts +54 -0
- package/src/providers/settings.ts +109 -0
- package/src/providers/status.ts +25 -0
- package/src/settings/behavior.ts +58 -0
- package/src/settings/menu.ts +83 -0
- package/src/settings/tools.ts +38 -0
- package/src/settings/ui.ts +450 -0
- package/src/settings-types.ts +95 -0
- package/src/settings-ui.ts +1 -0
- package/src/settings.ts +137 -0
- package/src/status.ts +245 -0
- package/src/storage/lock.ts +150 -0
- package/src/storage.ts +61 -0
- package/src/types.ts +33 -0
- package/src/ui/keybindings.ts +92 -0
- package/src/ui/settings-list.ts +290 -0
- package/src/usage/controller.ts +250 -0
- package/src/usage/fetch.ts +215 -0
- package/src/usage/types.ts +5 -0
- package/src/utils.ts +158 -0
- package/test/all.test.ts +9 -0
- package/test/cache.test.ts +157 -0
- package/test/controller.test.ts +101 -0
- package/test/detection.test.ts +24 -0
- package/test/extension.test.ts +233 -0
- package/test/helpers.ts +48 -0
- package/test/keybindings.test.ts +59 -0
- package/test/lock.test.ts +49 -0
- package/test/prioritize.test.ts +81 -0
- package/test/providers.test.ts +385 -0
- package/test/status.test.ts +70 -0
- package/tsconfig.json +5 -0
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import type { ExtensionAPI, ExtensionContext } from '@mariozechner/pi-coding-agent';
|
|
4
|
+
import createExtension from '../index.js';
|
|
5
|
+
import { CACHE_PATH, clearCache } from '../src/cache.js';
|
|
6
|
+
import { SETTINGS_PATH } from '../src/settings.js';
|
|
7
|
+
import { getStorage, setStorage, type StorageAdapter } from '../src/storage.js';
|
|
8
|
+
import { createDeps, getAuthPath } from './helpers.js';
|
|
9
|
+
|
|
10
|
+
interface PiHarness {
|
|
11
|
+
pi: ExtensionAPI;
|
|
12
|
+
emitLifecycle: (event: string, ctx: ExtensionContext) => Promise<void>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Build a minimal ExtensionAPI mock that captures lifecycle handlers
|
|
17
|
+
* (`pi.on(event, handler)`) and exposes them via `emitLifecycle` for
|
|
18
|
+
* deterministic invocation in tests.
|
|
19
|
+
*/
|
|
20
|
+
function createPiHarness(): PiHarness {
|
|
21
|
+
const lifecycle = new Map<string, Array<(event: unknown, ctx: ExtensionContext) => unknown>>();
|
|
22
|
+
const eventListeners = new Map<string, Array<(payload: unknown) => unknown>>();
|
|
23
|
+
|
|
24
|
+
const events = {
|
|
25
|
+
on(event: string, handler: (payload: unknown) => unknown) {
|
|
26
|
+
const list = eventListeners.get(event) ?? [];
|
|
27
|
+
list.push(handler);
|
|
28
|
+
eventListeners.set(event, list);
|
|
29
|
+
},
|
|
30
|
+
emit(event: string, payload?: unknown) {
|
|
31
|
+
for (const handler of eventListeners.get(event) ?? []) {
|
|
32
|
+
handler(payload);
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const pi = {
|
|
38
|
+
events,
|
|
39
|
+
on(event: string, handler: (event: unknown, ctx: ExtensionContext) => unknown) {
|
|
40
|
+
const list = lifecycle.get(event) ?? [];
|
|
41
|
+
list.push(handler);
|
|
42
|
+
lifecycle.set(event, list);
|
|
43
|
+
},
|
|
44
|
+
registerCommand: () => {},
|
|
45
|
+
registerTool: () => {},
|
|
46
|
+
registerShortcut: () => {},
|
|
47
|
+
registerFlag: () => {},
|
|
48
|
+
getFlag: () => undefined,
|
|
49
|
+
registerMessageRenderer: () => {},
|
|
50
|
+
sendMessage: () => {},
|
|
51
|
+
sendUserMessage: () => {},
|
|
52
|
+
appendEntry: () => {},
|
|
53
|
+
setSessionName: () => {},
|
|
54
|
+
getSessionName: () => undefined,
|
|
55
|
+
setLabel: () => {},
|
|
56
|
+
exec: async () => ({ code: 0, stdout: '', stderr: '' }),
|
|
57
|
+
getActiveTools: () => [],
|
|
58
|
+
getAllTools: () => [],
|
|
59
|
+
setActiveTools: () => {},
|
|
60
|
+
setModel: async () => true,
|
|
61
|
+
getThinkingLevel: () => 'high',
|
|
62
|
+
setThinkingLevel: () => {},
|
|
63
|
+
registerProvider: () => {},
|
|
64
|
+
} as unknown as ExtensionAPI;
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
pi,
|
|
68
|
+
async emitLifecycle(event, ctx) {
|
|
69
|
+
for (const handler of lifecycle.get(event) ?? []) {
|
|
70
|
+
await handler({ type: event }, ctx);
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function createMemoryStorage(): { storage: StorageAdapter; files: Map<string, string> } {
|
|
77
|
+
const files = new Map<string, string>();
|
|
78
|
+
const storage: StorageAdapter = {
|
|
79
|
+
readFile: (filePath) => files.get(filePath),
|
|
80
|
+
writeFile: (filePath, contents) => {
|
|
81
|
+
files.set(filePath, contents);
|
|
82
|
+
},
|
|
83
|
+
writeFileExclusive: (filePath, contents) => {
|
|
84
|
+
if (files.has(filePath)) return false;
|
|
85
|
+
files.set(filePath, contents);
|
|
86
|
+
return true;
|
|
87
|
+
},
|
|
88
|
+
exists: (filePath) => files.has(filePath),
|
|
89
|
+
removeFile: (filePath) => {
|
|
90
|
+
files.delete(filePath);
|
|
91
|
+
},
|
|
92
|
+
ensureDir: () => {},
|
|
93
|
+
};
|
|
94
|
+
return { storage, files };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function createCtx(): ExtensionContext {
|
|
98
|
+
return {
|
|
99
|
+
ui: {
|
|
100
|
+
select: async () => undefined,
|
|
101
|
+
confirm: async () => false,
|
|
102
|
+
input: async () => undefined,
|
|
103
|
+
notify: () => {},
|
|
104
|
+
setStatus: () => {},
|
|
105
|
+
setWorkingMessage: () => {},
|
|
106
|
+
setWidget: () => {},
|
|
107
|
+
setFooter: () => {},
|
|
108
|
+
setHeader: () => {},
|
|
109
|
+
setTitle: () => {},
|
|
110
|
+
custom: async () => undefined,
|
|
111
|
+
setEditorText: () => {},
|
|
112
|
+
},
|
|
113
|
+
hasUI: false,
|
|
114
|
+
cwd: '/tmp/project',
|
|
115
|
+
sessionManager: {} as ExtensionContext['sessionManager'],
|
|
116
|
+
modelRegistry: {} as ExtensionContext['modelRegistry'],
|
|
117
|
+
model: { provider: 'anthropic', id: 'claude-opus-4-7' } as ExtensionContext['model'],
|
|
118
|
+
isIdle: () => true,
|
|
119
|
+
abort: () => {},
|
|
120
|
+
hasPendingMessages: () => false,
|
|
121
|
+
shutdown: () => {},
|
|
122
|
+
getContextUsage: () => undefined,
|
|
123
|
+
compact: () => {},
|
|
124
|
+
getSystemPrompt: () => '',
|
|
125
|
+
} as ExtensionContext;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function resetGlobal(): void {
|
|
129
|
+
const g = globalThis as typeof globalThis & { __piSubCore?: { active: boolean } };
|
|
130
|
+
g.__piSubCore = undefined;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Regression for marckrenn/pi-sub#58.
|
|
135
|
+
*
|
|
136
|
+
* Before the fix, `turn_end` and `tool_result` (with `refreshOnToolResult` enabled)
|
|
137
|
+
* called `refresh(ctx, { force: true })`. That bypassed the cache TTL and triggered
|
|
138
|
+
* an upstream fetch on every turn, hammering rate-limited endpoints (e.g. anthropic
|
|
139
|
+
* OAuth `/usage`) and, when the endpoint started returning 429, leaving the cache
|
|
140
|
+
* stuck because `fetchWithCache` refuses to write entries with errors.
|
|
141
|
+
*
|
|
142
|
+
* With the fix, both handlers call `refresh(ctx)` without `force`, so the existing
|
|
143
|
+
* `behavior.refreshInterval` (60s) and `behavior.minRefreshInterval` (10s) gates
|
|
144
|
+
* correctly suppress redundant network calls when the cache is fresh.
|
|
145
|
+
*/
|
|
146
|
+
test('turn_end and tool_result do not bypass the cache TTL when the entry is fresh', async () => {
|
|
147
|
+
const originalStorage = getStorage();
|
|
148
|
+
const { storage, files } = createMemoryStorage();
|
|
149
|
+
setStorage(storage);
|
|
150
|
+
clearCache();
|
|
151
|
+
resetGlobal();
|
|
152
|
+
|
|
153
|
+
try {
|
|
154
|
+
const home = '/home/test';
|
|
155
|
+
let fetchCount = 0;
|
|
156
|
+
const { deps, files: depFiles } = createDeps({
|
|
157
|
+
fetch: async () => {
|
|
158
|
+
fetchCount += 1;
|
|
159
|
+
return {
|
|
160
|
+
ok: true,
|
|
161
|
+
status: 200,
|
|
162
|
+
json: async () => ({}),
|
|
163
|
+
} as Response;
|
|
164
|
+
},
|
|
165
|
+
homedir: home,
|
|
166
|
+
});
|
|
167
|
+
// anthropic provider reads ~/.pi/agent/auth.json under `anthropic.access`.
|
|
168
|
+
depFiles.set(getAuthPath(home), JSON.stringify({ anthropic: { access: 'token' } }));
|
|
169
|
+
|
|
170
|
+
// Pre-populate the cache: 30s old. That's past the 10s minRefreshInterval but
|
|
171
|
+
// well within the 60s ttl. Without `force`, fetchUsageForProvider must return
|
|
172
|
+
// the cached value; with `force: true` (the bug) it bypasses the ttl and fetches.
|
|
173
|
+
const fetchedAt = Date.now() - 30_000;
|
|
174
|
+
files.set(
|
|
175
|
+
CACHE_PATH,
|
|
176
|
+
JSON.stringify({
|
|
177
|
+
anthropic: {
|
|
178
|
+
fetchedAt,
|
|
179
|
+
statusFetchedAt: fetchedAt,
|
|
180
|
+
usage: {
|
|
181
|
+
provider: 'anthropic',
|
|
182
|
+
displayName: 'Anthropic (Claude)',
|
|
183
|
+
windows: [
|
|
184
|
+
{ label: '5h', usedPercent: 12, resetDescription: '4h' },
|
|
185
|
+
{ label: 'Week', usedPercent: 25, resetDescription: '3d' },
|
|
186
|
+
],
|
|
187
|
+
},
|
|
188
|
+
status: { indicator: 'none' },
|
|
189
|
+
},
|
|
190
|
+
}),
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
// Enable refresh on tool result so the tool_result handler actually calls refresh.
|
|
194
|
+
files.set(
|
|
195
|
+
SETTINGS_PATH,
|
|
196
|
+
JSON.stringify({
|
|
197
|
+
version: 3,
|
|
198
|
+
behavior: { refreshOnToolResult: true, refreshInterval: 60, minRefreshInterval: 10 },
|
|
199
|
+
statusRefresh: { refreshOnToolResult: true, refreshInterval: 60, minRefreshInterval: 10 },
|
|
200
|
+
providers: { anthropic: { enabled: 'on', fetchStatus: false } },
|
|
201
|
+
}),
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
const harness = createPiHarness();
|
|
205
|
+
createExtension(harness.pi, deps);
|
|
206
|
+
|
|
207
|
+
const ctx = createCtx();
|
|
208
|
+
await harness.emitLifecycle('session_start', ctx);
|
|
209
|
+
// session_start emits with skipFetch:true, so it should not hit the network.
|
|
210
|
+
assert.equal(fetchCount, 0, 'session_start should never fetch');
|
|
211
|
+
|
|
212
|
+
await harness.emitLifecycle('turn_end', ctx);
|
|
213
|
+
assert.equal(
|
|
214
|
+
fetchCount,
|
|
215
|
+
0,
|
|
216
|
+
'turn_end must respect cache TTL and not refetch when the entry is fresh',
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
await harness.emitLifecycle('tool_result', ctx);
|
|
220
|
+
assert.equal(
|
|
221
|
+
fetchCount,
|
|
222
|
+
0,
|
|
223
|
+
'tool_result must respect cache TTL and not refetch when the entry is fresh',
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
// Cleanup so the global guard, intervals and watchers do not leak across tests.
|
|
227
|
+
await harness.emitLifecycle('session_shutdown', ctx);
|
|
228
|
+
} finally {
|
|
229
|
+
resetGlobal();
|
|
230
|
+
setStorage(originalStorage);
|
|
231
|
+
clearCache();
|
|
232
|
+
}
|
|
233
|
+
});
|
package/test/helpers.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import type { Dependencies } from "../src/types.js";
|
|
3
|
+
|
|
4
|
+
export type FetchResponse<T> = {
|
|
5
|
+
ok: boolean;
|
|
6
|
+
status: number;
|
|
7
|
+
json: () => Promise<T>;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export function createJsonResponse<T>(data: T, init?: { ok?: boolean; status?: number }): FetchResponse<T> {
|
|
11
|
+
return {
|
|
12
|
+
ok: init?.ok ?? true,
|
|
13
|
+
status: init?.status ?? 200,
|
|
14
|
+
json: async () => data,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function createDeps(options?: {
|
|
19
|
+
files?: Record<string, string>;
|
|
20
|
+
fetch?: Dependencies["fetch"];
|
|
21
|
+
execFileSync?: Dependencies["execFileSync"];
|
|
22
|
+
env?: NodeJS.ProcessEnv;
|
|
23
|
+
homedir?: string;
|
|
24
|
+
}): { deps: Dependencies; files: Map<string, string> } {
|
|
25
|
+
const files = new Map<string, string>(Object.entries(options?.files ?? {}));
|
|
26
|
+
const homedir = options?.homedir ?? "/home/test";
|
|
27
|
+
|
|
28
|
+
const deps: Dependencies = {
|
|
29
|
+
fetch: options?.fetch
|
|
30
|
+
?? (async () => {
|
|
31
|
+
throw new Error("fetch not mocked");
|
|
32
|
+
}),
|
|
33
|
+
readFile: (filePath: string) => files.get(filePath),
|
|
34
|
+
fileExists: (filePath: string) => files.has(filePath),
|
|
35
|
+
execFileSync: options?.execFileSync
|
|
36
|
+
?? (() => {
|
|
37
|
+
throw new Error("execFileSync not mocked");
|
|
38
|
+
}),
|
|
39
|
+
homedir: () => homedir,
|
|
40
|
+
env: options?.env ?? {},
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
return { deps, files };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function getAuthPath(home: string): string {
|
|
47
|
+
return path.join(home, ".pi", "agent", "auth.json");
|
|
48
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { createSettingsKeybindings } from "../src/ui/keybindings.js";
|
|
4
|
+
|
|
5
|
+
test("settings keybindings prefer editor keybindings when available", () => {
|
|
6
|
+
const kb = createSettingsKeybindings({
|
|
7
|
+
getEditorKeybindings: () => ({
|
|
8
|
+
matches: (data, action) => data === "X" && action === "selectDown",
|
|
9
|
+
}),
|
|
10
|
+
getKeybindings: () => ({
|
|
11
|
+
matches: () => {
|
|
12
|
+
throw new Error("legacy keybindings should not be used when editor keybindings exist");
|
|
13
|
+
},
|
|
14
|
+
}),
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
assert.equal(kb.matches("X", "selectDown"), true);
|
|
18
|
+
assert.equal(kb.matches("X", "selectUp"), false);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("settings keybindings map actions to legacy keybinding IDs", () => {
|
|
22
|
+
const seen: string[] = [];
|
|
23
|
+
const kb = createSettingsKeybindings({
|
|
24
|
+
getKeybindings: () => ({
|
|
25
|
+
matches: (_data, action) => {
|
|
26
|
+
seen.push(action);
|
|
27
|
+
return action === "tui.select.down";
|
|
28
|
+
},
|
|
29
|
+
}),
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
assert.equal(kb.matches("ignored", "selectDown"), true);
|
|
33
|
+
assert.equal(kb.matches("ignored", "cursorLeft"), false);
|
|
34
|
+
assert.deepEqual(seen, ["tui.select.down", "tui.editor.cursorLeft"]);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("settings keybindings fallback uses matchesKey helper when no keybinding manager exists", () => {
|
|
38
|
+
const kb = createSettingsKeybindings({
|
|
39
|
+
matchesKey: (data, key) => data === `<<${key}>>`,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
assert.equal(kb.matches("<<up>>", "selectUp"), true);
|
|
43
|
+
assert.equal(kb.matches("<<down>>", "selectDown"), true);
|
|
44
|
+
assert.equal(kb.matches("<<left>>", "cursorLeft"), true);
|
|
45
|
+
assert.equal(kb.matches("<<enter>>", "selectConfirm"), true);
|
|
46
|
+
assert.equal(kb.matches("<<escape>>", "selectCancel"), true);
|
|
47
|
+
assert.equal(kb.matches("<<right>>", "cursorLeft"), false);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("settings keybindings fallback handles raw escape sequences", () => {
|
|
51
|
+
const kb = createSettingsKeybindings({});
|
|
52
|
+
|
|
53
|
+
assert.equal(kb.matches("\u001b[A", "selectUp"), true);
|
|
54
|
+
assert.equal(kb.matches("\u001b[B", "selectDown"), true);
|
|
55
|
+
assert.equal(kb.matches("\u001b[D", "cursorLeft"), true);
|
|
56
|
+
assert.equal(kb.matches("\u001b[C", "cursorRight"), true);
|
|
57
|
+
assert.equal(kb.matches("\r", "selectConfirm"), true);
|
|
58
|
+
assert.equal(kb.matches("\u001b", "selectCancel"), true);
|
|
59
|
+
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { getStorage, setStorage, type StorageAdapter } from "../src/storage.js";
|
|
4
|
+
import { tryAcquireFileLock, releaseFileLock } from "../src/storage/lock.js";
|
|
5
|
+
|
|
6
|
+
function createMemoryStorage(): { storage: StorageAdapter; files: Map<string, string> } {
|
|
7
|
+
const files = new Map<string, string>();
|
|
8
|
+
const storage: StorageAdapter = {
|
|
9
|
+
readFile: (filePath) => files.get(filePath),
|
|
10
|
+
writeFile: (filePath, contents) => {
|
|
11
|
+
files.set(filePath, contents);
|
|
12
|
+
},
|
|
13
|
+
writeFileExclusive: (filePath, contents) => {
|
|
14
|
+
if (files.has(filePath)) return false;
|
|
15
|
+
files.set(filePath, contents);
|
|
16
|
+
return true;
|
|
17
|
+
},
|
|
18
|
+
exists: (filePath) => files.has(filePath),
|
|
19
|
+
removeFile: (filePath) => {
|
|
20
|
+
files.delete(filePath);
|
|
21
|
+
},
|
|
22
|
+
ensureDir: () => {},
|
|
23
|
+
};
|
|
24
|
+
return { storage, files };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
test("tryAcquireFileLock replaces stale locks with owned tokens", () => {
|
|
28
|
+
const { storage, files } = createMemoryStorage();
|
|
29
|
+
const originalStorage = getStorage();
|
|
30
|
+
setStorage(storage);
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
const lockPath = "/tmp/lock";
|
|
34
|
+
const firstToken = tryAcquireFileLock(lockPath, 10);
|
|
35
|
+
assert.ok(firstToken);
|
|
36
|
+
files.set(lockPath, String(Date.now() - 1000));
|
|
37
|
+
const secondToken = tryAcquireFileLock(lockPath, 10);
|
|
38
|
+
assert.ok(secondToken);
|
|
39
|
+
assert.notEqual(secondToken, firstToken);
|
|
40
|
+
|
|
41
|
+
releaseFileLock(lockPath, firstToken ?? undefined);
|
|
42
|
+
assert.equal(files.has(lockPath), true);
|
|
43
|
+
|
|
44
|
+
releaseFileLock(lockPath, secondToken ?? undefined);
|
|
45
|
+
assert.equal(files.has(lockPath), false);
|
|
46
|
+
} finally {
|
|
47
|
+
setStorage(originalStorage);
|
|
48
|
+
}
|
|
49
|
+
});
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import type { RateWindow } from "../src/types.js";
|
|
4
|
+
import { prioritizeWindowsForModel } from "../src/utils.js";
|
|
5
|
+
|
|
6
|
+
function window(label: string, usedPercent = 0): RateWindow {
|
|
7
|
+
return { label, usedPercent };
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
test("codex spark windows are moved before general windows", () => {
|
|
11
|
+
const windows = [
|
|
12
|
+
window("5h", 0),
|
|
13
|
+
window("Week", 1),
|
|
14
|
+
window("GPT-5.3-Codex-Spark 5h", 2),
|
|
15
|
+
window("GPT-5.3-Codex-Spark Week", 3),
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
const result = prioritizeWindowsForModel(windows, { id: "gpt-5.3-codex-spark" });
|
|
19
|
+
|
|
20
|
+
assert.deepEqual(
|
|
21
|
+
result.map((w) => w.label),
|
|
22
|
+
["GPT-5.3-Codex-Spark 5h", "GPT-5.3-Codex-Spark Week", "5h", "Week"],
|
|
23
|
+
);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("antigravity current model window is moved first", () => {
|
|
27
|
+
const windows = [
|
|
28
|
+
window("Gemini 2.5 Pro"),
|
|
29
|
+
window("Gemini 3 Flash"),
|
|
30
|
+
window("Gemini 3 Pro"),
|
|
31
|
+
window("Gemini 3.5 Flash"),
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
const result = prioritizeWindowsForModel(windows, { id: "gemini-3-pro" });
|
|
35
|
+
|
|
36
|
+
assert.deepEqual(
|
|
37
|
+
result.map((w) => w.label),
|
|
38
|
+
["Gemini 3 Pro", "Gemini 2.5 Pro", "Gemini 3 Flash", "Gemini 3.5 Flash"],
|
|
39
|
+
);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("returns original array when no windows match the model", () => {
|
|
43
|
+
const windows = [window("5h"), window("Week")];
|
|
44
|
+
|
|
45
|
+
const result = prioritizeWindowsForModel(windows, { id: "claude-3.5-sonnet" });
|
|
46
|
+
|
|
47
|
+
assert.equal(result, windows);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("returns original array when all windows match", () => {
|
|
51
|
+
const windows = [
|
|
52
|
+
window("GPT-5.3-Codex-Spark 5h"),
|
|
53
|
+
window("GPT-5.3-Codex-Spark Week"),
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
const result = prioritizeWindowsForModel(windows, { id: "gpt-5.3-codex-spark" });
|
|
57
|
+
|
|
58
|
+
assert.equal(result, windows);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("non-spark codex model does not match spark windows", () => {
|
|
62
|
+
const windows = [
|
|
63
|
+
window("5h", 0),
|
|
64
|
+
window("Week", 1),
|
|
65
|
+
window("GPT-5.3-Codex-Spark 5h", 2),
|
|
66
|
+
window("GPT-5.3-Codex-Spark Week", 3),
|
|
67
|
+
];
|
|
68
|
+
|
|
69
|
+
const result = prioritizeWindowsForModel(windows, { id: "gpt-5.3" });
|
|
70
|
+
|
|
71
|
+
assert.equal(result, windows);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("returns original array when model is absent", () => {
|
|
75
|
+
const windows = [window("5h"), window("Week")];
|
|
76
|
+
|
|
77
|
+
assert.equal(prioritizeWindowsForModel(windows, undefined), windows);
|
|
78
|
+
assert.equal(prioritizeWindowsForModel(windows, null), windows);
|
|
79
|
+
assert.equal(prioritizeWindowsForModel(windows, { id: undefined }), windows);
|
|
80
|
+
assert.equal(prioritizeWindowsForModel(windows, { id: "" }), windows);
|
|
81
|
+
});
|