mirror of
https://github.com/wassname/pi-plan.git
synced 2026-09-26 14:10:23 +08:00
Remember project model choices for planning worker and supervisor
Keep choices separate by role, ignore automatic restores, and fail when a remembered model is unavailable. Restore the worker model only after the planning fork is ready.
This commit is contained in:
@@ -38,12 +38,14 @@ Run Pi from the Git repository that the plan will change. **Ready** fails if the
|
||||
```text
|
||||
/goals <objective> create a new plan
|
||||
/goals model <model> select the visible supervisor model
|
||||
/goals model use Pi's current default model
|
||||
/goals model use the remembered supervisor model
|
||||
/goals clear close the supervisor pane and disconnect the plan
|
||||
```
|
||||
|
||||
`/goals clear` keeps the plan file. Starting another plan also keeps older versions.
|
||||
|
||||
Model choices are remembered per project and role in `.pi/pi-goals/models/`. Use `/model` in planning, worker, or supervisor sessions to change that role's choice. Ready restores the worker choice after the planning fork is ready. An unavailable saved model stops the transition instead of substituting another. `/goals model <model>` explicitly overrides the supervisor choice for launch. -- Pi/OpenAI
|
||||
|
||||
## Plan format
|
||||
|
||||
A goal is a checkbox line whose text starts with `goal:`:
|
||||
|
||||
@@ -21,10 +21,11 @@ User priority: pi-intercom is the session-to-session transport. Keep one pi-goal
|
||||
- failure modes: approving while a tracked job runs, treating a local queue as a dependency of remote work.
|
||||
- deliverable: idle/running/unknown status cases and a blocker-diagnosis scenario.
|
||||
- evidence: the same validation log checks active and unavailable tracker reports and rejection of approval with unknown background state. Actual independent diagnosis of the queue mistake remains a behavioral acceptance task.
|
||||
- [ ] goal: role model choices persist
|
||||
- [x] goal: role model choices persist
|
||||
- Borrow planning/worker/supervisor model preference behavior without changing active user settings.
|
||||
- failure modes: automatic model changes overwrite user choices; a missing model silently substitutes another.
|
||||
- deliverable: isolated preference restoration and explicit unavailable-model errors.
|
||||
- evidence: [45-test validation](../reviews/20260908-role-model-validation.txt) restores three distinct role choices, ignores automatic restore events, and leaves an unavailable saved choice unchanged. Preferences are project-local; active user settings were not edited.
|
||||
|
||||
## Verification
|
||||
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
|
||||
> @wassname2/pi-goals@0.2.2 test
|
||||
> vitest run --reporter=verbose
|
||||
|
||||
|
||||
RUN v4.1.9 /home/code/.pi/agent/git/github.com/wassname/pi-goals
|
||||
|
||||
✓ test/package-agent.test.ts > package manifest > includes the extension without registering a packaged subagent 3ms
|
||||
stdout | test/role-models.test.ts > remembers each role without automatic switching overwriting another role
|
||||
Role preferences restored: planning=planner, worker=small-worker, supervisor=astra; restore events did not overwrite the choice.
|
||||
|
||||
✓ test/background.test.ts > reports tracked running work, rather than equating idle agent with finished jobs 2ms
|
||||
✓ test/background.test.ts > distinguishes missing providers from an unavailable installed tracker 6ms
|
||||
✓ test/role-models.test.ts > remembers each role without automatic switching overwriting another role 7ms
|
||||
✓ test/role-models.test.ts > fails on an unavailable remembered model without replacing the choice 6ms
|
||||
✓ test/prompts.test.ts > planning prompt > requires fact finding or a focused question before a goal 2ms
|
||||
✓ test/prompts.test.ts > planning prompt > restores the same rule after compaction 0ms
|
||||
✓ test/prompts.test.ts > planning prompt > anchors work and sign-off to the user-visible result 0ms
|
||||
✓ test/intercom.test.ts > pi-intercom transport > receives exact advice once, acknowledges it and rejects unrelated peers 5ms
|
||||
✓ test/intercom.test.ts > pi-intercom transport > restores an unacknowledged steer on reconnect and stops replay after acknowledgment 3ms
|
||||
✓ test/intercom.test.ts > pi-intercom transport > advances the incremental overview only after acknowledgment 1ms
|
||||
✓ test/intercom.test.ts > pi-intercom transport > cancels a readiness wait on shutdown 1ms
|
||||
✓ test/herdr.test.ts > supervisor pane command > forks the planning session with pi-goals owning its Intercom dependency 2ms
|
||||
✓ test/herdr.test.ts > supervisor pane command > accepts Herdr's text version output and stale pane cleanup 30ms
|
||||
✓ test/worker-view.test.ts > keeps human direction and source location while sending only new messages 2ms
|
||||
✓ test/worker-view.test.ts > restarts after compaction and does not report historical tool calls as active 0ms
|
||||
✓ test/worker-view.test.ts > bounds serialized Unicode and quoted logs while marking omissions 2ms
|
||||
✓ test/fold.test.ts > foldPlan (current goals are above ## Log; durable memory is below it) > keeps the title, user voice and goals 2ms
|
||||
✓ test/fold.test.ts > foldPlan (current goals are above ## Log; durable memory is below it) > drops the log, the learnings and the unlimited appendix 0ms
|
||||
✓ test/fold.test.ts > foldPlan (current goals are above ## Log; durable memory is below it) > returns the whole plan when there is no ## Log yet (a fresh draft) 0ms
|
||||
✓ test/fold.test.ts > openSubtasks (the widget shows the next action, so the plan IS the task list) > lists the active goal's open and in-progress subtasks, stopping at the next goal 1ms
|
||||
✓ test/fold.test.ts > openSubtasks (the widget shows the next action, so the plan IS the task list) > does not leak subtasks from the goal below 1ms
|
||||
stdout | test/intercom-broker.test.ts > exchanges readiness, views and exact advice over a real isolated pi-intercom broker
|
||||
Intercom broker: readiness confirmed; exact worker view and supervisor advice received.
|
||||
|
||||
✓ test/append-log.test.ts > appendLog (the extension's only plan-file write) > creates ## Log at EOF when absent 1ms
|
||||
✓ test/append-log.test.ts > appendLog (the extension's only plan-file write) > appends after the last existing log line, before any following header 0ms
|
||||
✓ test/tick-goal.test.ts > tickGoal (sign-off ticks the goal; agent only ticks on wording drift) > ticks the exact-matching goal line, case-insensitive, leaving subtasks alone 2ms
|
||||
✓ test/tick-goal.test.ts > tickGoal (sign-off ticks the goal; agent only ticks on wording drift) > returns null on wording drift (fuzzy matching is the judge's job, not TypeScript's) 0ms
|
||||
✓ test/tick-goal.test.ts > tickGoal (sign-off ticks the goal; agent only ticks on wording drift) > returns null when the subject matches more than one goal line 0ms
|
||||
✓ test/intercom-broker.test.ts > exchanges readiness, views and exact advice over a real isolated pi-intercom broker 222ms
|
||||
✓ test/goals-flow.test.ts > /goals flow > reports actual idle state, invalidates stopped views on start, and stops completed plans 36ms
|
||||
✓ test/goals-flow.test.ts > /goals flow > preserves drafts, records the interview, and keeps planning read-only 24ms
|
||||
✓ test/goals-flow.test.ts > /goals flow > forks a visible supervisor on Ready and keeps the main session as worker 26ms
|
||||
✓ test/goals-flow.test.ts > /goals flow > starts work only after the supervisor launcher resolves 21ms
|
||||
✓ test/goals-flow.test.ts > /goals flow > delivers an Intercom instruction to the worker 21ms
|
||||
✓ test/goals-flow.test.ts > /goals flow > closes the supervisor on clear but keeps the plan file 19ms
|
||||
✓ test/goals-flow.test.ts > /goals flow > accepts only an approval for the exact clean commit and goal block 47ms
|
||||
✓ test/goals-flow.test.ts > process role > keeps subagent children and visible supervisors out of the worker extension 0ms
|
||||
✓ test/supervisor-session.test.ts > visible supervisor session > restores monitoring and read-only tools without replaying persisted views 7ms
|
||||
✓ test/supervisor-session.test.ts > visible supervisor session > renders all advice in real Pi tool rows, including collapsed and restored rows 22ms
|
||||
✓ test/supervisor-session.test.ts > visible supervisor session > asks for judgment and useful recaps without inventing instructions 1ms
|
||||
✓ test/supervisor-session.test.ts > visible supervisor session > writes readiness only after removing writing tools 2ms
|
||||
✓ test/supervisor-session.test.ts > visible supervisor session > compacts a large planning fork before writing readiness 2ms
|
||||
✓ test/supervisor-session.test.ts > visible supervisor session > does not become ready when initial compaction fails 3ms
|
||||
✓ test/supervisor-session.test.ts > visible supervisor session > writes a durable worker instruction 5ms
|
||||
✓ test/supervisor-session.test.ts > visible supervisor session > records approval only from a stopped view with evidence and no active work 49ms
|
||||
✓ test/rpc-review.test.ts > RPC review flow > opens Refine's editor before it starts the revision turn 1427ms
|
||||
|
||||
Test Files 14 passed (14)
|
||||
Tests 45 passed (45)
|
||||
Start at 16:49:24
|
||||
Duration 1.79s (transform 1.51s, setup 0ms, import 4.33s, tests 2.03s, environment 1ms)
|
||||
|
||||
|
||||
> @wassname2/pi-goals@0.2.2 typecheck
|
||||
> tsc --noEmit
|
||||
|
||||
|
||||
> @wassname2/pi-goals@0.2.2 lint
|
||||
> biome check src/ test/
|
||||
|
||||
Checked 26 files in 62ms. No fixes applied.
|
||||
@@ -36,6 +36,7 @@ export function repositoryState(cwd: string): { repoRoot: string; head: string;
|
||||
"status", "--porcelain=v1", "--untracked-files=all", "--", ".",
|
||||
`:(exclude,glob)${owned}/plan/*.md`,
|
||||
`:(exclude,glob)${owned}/pi-goals/approvals/*`,
|
||||
`:(exclude,glob)${owned}/pi-goals/models/*`,
|
||||
]) === "";
|
||||
return { repoRoot, head, tree, cleanWorktree };
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@ function stalePaneError(error: unknown): boolean {
|
||||
export function supervisorCommand(input: LaunchSupervisorInput): string {
|
||||
const env = [
|
||||
"PI_GOALS_ROLE=supervisor",
|
||||
`PI_GOALS_MODEL_EXPLICIT=${input.model ? "1" : "0"}`,
|
||||
`PI_GOALS_WORKER_ID=${input.workerSessionId}`,
|
||||
`PI_GOALS_PLAN_PATH=${input.planPath}`,
|
||||
`PI_GOALS_APPROVAL_ID=${input.approvalId}`,
|
||||
|
||||
+12
-1
@@ -25,6 +25,7 @@ import { backgroundState } from "./background.js";
|
||||
import { closeSupervisorPane, openSupervisorPane } from "./herdr.js";
|
||||
import { GoalIntercom } from "./intercom.js";
|
||||
import { completeGoalDescription, completeGoalParamDescription, planDrafting, planningState, resync } from "./prompts.js";
|
||||
import { RoleModels } from "./role-models.js";
|
||||
import { isVisibleSupervisor, registerVisibleSupervisor } from "./supervisor-session.js";
|
||||
import { workerView } from "./worker-view.js";
|
||||
|
||||
@@ -108,6 +109,7 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
|
||||
}
|
||||
if (!isMainSession()) return;
|
||||
const intercom = new GoalIntercom(pi);
|
||||
const models = new RoleModels(pi);
|
||||
intercom.onSteer = (instruction) => {
|
||||
if (state.phase !== "working") throw new Error("Worker plan is not active; instruction rejected.");
|
||||
pi.sendUserMessage(`[supervisor] ${instruction}`, { deliverAs: "steer" });
|
||||
@@ -208,6 +210,7 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
|
||||
if (goals.length > 0 && goals.every((goal) => goal.status === "done" || goal.status === "cancelled")) {
|
||||
stopWorkerTimers();
|
||||
state = { ...state, phase: null };
|
||||
models.leave();
|
||||
persist();
|
||||
}
|
||||
}
|
||||
@@ -282,6 +285,7 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
|
||||
return;
|
||||
}
|
||||
state = { ...state, phase: null, supervisorPaneId: null, approvalId: null, planVersion: null };
|
||||
models.leave();
|
||||
persist();
|
||||
updateWidget(ctx);
|
||||
ctx.ui.notify(`Disconnected from ${currentPlan}; the file remains on disk.`, "info");
|
||||
@@ -299,13 +303,14 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
|
||||
const ref = arg.slice("model".length).trim();
|
||||
state = { ...state, supervisorModel: ref || null, supervisorPaneId: null, approvalId: null };
|
||||
persist();
|
||||
ctx.ui.notify(`Goal-supervisor model ${ref ? `set to ${ref}` : "reset to the current Pi default"}.`, "info");
|
||||
ctx.ui.notify(`Goal-supervisor model ${ref ? `set to ${ref}` : "reset to the remembered supervisor model"}.`, "info");
|
||||
return;
|
||||
}
|
||||
if (!(await stopSupervisor())) {
|
||||
ctx.ui.notify("Could not close the visible supervisor; no new plan was started.", "warning");
|
||||
return;
|
||||
}
|
||||
await models.enter("planning", ctx);
|
||||
state = { ...state, phase: "planning", supervisorPaneId: null, approvalId: null, planVersion: nextVersion(ctx), latestDirection: arg };
|
||||
planningContextPending = true;
|
||||
resyncReason = null;
|
||||
@@ -408,6 +413,7 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
|
||||
if (state.phase !== "planning" || !ctx.hasUI) return;
|
||||
let printed = "";
|
||||
while (true) {
|
||||
if (intercom.ended) return;
|
||||
const plan = readPlan(ctx);
|
||||
if (scanGoals(plan).length === 0) {
|
||||
if (plan.trim()) ctx.ui.notify(`The plan has no goal line. Revise ${planRel(ctx)} to add one.`, "warning");
|
||||
@@ -435,6 +441,7 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
|
||||
}
|
||||
if (choice === "Cancel") {
|
||||
rmSync(planPath(ctx), { force: true });
|
||||
models.leave();
|
||||
state = { ...state, phase: null, supervisorPaneId: null, approvalId: null, planVersion: null };
|
||||
persist();
|
||||
updateWidget(ctx);
|
||||
@@ -444,6 +451,8 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
|
||||
if (choice !== "Ready") return;
|
||||
try {
|
||||
await startSupervisor(ctx);
|
||||
if (intercom.ended) return;
|
||||
await models.enter("worker", ctx);
|
||||
state = { ...state, phase: "working" };
|
||||
resyncReason = "The plan was approved.";
|
||||
persist();
|
||||
@@ -453,6 +462,7 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
|
||||
ctx.ui.notify(`Visible supervisor opened in Herdr pane ${state.supervisorPaneId}.`, "info");
|
||||
pi.sendUserMessage("The plan is approved. Begin implementation as the worker.");
|
||||
} catch (error) {
|
||||
if (intercom.ended) return;
|
||||
ctx.ui.notify(`Goal supervisor could not start: ${error instanceof Error ? error.message : String(error)}`, "warning");
|
||||
state = { ...state, phase: "planning" };
|
||||
persist();
|
||||
@@ -475,6 +485,7 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
|
||||
planVersion: last?.data?.planVersion ?? null,
|
||||
latestDirection: last?.data?.latestDirection ?? "",
|
||||
};
|
||||
if (state.phase) await models.enter(state.phase === "planning" ? "planning" : "worker", ctx);
|
||||
planningContextPending = state.phase === "planning";
|
||||
resyncReason = state.phase === "working" ? "New session." : null;
|
||||
if (state.phase === "working") {
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
||||
|
||||
export type ModelRole = "planning" | "worker" | "supervisor";
|
||||
interface Choice { provider: string; id: string }
|
||||
|
||||
// Pi/OpenAI: Per-role model memory, adapted from cecb1e9; preferences stay in the project.
|
||||
export class RoleModels {
|
||||
private role?: ModelRole;
|
||||
private ctx?: ExtensionContext;
|
||||
private switching = false;
|
||||
private stopped = false;
|
||||
|
||||
constructor(private pi: ExtensionAPI) {
|
||||
pi.on("session_shutdown", async () => { this.stopped = true; this.leave(); });
|
||||
pi.on("model_select", async (event) => {
|
||||
if (this.switching || event.source === "restore" || !this.role || !this.ctx) return;
|
||||
this.save(this.role, this.ctx, event.model);
|
||||
});
|
||||
}
|
||||
|
||||
leave(): void { this.role = undefined; }
|
||||
|
||||
async enter(role: ModelRole, ctx: ExtensionContext, useCurrent = false): Promise<void> {
|
||||
this.role = role;
|
||||
this.ctx = ctx;
|
||||
let choice: Choice | undefined;
|
||||
if (!useCurrent) {
|
||||
try { choice = JSON.parse(readFileSync(this.path(role, ctx), "utf8")); }
|
||||
catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
|
||||
}
|
||||
if (choice && (!choice.provider || !choice.id)) throw new Error(`Invalid saved ${role} model.`);
|
||||
const model = choice ? ctx.modelRegistry.find(choice.provider, choice.id) : ctx.model;
|
||||
if (!model) throw new Error(`${role} model is unavailable. Select an available model with /model, then retry. Saved choice was not replaced.`);
|
||||
this.switching = true;
|
||||
try {
|
||||
if (!await this.pi.setModel(model)) throw new Error(`${role} model ${model.provider}/${model.id} is unavailable or unauthenticated. Saved choice was not replaced.`);
|
||||
if (this.stopped) throw new Error("Role model session ended.");
|
||||
this.save(role, ctx, model);
|
||||
ctx.ui.notify(`${role} model: ${model.provider}/${model.id}`, "info");
|
||||
} finally { this.switching = false; }
|
||||
}
|
||||
|
||||
private path(role: ModelRole, ctx: ExtensionContext): string {
|
||||
return join(ctx.cwd, ".pi", "pi-goals", "models", `${role}.json`);
|
||||
}
|
||||
private save(role: ModelRole, ctx: ExtensionContext, model: Choice): void {
|
||||
const path = this.path(role, ctx);
|
||||
mkdirSync(join(ctx.cwd, ".pi", "pi-goals", "models"), { recursive: true });
|
||||
const temporary = `${path}.${process.pid}.tmp`;
|
||||
writeFileSync(temporary, `${JSON.stringify({ provider: model.provider, id: model.id })}\n`);
|
||||
renameSync(temporary, path);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { Text } from "@earendil-works/pi-tui";
|
||||
import { Type } from "typebox";
|
||||
import { approvalPath, goalBlock, hashGoalBlock, repositoryState, verifyOutputPath, writeApproval } from "./approval.js";
|
||||
import { GoalIntercom } from "./intercom.js";
|
||||
import { RoleModels } from "./role-models.js";
|
||||
|
||||
const BOOTSTRAPPED = "pi-goals-visible-supervisor-v2";
|
||||
const INITIAL_COMPACT_AT_TOKENS = 20_000;
|
||||
@@ -93,6 +94,7 @@ export function registerVisibleSupervisor(pi: ExtensionAPI): void {
|
||||
let compacting = false;
|
||||
let bootstrapping = false;
|
||||
const intercom = new GoalIntercom(pi);
|
||||
const models = new RoleModels(pi);
|
||||
intercom.onView = (view) => pi.sendUserMessage(view.text, { deliverAs: "followUp" });
|
||||
|
||||
const bootstrap = async (ctx: ExtensionContext): Promise<void> => {
|
||||
@@ -140,6 +142,7 @@ export function registerVisibleSupervisor(pi: ExtensionAPI): void {
|
||||
pi.on("session_start", async (_event, ctx) => {
|
||||
intercom.configure(settings.approvalId, "supervisor", ctx);
|
||||
pi.setActiveTools(pi.getActiveTools().filter((tool) => !WRITER_TOOLS.has(tool.toLowerCase())));
|
||||
await models.enter("supervisor", ctx, process.env.PI_GOALS_MODEL_EXPLICIT === "1");
|
||||
setImmediate(() => { bootstrapAfterInitialCompaction(ctx); });
|
||||
});
|
||||
pi.on("before_agent_start", async (_event, ctx) => ({ systemPrompt: `${ctx.getSystemPrompt()}\n\n${supervisorPrompt(settings)}` }));
|
||||
|
||||
@@ -31,6 +31,8 @@ function setup(selectChoices: Array<string | undefined>, editorChoices: Array<st
|
||||
hasUI: true,
|
||||
isIdle: vi.fn(() => true),
|
||||
getSystemPrompt: () => "base prompt",
|
||||
model: { provider: "test", id: "tiny" },
|
||||
modelRegistry: { find: (provider: string, id: string) => ({ provider, id }) },
|
||||
sessionManager: {
|
||||
getSessionId: () => "session-a",
|
||||
getSessionFile: () => join(cwd, "session.jsonl"),
|
||||
@@ -57,6 +59,7 @@ function setup(selectChoices: Array<string | undefined>, editorChoices: Array<st
|
||||
appendEntry: (customType: string, data: unknown) => { if (customType === "pi-goals-state") entries.push({ type: "custom", customType, data }); },
|
||||
registerTool: (tool: any) => tools.set(tool.name, tool),
|
||||
getAllTools: () => [],
|
||||
setModel: vi.fn(async () => true),
|
||||
sendMessage: (message: { content: string; display?: boolean }) => messages.push(message),
|
||||
sendUserMessage: (content: string) => messages.push({ content }),
|
||||
};
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||
import { expect, it, vi } from "vitest";
|
||||
import { RoleModels } from "../src/role-models.js";
|
||||
|
||||
function runtime(cwd: string) {
|
||||
const hooks = new Map<string, any>();
|
||||
const ctx = { cwd, model: { provider: "test", id: "planner" }, modelRegistry: { find: vi.fn((provider: string, id: string): any => ({ provider, id })) }, ui: { notify: vi.fn() } };
|
||||
const pi = {
|
||||
on: (name: string, fn: any) => hooks.set(name, fn),
|
||||
setModel: vi.fn(async (model: any) => { ctx.model = model; await hooks.get("model_select")({ source: "set", model }); return true; }),
|
||||
};
|
||||
const models = new RoleModels(pi as unknown as ExtensionAPI);
|
||||
return { models, ctx, pi, hooks, select: async (id: string, source = "set") => { ctx.model = { provider: "test", id }; await hooks.get("model_select")({ source, model: ctx.model }); } };
|
||||
}
|
||||
|
||||
it("remembers each role without automatic switching overwriting another role", async () => {
|
||||
const cwd = mkdtempSync(join(tmpdir(), "goals-role-models-"));
|
||||
try {
|
||||
const first = runtime(cwd);
|
||||
await first.models.enter("planning", first.ctx as any);
|
||||
await first.models.enter("worker", first.ctx as any);
|
||||
await first.select("small-worker");
|
||||
await first.models.enter("supervisor", first.ctx as any);
|
||||
await first.select("astra");
|
||||
await first.models.enter("planning", first.ctx as any);
|
||||
expect(first.ctx.model.id).toBe("planner");
|
||||
const resumed = runtime(cwd);
|
||||
await resumed.models.enter("worker", resumed.ctx as any);
|
||||
expect(resumed.ctx.model.id).toBe("small-worker");
|
||||
await resumed.models.enter("supervisor", resumed.ctx as any);
|
||||
expect(resumed.ctx.model.id).toBe("astra");
|
||||
await resumed.select("restored-default", "restore");
|
||||
expect(JSON.parse(readFileSync(join(cwd, ".pi/pi-goals/models/supervisor.json"), "utf8")).id).toBe("astra");
|
||||
console.log("Role preferences restored: planning=planner, worker=small-worker, supervisor=astra; restore events did not overwrite the choice.");
|
||||
} finally { rmSync(cwd, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
it("fails on an unavailable remembered model without replacing the choice", async () => {
|
||||
const cwd = mkdtempSync(join(tmpdir(), "goals-role-unavailable-"));
|
||||
try {
|
||||
const first = runtime(cwd);
|
||||
await first.models.enter("supervisor", first.ctx as any);
|
||||
await first.select("astra");
|
||||
const path = join(cwd, ".pi/pi-goals/models/supervisor.json");
|
||||
const saved = readFileSync(path, "utf8");
|
||||
const resumed = runtime(cwd);
|
||||
resumed.ctx.modelRegistry.find.mockReturnValue(undefined);
|
||||
await expect(resumed.models.enter("supervisor", resumed.ctx as any)).rejects.toThrow("unavailable");
|
||||
expect(resumed.pi.setModel).not.toHaveBeenCalled();
|
||||
expect(readFileSync(path, "utf8")).toBe(saved);
|
||||
await resumed.models.enter("supervisor", resumed.ctx as any, true);
|
||||
expect(JSON.parse(readFileSync(path, "utf8")).id).toBe("planner");
|
||||
resumed.models.leave();
|
||||
await resumed.select("unrelated-model");
|
||||
expect(JSON.parse(readFileSync(path, "utf8")).id).toBe("planner");
|
||||
} finally { rmSync(cwd, { recursive: true, force: true }); }
|
||||
});
|
||||
@@ -26,6 +26,8 @@ function setup(cwd: string, planPath: string, tokens: number | null = 10, onComp
|
||||
const ctx = {
|
||||
cwd,
|
||||
getSystemPrompt: () => "base",
|
||||
model: { provider: "test", id: "supervisor" },
|
||||
modelRegistry: { find: (provider: string, id: string) => ({ provider, id }) },
|
||||
getContextUsage: () => tokens === null ? undefined : ({ tokens }),
|
||||
compact: vi.fn(onCompact),
|
||||
sessionManager: { getEntries: () => entries, getBranch: () => branch, getSessionId: () => "supervisor-session" },
|
||||
@@ -41,6 +43,7 @@ function setup(cwd: string, planPath: string, tokens: number | null = 10, onComp
|
||||
appendEntry: (customType: string, data: unknown) => entries.push({ type: "custom", customType, data }),
|
||||
sendUserMessage: (message: string) => messages.push(message),
|
||||
getActiveTools: () => activeTools,
|
||||
setModel: vi.fn(async () => true),
|
||||
setActiveTools: (next: string[]) => { activeTools = next; },
|
||||
};
|
||||
registerVisibleSupervisor(pi as unknown as ExtensionAPI);
|
||||
|
||||
Reference in New Issue
Block a user