mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-08-28 08:35:57 +00:00
feat: enable permission-aware subagents
This commit is contained in:
@@ -0,0 +1,147 @@
|
||||
// src/model-scope.ts had no test file. Its `error` verdict was reachable only
|
||||
// indirectly, through one nested-tools case; the `warn` verdict — the whole
|
||||
// reason the policy is a three-way split rather than a boolean — was never
|
||||
// exercised at all.
|
||||
//
|
||||
// The split is the point: a model the ORCHESTRATOR picked at runtime is refused,
|
||||
// because it can pick again; a model the USER pinned in frontmatter (or that was
|
||||
// inherited from the parent) only warns, because refusing it would break every
|
||||
// pinned agent the moment someone enables the setting. Collapsing the two in
|
||||
// either direction is a one-line edit with no test in the way.
|
||||
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import type { ModelRegistryRef } from "../src/enabled-models.js";
|
||||
import { checkModelScope, isScopeModelsEnabled, setScopeModelsEnabled } from "../src/model-scope.js";
|
||||
|
||||
const MODELS = [
|
||||
{ id: "claude-opus-4-6", name: "Claude Opus 4.6", provider: "anthropic" },
|
||||
{ id: "claude-haiku-4-5", name: "Claude Haiku 4.5", provider: "anthropic" },
|
||||
{ id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6", provider: "anthropic" },
|
||||
];
|
||||
|
||||
function makeRegistry(models = MODELS): ModelRegistryRef {
|
||||
return { getAll() { return models; }, getAvailable: undefined };
|
||||
}
|
||||
|
||||
const HAIKU = { provider: "anthropic", id: "claude-haiku-4-5" };
|
||||
const OPUS = { provider: "anthropic", id: "claude-opus-4-6" };
|
||||
|
||||
describe("checkModelScope", () => {
|
||||
let projectDir: string;
|
||||
let agentDir: string;
|
||||
let prevAgentDir: string | undefined;
|
||||
let prevEnabled: boolean;
|
||||
|
||||
beforeEach(() => {
|
||||
// resolveEnabledModels memoizes on (patterns, mtime+size of both settings
|
||||
// files). A fresh project dir per test keeps one case's allowlist from
|
||||
// being served to the next.
|
||||
projectDir = mkdtempSync(join(tmpdir(), "pi-scope-project-"));
|
||||
agentDir = mkdtempSync(join(tmpdir(), "pi-scope-global-"));
|
||||
prevAgentDir = process.env.PI_CODING_AGENT_DIR;
|
||||
process.env.PI_CODING_AGENT_DIR = agentDir;
|
||||
prevEnabled = isScopeModelsEnabled();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
setScopeModelsEnabled(prevEnabled); // module-global — restore for other suites
|
||||
if (prevAgentDir == null) delete process.env.PI_CODING_AGENT_DIR;
|
||||
else process.env.PI_CODING_AGENT_DIR = prevAgentDir;
|
||||
rmSync(projectDir, { recursive: true, force: true });
|
||||
rmSync(agentDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function setEnabledModels(models: string[]) {
|
||||
mkdirSync(join(projectDir, ".pi"), { recursive: true });
|
||||
writeFileSync(join(projectDir, ".pi", "settings.json"), JSON.stringify({ enabledModels: models }));
|
||||
}
|
||||
|
||||
function check(overrides: Partial<Parameters<typeof checkModelScope>[0]> = {}) {
|
||||
return checkModelScope({
|
||||
model: OPUS,
|
||||
cwd: projectDir,
|
||||
modelRegistry: makeRegistry(),
|
||||
callerSupplied: true,
|
||||
agentLabel: "scout",
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
it("is a no-op while the feature is off, even for an out-of-scope model", () => {
|
||||
setScopeModelsEnabled(false);
|
||||
setEnabledModels(["anthropic/claude-haiku-4-5"]);
|
||||
expect(check().kind).toBe("ok");
|
||||
});
|
||||
|
||||
it("is a no-op when no model was resolved", () => {
|
||||
setScopeModelsEnabled(true);
|
||||
setEnabledModels(["anthropic/claude-haiku-4-5"]);
|
||||
expect(check({ model: undefined }).kind).toBe("ok");
|
||||
});
|
||||
|
||||
it("is a no-op when the user has no enabledModels configured", () => {
|
||||
setScopeModelsEnabled(true);
|
||||
expect(check().kind).toBe("ok");
|
||||
});
|
||||
|
||||
it("is a no-op when enabledModels resolves to nothing usable", () => {
|
||||
// Globs and bare ids are deliberately unsupported; an unresolvable list
|
||||
// must disable the check rather than lock the user out of every model.
|
||||
setScopeModelsEnabled(true);
|
||||
setEnabledModels(["anthropic/*", "haiku"]);
|
||||
expect(check().kind).toBe("ok");
|
||||
});
|
||||
|
||||
it("passes an in-scope model", () => {
|
||||
setScopeModelsEnabled(true);
|
||||
setEnabledModels(["anthropic/claude-opus-4-6"]);
|
||||
expect(check().kind).toBe("ok");
|
||||
});
|
||||
|
||||
describe("out of scope", () => {
|
||||
beforeEach(() => {
|
||||
setScopeModelsEnabled(true);
|
||||
setEnabledModels(["anthropic/claude-haiku-4-5", "anthropic/claude-sonnet-4-6"]);
|
||||
});
|
||||
|
||||
it("refuses a caller-supplied choice and lists what is allowed", () => {
|
||||
const verdict = check({ callerSupplied: true, modelInput: "anthropic/claude-opus-4-6" });
|
||||
expect(verdict.kind).toBe("error");
|
||||
const message = (verdict as { message: string }).message;
|
||||
expect(message).toContain('"anthropic/claude-opus-4-6"');
|
||||
expect(message).toContain(" anthropic/claude-haiku-4-5");
|
||||
expect(message).toContain(" anthropic/claude-sonnet-4-6");
|
||||
});
|
||||
|
||||
it("only warns for a frontmatter-pinned choice, so the spawn still proceeds", () => {
|
||||
const verdict = check({ callerSupplied: false, modelInput: "anthropic/claude-opus-4-6" });
|
||||
expect(verdict.kind).toBe("warn");
|
||||
expect((verdict as { message: string }).message)
|
||||
.toBe('Agent "scout" using out-of-scope model "anthropic/claude-opus-4-6"');
|
||||
});
|
||||
|
||||
it("names the resolved model in the warning when there was no raw input", () => {
|
||||
// Parent-inherited: nothing was typed, so the label falls back to the
|
||||
// resolved provider/id rather than rendering "undefined".
|
||||
const verdict = check({ callerSupplied: false, modelInput: undefined });
|
||||
expect(verdict.kind).toBe("warn");
|
||||
expect((verdict as { message: string }).message).toContain("anthropic/claude-opus-4-6");
|
||||
expect((verdict as { message: string }).message).not.toContain("undefined");
|
||||
});
|
||||
|
||||
it("stops enforcing as soon as the setting is turned back off", () => {
|
||||
expect(check().kind).toBe("error");
|
||||
setScopeModelsEnabled(false);
|
||||
expect(check().kind).toBe("ok");
|
||||
});
|
||||
|
||||
it("treats scope as case-insensitive on both sides", () => {
|
||||
setEnabledModels(["Anthropic/Claude-Opus-4-6"]);
|
||||
expect(check({ model: OPUS }).kind).toBe("ok");
|
||||
expect(check({ model: HAIKU }).kind).toBe("error");
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user