pi-extended-teams 2.1.16
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 +105 -0
- package/assets/pi-extended-teams-agent-navigation.png +0 -0
- package/assets/pi-extended-teams-in-action.png +0 -0
- package/extensions/agents/read-agent-report.ts +181 -0
- package/extensions/agents/read-agent-session-lifecycle.test.ts +605 -0
- package/extensions/agents/read-agent-session-lifecycle.ts +676 -0
- package/extensions/agents/read-agent.test.ts +3077 -0
- package/extensions/agents/read-agent.ts +1119 -0
- package/extensions/agents/write-agent.test.ts +513 -0
- package/extensions/agents/write-agent.ts +392 -0
- package/extensions/events/register-events.test.ts +465 -0
- package/extensions/events/register-events.ts +409 -0
- package/extensions/index.test.ts +1659 -0
- package/extensions/index.ts +1228 -0
- package/extensions/internal/agent-session-files.test.ts +164 -0
- package/extensions/internal/agent-session-files.ts +320 -0
- package/extensions/internal/debug.ts +44 -0
- package/extensions/internal/model-selection.ts +82 -0
- package/extensions/internal/pi-command.test.ts +191 -0
- package/extensions/internal/pi-command.ts +224 -0
- package/extensions/internal/pi-runtime-api.test.ts +43 -0
- package/extensions/internal/pi-runtime-api.ts +78 -0
- package/extensions/internal/schema.ts +19 -0
- package/extensions/internal/session-context-reference.test.ts +290 -0
- package/extensions/internal/session-context-reference.ts +430 -0
- package/extensions/internal/session-files.test.ts +225 -0
- package/extensions/internal/session-files.ts +273 -0
- package/extensions/internal/session-usage.ts +59 -0
- package/extensions/resources/spawn-resource-plan.test.ts +233 -0
- package/extensions/resources/spawn-resource-plan.ts +247 -0
- package/extensions/runtime/active-agent-sleep.test.ts +157 -0
- package/extensions/runtime/active-agent-sleep.ts +117 -0
- package/extensions/runtime/nested-read-agents.ts +22 -0
- package/extensions/runtime/pending-child-controller.test.ts +169 -0
- package/extensions/runtime/pending-child-controller.ts +280 -0
- package/extensions/runtime/types.ts +68 -0
- package/extensions/team/contracts.test.ts +111 -0
- package/extensions/team/lifecycle.test.ts +1095 -0
- package/extensions/team/lifecycle.ts +502 -0
- package/extensions/team/recipient-closure.test.ts +97 -0
- package/extensions/team/recipient-closure.ts +120 -0
- package/extensions/team/roster.test.ts +129 -0
- package/extensions/team/roster.ts +165 -0
- package/extensions/team/team-contracts.json +43 -0
- package/extensions/team/writer-screens.test.ts +50 -0
- package/extensions/team/writer-screens.ts +156 -0
- package/extensions/tools/agent-communication-tools.test.ts +306 -0
- package/extensions/tools/agent-communication-tools.ts +183 -0
- package/extensions/tools/coordination-tools.test.ts +670 -0
- package/extensions/tools/coordination-tools.ts +304 -0
- package/extensions/tools/delegation-guard.test.ts +95 -0
- package/extensions/tools/delegation-guard.ts +65 -0
- package/extensions/tools/file-claim-tools.test.ts +104 -0
- package/extensions/tools/file-claim-tools.ts +68 -0
- package/extensions/tools/model-tools.ts +53 -0
- package/extensions/tools/predefined-tools.test.ts +647 -0
- package/extensions/tools/predefined-tools.ts +331 -0
- package/extensions/tools/read-helper.test.ts +35 -0
- package/extensions/tools/task-runtime-tools.test.ts +334 -0
- package/extensions/tools/task-runtime-tools.ts +227 -0
- package/extensions/tools/team-tools.read-agent.test.ts +1924 -0
- package/extensions/tools/team-tools.ts +1270 -0
- package/extensions/ui/agent-follow-view.test.ts +520 -0
- package/extensions/ui/agent-follow-view.ts +779 -0
- package/extensions/ui/agent-navigation.test.ts +52 -0
- package/extensions/ui/agent-navigation.ts +55 -0
- package/extensions/ui/ansi.ts +16 -0
- package/extensions/ui/extensions-command.test.ts +336 -0
- package/extensions/ui/extensions-command.ts +282 -0
- package/extensions/ui/favorite-models-command.test.ts +280 -0
- package/extensions/ui/favorite-models-command.ts +423 -0
- package/extensions/ui/frame.ts +77 -0
- package/extensions/ui/input.ts +17 -0
- package/extensions/ui/read-agent-status.test.ts +117 -0
- package/extensions/ui/read-agent-status.ts +125 -0
- package/extensions/ui/renderers.ts +243 -0
- package/extensions/ui/status-widget.test.ts +143 -0
- package/extensions/ui/status-widget.ts +440 -0
- package/extensions/ui-frame.test.ts +69 -0
- package/package.json +75 -0
- package/skills/teams.md +256 -0
- package/src/adapters/terminal-registry.ts +78 -0
- package/src/adapters/tmux-adapter.test.ts +276 -0
- package/src/adapters/tmux-adapter.ts +200 -0
- package/src/orchestration/index.ts +437 -0
- package/src/orchestration/orchestrator.test.ts +396 -0
- package/src/orchestration/types.ts +125 -0
- package/src/utils/atomic-json.ts +21 -0
- package/src/utils/claims.test.ts +137 -0
- package/src/utils/claims.ts +169 -0
- package/src/utils/hooks.test.ts +75 -0
- package/src/utils/hooks.ts +35 -0
- package/src/utils/lifecycle-tombstone.test.ts +105 -0
- package/src/utils/lifecycle-tombstone.ts +268 -0
- package/src/utils/lock.race.child.ts +44 -0
- package/src/utils/lock.race.test.ts +198 -0
- package/src/utils/lock.test.ts +90 -0
- package/src/utils/lock.ts +186 -0
- package/src/utils/messaging.test.ts +337 -0
- package/src/utils/messaging.ts +441 -0
- package/src/utils/model-resolution.test.ts +231 -0
- package/src/utils/model-resolution.ts +322 -0
- package/src/utils/models.test.ts +8 -0
- package/src/utils/models.ts +115 -0
- package/src/utils/paths.ts +81 -0
- package/src/utils/predefined-teams/types.ts +48 -0
- package/src/utils/predefined-teams.save-template.test.ts +74 -0
- package/src/utils/predefined-teams.test.ts +441 -0
- package/src/utils/predefined-teams.ts +471 -0
- package/src/utils/read-helper-queue.ts +99 -0
- package/src/utils/report-events.test.ts +154 -0
- package/src/utils/report-events.ts +222 -0
- package/src/utils/runtime.test.ts +314 -0
- package/src/utils/runtime.ts +261 -0
- package/src/utils/security.test.ts +43 -0
- package/src/utils/settings.test.ts +480 -0
- package/src/utils/settings.ts +645 -0
- package/src/utils/shared-memory.test.ts +80 -0
- package/src/utils/shared-memory.ts +81 -0
- package/src/utils/tasks.race.test.ts +44 -0
- package/src/utils/tasks.test.ts +229 -0
- package/src/utils/tasks.ts +396 -0
- package/src/utils/teams.ts +231 -0
- package/src/utils/terminal-adapter.ts +103 -0
- package/src/utils/thinking-levels.test.ts +56 -0
- package/src/utils/thinking-levels.ts +47 -0
- package/src/utils/workflow-metadata.test.ts +11 -0
- package/src/utils/workflow-metadata.ts +88 -0
- package/src/utils/write-queue.test.ts +251 -0
- package/src/utils/write-queue.ts +202 -0
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
import { getSettingsListTheme } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
import {
|
|
3
|
+
Container,
|
|
4
|
+
Key,
|
|
5
|
+
matchesKey,
|
|
6
|
+
type SettingItem,
|
|
7
|
+
SettingsList,
|
|
8
|
+
Text,
|
|
9
|
+
truncateToWidth,
|
|
10
|
+
} from "@mariozechner/pi-tui";
|
|
11
|
+
import {
|
|
12
|
+
globalSettingsPath,
|
|
13
|
+
loadSettings,
|
|
14
|
+
readProjectExtensionAllowOverride,
|
|
15
|
+
replaceGlobalExtensionAllow,
|
|
16
|
+
type PiExtendedTeamsSettings,
|
|
17
|
+
} from "../../src/utils/settings";
|
|
18
|
+
import {
|
|
19
|
+
createSpawnResourcePlan,
|
|
20
|
+
EXTENSIONS_COMMAND_DESCRIPTION,
|
|
21
|
+
parentProjectTrustForSpawn,
|
|
22
|
+
type SpawnExtensionCandidate,
|
|
23
|
+
type SpawnResourcePlan,
|
|
24
|
+
} from "../resources/spawn-resource-plan";
|
|
25
|
+
|
|
26
|
+
const MODE_SETTING_ID = "__selection_mode__";
|
|
27
|
+
|
|
28
|
+
interface ExtensionPickerResult {
|
|
29
|
+
mode: "default" | "explicit";
|
|
30
|
+
selectedIdentities: readonly string[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface ExtensionsCommandOptions {
|
|
34
|
+
createResourcePlan?(
|
|
35
|
+
options: Parameters<typeof createSpawnResourcePlan>[0],
|
|
36
|
+
): ReturnType<typeof createSpawnResourcePlan> | Promise<ReturnType<typeof createSpawnResourcePlan>>;
|
|
37
|
+
loadTeamsSettings?: typeof loadSettings;
|
|
38
|
+
saveAllow?: typeof replaceGlobalExtensionAllow;
|
|
39
|
+
readProjectExtensionAllowOverride?: typeof readProjectExtensionAllowOverride;
|
|
40
|
+
getGlobalSettingsPath?: typeof globalSettingsPath;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function provenanceLabel(extension: SpawnExtensionCandidate): string {
|
|
44
|
+
const sourceInfo = extension.sourceInfo;
|
|
45
|
+
return [sourceInfo.scope, sourceInfo.origin, sourceInfo.source].filter(Boolean).join(" · ");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function displayLabel(extension: SpawnExtensionCandidate): string {
|
|
49
|
+
const normalized = extension.path.replace(/\\/g, "/");
|
|
50
|
+
const base = normalized.split("/").pop() || normalized;
|
|
51
|
+
if (/^index\.(?:ts|js|mjs|cjs)$/.test(base)) {
|
|
52
|
+
return normalized.split("/").at(-2) || normalized;
|
|
53
|
+
}
|
|
54
|
+
return base;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function stateLabel(extension: SpawnExtensionCandidate): string {
|
|
58
|
+
switch (extension.state) {
|
|
59
|
+
case "selected": return "enabled";
|
|
60
|
+
case "available": return "disabled";
|
|
61
|
+
case "blocked": return "blocked";
|
|
62
|
+
case "self": return "internal";
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function preservedConfiguredEntries(plan: SpawnResourcePlan): string[] {
|
|
67
|
+
const preservedCodes = new Set(["stale-selection", "blocked-selection"]);
|
|
68
|
+
return plan.diagnostics.flatMap((diagnostic) => {
|
|
69
|
+
return diagnostic.configuredEntry && preservedCodes.has(diagnostic.code)
|
|
70
|
+
? [diagnostic.configuredEntry]
|
|
71
|
+
: [];
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function explicitAllowFromPicker(
|
|
76
|
+
plan: SpawnResourcePlan,
|
|
77
|
+
result: ExtensionPickerResult,
|
|
78
|
+
): string[] | null {
|
|
79
|
+
if (result.mode === "default") return null;
|
|
80
|
+
const selected = new Set(result.selectedIdentities);
|
|
81
|
+
return Array.from(new Set([
|
|
82
|
+
...plan.extensions
|
|
83
|
+
.filter((extension) => selected.has(extension.identity) && (extension.state === "selected" || extension.state === "available"))
|
|
84
|
+
.map((extension) => extension.identity),
|
|
85
|
+
...preservedConfiguredEntries(plan),
|
|
86
|
+
]));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function formatExtensionsPlan(plan: SpawnResourcePlan): string {
|
|
90
|
+
const lines = [
|
|
91
|
+
"Agent extensions",
|
|
92
|
+
"Skills use normal Pi discovery and trust. pi-extended-teams is handled internally.",
|
|
93
|
+
"Visibility: Pi exposes sourceInfo for registered commands/tools; event-only extensions cannot be propagated.",
|
|
94
|
+
`Selection: ${plan.selectionMode === "default" ? "default (all observable loaded)" : "explicit"}`,
|
|
95
|
+
"",
|
|
96
|
+
];
|
|
97
|
+
for (const extension of plan.extensions) {
|
|
98
|
+
lines.push(`- ${stateLabel(extension)} · ${displayLabel(extension)} · ${provenanceLabel(extension)}`);
|
|
99
|
+
lines.push(` ${extension.path}`);
|
|
100
|
+
}
|
|
101
|
+
for (const diagnostic of plan.diagnostics) lines.push(`! ${diagnostic.message}`);
|
|
102
|
+
return lines.join("\n");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function showExtensionsPicker(
|
|
106
|
+
ctx: any,
|
|
107
|
+
plan: SpawnResourcePlan,
|
|
108
|
+
): Promise<ExtensionPickerResult | null> {
|
|
109
|
+
return ctx.ui.custom((tui: any, theme: any, _keybindings: any, done: (value: ExtensionPickerResult | null) => void) => {
|
|
110
|
+
let mode = plan.selectionMode;
|
|
111
|
+
const selectedIdentities = new Set(
|
|
112
|
+
plan.extensions.filter((extension) => extension.selected).map((extension) => extension.identity),
|
|
113
|
+
);
|
|
114
|
+
const candidateByItemId = new Map<string, SpawnExtensionCandidate>();
|
|
115
|
+
const itemIdByIdentity = new Map<string, string>();
|
|
116
|
+
|
|
117
|
+
const items: SettingItem[] = [{
|
|
118
|
+
id: MODE_SETTING_ID,
|
|
119
|
+
label: "Selection policy",
|
|
120
|
+
currentValue: mode,
|
|
121
|
+
values: ["default", "explicit"],
|
|
122
|
+
description: "default follows all observable loaded Pi extensions; explicit uses only saved choices",
|
|
123
|
+
}];
|
|
124
|
+
plan.extensions.forEach((extension, index) => {
|
|
125
|
+
const id = `extension:${index}`;
|
|
126
|
+
candidateByItemId.set(id, extension);
|
|
127
|
+
itemIdByIdentity.set(extension.identity, id);
|
|
128
|
+
const selectable = extension.state === "selected" || extension.state === "available";
|
|
129
|
+
items.push({
|
|
130
|
+
id,
|
|
131
|
+
label: displayLabel(extension),
|
|
132
|
+
currentValue: stateLabel(extension),
|
|
133
|
+
values: selectable ? ["enabled", "disabled"] : undefined,
|
|
134
|
+
description: `${provenanceLabel(extension)} · ${extension.path}`,
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
plan.diagnostics.forEach((diagnostic, index) => {
|
|
138
|
+
if (!diagnostic.configuredEntry) return;
|
|
139
|
+
items.push({
|
|
140
|
+
id: `diagnostic:${index}`,
|
|
141
|
+
label: diagnostic.configuredEntry,
|
|
142
|
+
currentValue: diagnostic.code === "stale-selection" ? "missing" : "unavailable",
|
|
143
|
+
description: diagnostic.message,
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
const container = new Container();
|
|
148
|
+
container.addChild(new Text([
|
|
149
|
+
theme.fg("accent", theme.bold("Agent extension selection")),
|
|
150
|
+
theme.fg("dim", "Skills use normal Pi discovery and trust. pi-extended-teams is handled internally."),
|
|
151
|
+
theme.fg("dim", `ctrl+s: save explicit choices · esc: cancel · ${globalSettingsPath()}`),
|
|
152
|
+
"",
|
|
153
|
+
].join("\n"), 1, 0));
|
|
154
|
+
|
|
155
|
+
let settingsList: SettingsList;
|
|
156
|
+
const refreshValues = () => {
|
|
157
|
+
settingsList.updateValue(MODE_SETTING_ID, mode);
|
|
158
|
+
for (const extension of plan.extensions) {
|
|
159
|
+
const id = itemIdByIdentity.get(extension.identity);
|
|
160
|
+
if (!id || (extension.state !== "selected" && extension.state !== "available")) continue;
|
|
161
|
+
settingsList.updateValue(id, selectedIdentities.has(extension.identity) ? "enabled" : "disabled");
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
settingsList = new SettingsList(
|
|
165
|
+
items,
|
|
166
|
+
Math.min(items.length + 2, 18),
|
|
167
|
+
getSettingsListTheme(),
|
|
168
|
+
(id, newValue) => {
|
|
169
|
+
if (id === MODE_SETTING_ID) {
|
|
170
|
+
mode = newValue === "default" ? "default" : "explicit";
|
|
171
|
+
if (mode === "default") {
|
|
172
|
+
for (const extension of plan.extensions) {
|
|
173
|
+
if (extension.state !== "blocked" && extension.state !== "self") {
|
|
174
|
+
selectedIdentities.add(extension.identity);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
refreshValues();
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
const extension = candidateByItemId.get(id);
|
|
182
|
+
if (!extension) return;
|
|
183
|
+
mode = "explicit";
|
|
184
|
+
if (newValue === "enabled") selectedIdentities.add(extension.identity);
|
|
185
|
+
else selectedIdentities.delete(extension.identity);
|
|
186
|
+
refreshValues();
|
|
187
|
+
},
|
|
188
|
+
() => done(null),
|
|
189
|
+
{ enableSearch: true },
|
|
190
|
+
);
|
|
191
|
+
container.addChild(settingsList);
|
|
192
|
+
|
|
193
|
+
return {
|
|
194
|
+
render(width: number) {
|
|
195
|
+
return container.render(width).map((line) => truncateToWidth(line, width));
|
|
196
|
+
},
|
|
197
|
+
invalidate() {
|
|
198
|
+
container.invalidate();
|
|
199
|
+
},
|
|
200
|
+
handleInput(data: string) {
|
|
201
|
+
if (matchesKey(data, Key.ctrl("s"))) {
|
|
202
|
+
done({ mode, selectedIdentities: [...selectedIdentities] });
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
settingsList.handleInput(data);
|
|
206
|
+
tui.requestRender();
|
|
207
|
+
},
|
|
208
|
+
};
|
|
209
|
+
}, {
|
|
210
|
+
overlay: true,
|
|
211
|
+
overlayOptions: { width: "92%", maxHeight: "88%", anchor: "center" },
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function registerExtensionsCommand(pi: any, options: ExtensionsCommandOptions = {}): void {
|
|
216
|
+
const createResourcePlan = options.createResourcePlan ?? createSpawnResourcePlan;
|
|
217
|
+
const loadTeamsSettings = options.loadTeamsSettings ?? loadSettings;
|
|
218
|
+
const saveAllow = options.saveAllow ?? replaceGlobalExtensionAllow;
|
|
219
|
+
const readProjectOverride = options.readProjectExtensionAllowOverride ?? readProjectExtensionAllowOverride;
|
|
220
|
+
const getGlobalSettingsPath = options.getGlobalSettingsPath ?? globalSettingsPath;
|
|
221
|
+
|
|
222
|
+
const savePolicy = (allow: string[] | null, ctx: any, successMessage: string): void => {
|
|
223
|
+
const projectTrusted = parentProjectTrustForSpawn(ctx, ctx.cwd);
|
|
224
|
+
const projectOverride = projectTrusted ? readProjectOverride(ctx.cwd) : null;
|
|
225
|
+
saveAllow(allow);
|
|
226
|
+
if (projectOverride) {
|
|
227
|
+
ctx.ui.notify(
|
|
228
|
+
`Global spawned-agent extension policy was saved to ${getGlobalSettingsPath()}. The trusted project override at ${projectOverride.filePath} remains authoritative for spawned agents in this project.`,
|
|
229
|
+
"warning",
|
|
230
|
+
);
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
ctx.ui.notify(successMessage, "info");
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
pi.registerCommand("agents-extensions", {
|
|
237
|
+
description: EXTENSIONS_COMMAND_DESCRIPTION,
|
|
238
|
+
handler: async (args: string, ctx: any) => {
|
|
239
|
+
try {
|
|
240
|
+
const action = args.trim().toLowerCase();
|
|
241
|
+
if (action === "default") {
|
|
242
|
+
savePolicy(null, ctx, "Spawned agents now follow all effective enabled Pi extensions.");
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
if (action === "none") {
|
|
246
|
+
savePolicy([], ctx, "Spawned agents will load no external extensions. Skills remain enabled.");
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
if (action && action !== "list") {
|
|
250
|
+
throw new Error("Usage: /agents-extensions [list|default|none]");
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const projectTrusted = parentProjectTrustForSpawn(ctx, ctx.cwd);
|
|
254
|
+
const settings: PiExtendedTeamsSettings = loadTeamsSettings({
|
|
255
|
+
projectDir: projectTrusted ? ctx.cwd : undefined,
|
|
256
|
+
});
|
|
257
|
+
const plan = await createResourcePlan({
|
|
258
|
+
cwd: ctx.cwd,
|
|
259
|
+
projectTrusted,
|
|
260
|
+
settings,
|
|
261
|
+
pi,
|
|
262
|
+
});
|
|
263
|
+
if (action === "list" || ctx.mode !== "tui" || typeof ctx.ui.custom !== "function") {
|
|
264
|
+
ctx.ui.notify(formatExtensionsPlan(plan), "info");
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const result = await showExtensionsPicker(ctx, plan);
|
|
269
|
+
if (!result) return;
|
|
270
|
+
savePolicy(
|
|
271
|
+
explicitAllowFromPicker(plan, result),
|
|
272
|
+
ctx,
|
|
273
|
+
result.mode === "default"
|
|
274
|
+
? "Spawned-agent extensions reset to default."
|
|
275
|
+
: "Spawned-agent extension choices saved.",
|
|
276
|
+
);
|
|
277
|
+
} catch (error) {
|
|
278
|
+
ctx.ui.notify(error instanceof Error ? error.message : String(error), "warning");
|
|
279
|
+
}
|
|
280
|
+
},
|
|
281
|
+
});
|
|
282
|
+
}
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { registerFavoriteModelsCommand } from "./favorite-models-command.js";
|
|
6
|
+
import { globalSettingsPath } from "../../src/utils/settings";
|
|
7
|
+
|
|
8
|
+
let homeDir: string;
|
|
9
|
+
|
|
10
|
+
function setupCommand() {
|
|
11
|
+
const commands = new Map<string, any>();
|
|
12
|
+
const pi = { registerCommand: vi.fn((name: string, command: any) => commands.set(name, command)) };
|
|
13
|
+
registerFavoriteModelsCommand(pi);
|
|
14
|
+
const ctx = {
|
|
15
|
+
modelRegistry: {
|
|
16
|
+
getAvailable: vi.fn(async (): Promise<any[]> => [{ provider: "provider", id: "model", reasoning: true }]),
|
|
17
|
+
},
|
|
18
|
+
ui: { notify: vi.fn() },
|
|
19
|
+
};
|
|
20
|
+
return { commands, ctx, pi };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function testTheme() {
|
|
24
|
+
return {
|
|
25
|
+
fg: (_name: string, value: string) => value,
|
|
26
|
+
bold: (value: string) => value,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
beforeEach(() => {
|
|
31
|
+
homeDir = fs.mkdtempSync(path.join(os.tmpdir(), "pet-favorite-models-"));
|
|
32
|
+
vi.spyOn(os, "homedir").mockReturnValue(homeDir);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
afterEach(() => {
|
|
36
|
+
vi.restoreAllMocks();
|
|
37
|
+
fs.rmSync(homeDir, { recursive: true, force: true });
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe("/agents-favorite-models", () => {
|
|
41
|
+
it("registers and displays empty slots", async () => {
|
|
42
|
+
const { commands, ctx } = setupCommand();
|
|
43
|
+
|
|
44
|
+
await commands.get("agents-favorite-models").handler("", ctx);
|
|
45
|
+
|
|
46
|
+
expect(ctx.ui.notify).toHaveBeenCalledWith(expect.stringContaining("read-collect: (empty)"), "info");
|
|
47
|
+
expect(ctx.ui.notify).toHaveBeenCalledWith(expect.stringContaining("write-critical: (empty)"), "info");
|
|
48
|
+
expect(ctx.ui.notify).toHaveBeenCalledWith(expect.stringContaining("Saved in:"), "info");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("opens a single-screen picker with scoped available models and saves a slot", async () => {
|
|
52
|
+
const { commands } = setupCommand();
|
|
53
|
+
const notify = vi.fn();
|
|
54
|
+
const requestRender = vi.fn();
|
|
55
|
+
const custom = vi.fn(async (factory: any) => {
|
|
56
|
+
let doneValue: "saved" | "cancelled" | undefined;
|
|
57
|
+
const component = factory({ requestRender, terminal: { rows: 30 } }, testTheme(), {}, (value: "saved" | "cancelled") => {
|
|
58
|
+
doneValue = value;
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
expect(component.render(120).join("\n")).toContain("provider/model");
|
|
62
|
+
component.handleInput("l"); // focus scoped models
|
|
63
|
+
component.handleInput("\x1b[B"); // select first scoped model for read-collect
|
|
64
|
+
component.handleInput("\r"); // save
|
|
65
|
+
return doneValue;
|
|
66
|
+
});
|
|
67
|
+
const ctx = {
|
|
68
|
+
mode: "tui",
|
|
69
|
+
modelRegistry: {
|
|
70
|
+
getAvailable: vi.fn(async () => [{ provider: "provider", id: "model", reasoning: true }]),
|
|
71
|
+
},
|
|
72
|
+
ui: { notify, custom },
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
await commands.get("agents-favorite-models").handler("", ctx);
|
|
76
|
+
|
|
77
|
+
const raw = JSON.parse(fs.readFileSync(globalSettingsPath(homeDir), "utf-8"));
|
|
78
|
+
expect(raw.favoriteModels["read-collect"]).toEqual({ model: "provider/model", thinking: "high" });
|
|
79
|
+
expect(custom).toHaveBeenCalled();
|
|
80
|
+
expect(notify).toHaveBeenCalledWith("Agent favorite models saved.", "info");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("applies the calibrated thinking default for all eight canonical tiers", async () => {
|
|
84
|
+
const { commands } = setupCommand();
|
|
85
|
+
const custom = vi.fn(async (factory: any) => {
|
|
86
|
+
let doneValue: "saved" | "cancelled" | undefined;
|
|
87
|
+
const component = factory(
|
|
88
|
+
{ requestRender: vi.fn(), terminal: { rows: 30 } },
|
|
89
|
+
testTheme(),
|
|
90
|
+
{},
|
|
91
|
+
(value: "saved" | "cancelled") => { doneValue = value; },
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
component.handleInput("l");
|
|
95
|
+
for (let index = 0; index < 8; index += 1) {
|
|
96
|
+
component.handleInput("\x1b[B");
|
|
97
|
+
if (index < 7) {
|
|
98
|
+
component.handleInput("h");
|
|
99
|
+
component.handleInput("\x1b[B");
|
|
100
|
+
component.handleInput("l");
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
component.handleInput("\r");
|
|
104
|
+
return doneValue;
|
|
105
|
+
});
|
|
106
|
+
const ctx = {
|
|
107
|
+
mode: "tui",
|
|
108
|
+
modelRegistry: {
|
|
109
|
+
getAvailable: vi.fn(async () => [{
|
|
110
|
+
provider: "provider",
|
|
111
|
+
id: "model",
|
|
112
|
+
reasoning: true,
|
|
113
|
+
thinkingLevelMap: { xhigh: "xhigh", max: "max" },
|
|
114
|
+
}]),
|
|
115
|
+
},
|
|
116
|
+
ui: { notify: vi.fn(), custom },
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
await commands.get("agents-favorite-models").handler("", ctx);
|
|
120
|
+
|
|
121
|
+
const raw = JSON.parse(fs.readFileSync(globalSettingsPath(homeDir), "utf-8"));
|
|
122
|
+
expect(raw.favoriteModels).toEqual({
|
|
123
|
+
"read-collect": { model: "provider/model", thinking: "high" },
|
|
124
|
+
"read-review": { model: "provider/model", thinking: "xhigh" },
|
|
125
|
+
"read-analyze": { model: "provider/model", thinking: "medium" },
|
|
126
|
+
"read-critical": { model: "provider/model", thinking: "xhigh" },
|
|
127
|
+
"write-patch": { model: "provider/model", thinking: "max" },
|
|
128
|
+
"write-feature": { model: "provider/model", thinking: "medium" },
|
|
129
|
+
"write-system": { model: "provider/model", thinking: "high" },
|
|
130
|
+
"write-critical": { model: "provider/model", thinking: "max" },
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("shows max only when the selected Pi model advertises it", async () => {
|
|
135
|
+
const { commands } = setupCommand();
|
|
136
|
+
const custom = vi.fn(async (factory: any) => {
|
|
137
|
+
const component = factory(
|
|
138
|
+
{ requestRender: vi.fn(), terminal: { rows: 30 } },
|
|
139
|
+
testTheme(),
|
|
140
|
+
{},
|
|
141
|
+
vi.fn(),
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
component.handleInput("l");
|
|
145
|
+
component.handleInput("\x1b[B");
|
|
146
|
+
component.handleInput("l");
|
|
147
|
+
const rendered = component.render(120).join("\n");
|
|
148
|
+
expect(rendered).toContain(" max");
|
|
149
|
+
expect(rendered).not.toContain(" xhigh");
|
|
150
|
+
return "cancelled";
|
|
151
|
+
});
|
|
152
|
+
const ctx = {
|
|
153
|
+
mode: "tui",
|
|
154
|
+
modelRegistry: {
|
|
155
|
+
getAvailable: vi.fn(async () => [{
|
|
156
|
+
provider: "provider",
|
|
157
|
+
id: "max-model",
|
|
158
|
+
reasoning: true,
|
|
159
|
+
thinkingLevelMap: { max: "max", xhigh: null },
|
|
160
|
+
}]),
|
|
161
|
+
},
|
|
162
|
+
ui: { notify: vi.fn(), custom },
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
await commands.get("agents-favorite-models").handler("", ctx);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("hides opt-in levels that the selected Pi model does not advertise", async () => {
|
|
169
|
+
const { commands } = setupCommand();
|
|
170
|
+
const custom = vi.fn(async (factory: any) => {
|
|
171
|
+
const component = factory(
|
|
172
|
+
{ requestRender: vi.fn(), terminal: { rows: 30 } },
|
|
173
|
+
testTheme(),
|
|
174
|
+
{},
|
|
175
|
+
vi.fn(),
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
component.handleInput("l");
|
|
179
|
+
component.handleInput("\x1b[B");
|
|
180
|
+
component.handleInput("l");
|
|
181
|
+
const rendered = component.render(120).join("\n");
|
|
182
|
+
expect(rendered).not.toContain(" max");
|
|
183
|
+
expect(rendered).not.toContain(" xhigh");
|
|
184
|
+
return "cancelled";
|
|
185
|
+
});
|
|
186
|
+
const ctx = {
|
|
187
|
+
mode: "tui",
|
|
188
|
+
modelRegistry: {
|
|
189
|
+
getAvailable: vi.fn(async () => [{ provider: "provider", id: "standard-model", reasoning: true }]),
|
|
190
|
+
},
|
|
191
|
+
ui: { notify: vi.fn(), custom },
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
await commands.get("agents-favorite-models").handler("", ctx);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("does not save a thinking-only empty slot from the picker", async () => {
|
|
198
|
+
const { commands } = setupCommand();
|
|
199
|
+
const notify = vi.fn();
|
|
200
|
+
const custom = vi.fn(async (factory: any) => {
|
|
201
|
+
let doneValue: "saved" | "cancelled" | undefined;
|
|
202
|
+
const component = factory({ requestRender: vi.fn(), terminal: { rows: 30 } }, testTheme(), {}, (value: "saved" | "cancelled") => {
|
|
203
|
+
doneValue = value;
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
component.handleInput("l");
|
|
207
|
+
component.handleInput("l"); // focus thinking with no model selected
|
|
208
|
+
component.handleInput("\x1b[B");
|
|
209
|
+
expect(component.render(120).join("\n")).toContain("Pick a scoped model for read-collect before choosing thinking.");
|
|
210
|
+
component.handleInput("\r");
|
|
211
|
+
return doneValue;
|
|
212
|
+
});
|
|
213
|
+
const ctx = {
|
|
214
|
+
mode: "tui",
|
|
215
|
+
modelRegistry: {
|
|
216
|
+
getAvailable: vi.fn(async () => [{ provider: "provider", id: "model", reasoning: true }]),
|
|
217
|
+
},
|
|
218
|
+
ui: { notify, custom },
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
await commands.get("agents-favorite-models").handler("", ctx);
|
|
222
|
+
|
|
223
|
+
const raw = JSON.parse(fs.readFileSync(globalSettingsPath(homeDir), "utf-8"));
|
|
224
|
+
expect(raw.favoriteModels).toEqual({});
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("sets and clears canonical tiers while accepting legacy CLI aliases", async () => {
|
|
228
|
+
const { commands, ctx } = setupCommand();
|
|
229
|
+
const command = commands.get("agents-favorite-models");
|
|
230
|
+
|
|
231
|
+
await command.handler("set reading-fast provider/model high", ctx);
|
|
232
|
+
|
|
233
|
+
let raw = JSON.parse(fs.readFileSync(globalSettingsPath(homeDir), "utf-8"));
|
|
234
|
+
expect(raw.favoriteModels["read-collect"]).toEqual({ model: "provider/model", thinking: "high" });
|
|
235
|
+
expect(raw.favoriteModels).not.toHaveProperty("reading-fast");
|
|
236
|
+
expect(ctx.ui.notify).toHaveBeenLastCalledWith("Set read-collect to provider/model · high.", "info");
|
|
237
|
+
|
|
238
|
+
await command.handler("clear reading-fast", ctx);
|
|
239
|
+
|
|
240
|
+
raw = JSON.parse(fs.readFileSync(globalSettingsPath(homeDir), "utf-8"));
|
|
241
|
+
expect(raw.favoriteModels).not.toHaveProperty("read-collect");
|
|
242
|
+
expect(ctx.ui.notify).toHaveBeenLastCalledWith("Cleared read-collect.", "info");
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it("accepts max only for a Pi model that advertises it", async () => {
|
|
246
|
+
const { commands, ctx } = setupCommand();
|
|
247
|
+
ctx.modelRegistry.getAvailable.mockResolvedValue([{
|
|
248
|
+
provider: "provider",
|
|
249
|
+
id: "max-model",
|
|
250
|
+
reasoning: true,
|
|
251
|
+
thinkingLevelMap: { max: "max" },
|
|
252
|
+
}]);
|
|
253
|
+
|
|
254
|
+
await commands.get("agents-favorite-models").handler("set read-critical provider/max-model max", ctx);
|
|
255
|
+
|
|
256
|
+
const raw = JSON.parse(fs.readFileSync(globalSettingsPath(homeDir), "utf-8"));
|
|
257
|
+
expect(raw.favoriteModels["read-critical"]).toEqual({ model: "provider/max-model", thinking: "max" });
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it("rejects max when the Pi model does not advertise it", async () => {
|
|
261
|
+
const { commands, ctx } = setupCommand();
|
|
262
|
+
|
|
263
|
+
await commands.get("agents-favorite-models").handler("set read-critical provider/model max", ctx);
|
|
264
|
+
|
|
265
|
+
expect(ctx.ui.notify).toHaveBeenCalledWith(
|
|
266
|
+
expect.stringContaining('Thinking level "max" is not available for provider/model'),
|
|
267
|
+
"warning",
|
|
268
|
+
);
|
|
269
|
+
expect(fs.existsSync(globalSettingsPath(homeDir))).toBe(false);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it("warns for invalid input without writing settings", async () => {
|
|
273
|
+
const { commands, ctx } = setupCommand();
|
|
274
|
+
|
|
275
|
+
await commands.get("agents-favorite-models").handler("set bad-slot provider/model low", ctx);
|
|
276
|
+
|
|
277
|
+
expect(ctx.ui.notify).toHaveBeenCalledWith(expect.stringContaining("Unknown slot"), "warning");
|
|
278
|
+
expect(fs.existsSync(globalSettingsPath(homeDir))).toBe(false);
|
|
279
|
+
});
|
|
280
|
+
});
|