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,187 @@
|
||||
// src/ui/schedule-menu.ts had 0% coverage — every one of its 50 statements.
|
||||
// That is not a cosmetic gap: the menu's only action is DESTRUCTIVE (cancel a
|
||||
// scheduled job), and it identifies the chosen job by matching the selected
|
||||
// label string back against the label list.
|
||||
//
|
||||
// Labels are built by `formatJob`, which truncates the name to 18 characters
|
||||
// (`j.name.padEnd(18).slice(0, 18)`). Job names come from the `Agent` call's
|
||||
// `description`, which is LLM-authored and routinely shares a prefix. Two jobs
|
||||
// whose names agree in the first 18 chars, with the same schedule, type, run
|
||||
// count and run times, produce byte-identical labels — and `labels.indexOf`
|
||||
// then resolves to the FIRST of them regardless of which the user picked.
|
||||
//
|
||||
// CHANGELOG 0.10.0 states the menu "lets you cancel any one of them", so this
|
||||
// contradicts documented intent rather than merely being unspecified.
|
||||
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { SubagentScheduler } from "../src/schedule.js";
|
||||
import { ScheduleStore } from "../src/schedule-store.js";
|
||||
import { showSchedulesMenu } from "../src/ui/schedule-menu.js";
|
||||
|
||||
/** ctx.ui stub: `select` returns whichever label index we tell it to. */
|
||||
function makeCtx(opts: { pick?: (labels: string[]) => string | undefined; confirm?: boolean } = {}) {
|
||||
const notify = vi.fn();
|
||||
const select = vi.fn(async (_title: string, labels: string[]) => opts.pick?.(labels));
|
||||
const confirm = vi.fn(async () => opts.confirm ?? true);
|
||||
return { ctx: { ui: { select, confirm, notify } } as any, select, confirm, notify };
|
||||
}
|
||||
|
||||
describe("showSchedulesMenu", () => {
|
||||
let dir: string;
|
||||
let scheduler: SubagentScheduler;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), "pi-schedmenu-"));
|
||||
scheduler = new SubagentScheduler();
|
||||
scheduler.start(
|
||||
{ events: { emit: vi.fn() } } as any,
|
||||
{ cwd: dir } as any,
|
||||
{ spawn: vi.fn(), getRecord: vi.fn() } as any,
|
||||
new ScheduleStore(join(dir, "jobs.json")),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
scheduler.stop();
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const addJob = (name: string) =>
|
||||
scheduler.addJob({
|
||||
name,
|
||||
description: name,
|
||||
schedule: "0 0 9 * * 1",
|
||||
subagent_type: "general-purpose",
|
||||
prompt: "go",
|
||||
});
|
||||
|
||||
it("warns and does nothing when the scheduler is not active", async () => {
|
||||
const idle = new SubagentScheduler();
|
||||
const { ctx, select, notify } = makeCtx();
|
||||
await showSchedulesMenu(ctx, idle);
|
||||
expect(select).not.toHaveBeenCalled();
|
||||
expect(notify).toHaveBeenCalledWith(expect.stringContaining("not active"), "warning");
|
||||
});
|
||||
|
||||
it("reports an empty list without opening a picker", async () => {
|
||||
const { ctx, select, notify } = makeCtx();
|
||||
await showSchedulesMenu(ctx, scheduler);
|
||||
expect(select).not.toHaveBeenCalled();
|
||||
expect(notify).toHaveBeenCalledWith("No scheduled jobs.", "info");
|
||||
});
|
||||
|
||||
it("cancels the job the user selected", async () => {
|
||||
const a = addJob("alpha job");
|
||||
const b = addJob("beta job");
|
||||
const { ctx } = makeCtx({ pick: (labels) => labels[1] });
|
||||
|
||||
await showSchedulesMenu(ctx, scheduler);
|
||||
|
||||
expect(scheduler.list().map(j => j.id)).toEqual([a.id]);
|
||||
expect(scheduler.list().map(j => j.id)).not.toContain(b.id);
|
||||
});
|
||||
|
||||
// Names are truncated to 18 chars for column alignment, and they come from the
|
||||
// LLM-authored `Agent` description, so two rows formatting identically is
|
||||
// ordinary. Resolving the pick by string match then cancelled whichever came
|
||||
// first — silently, with the confirm dialog showing the wrong job's details.
|
||||
describe("rows that format identically after truncation", () => {
|
||||
const NAME_A = "review the auth module A";
|
||||
const NAME_B = "review the auth module B";
|
||||
|
||||
it("gives every row a distinct label", () => {
|
||||
// The invariant the fix rests on. Everything below depends on it.
|
||||
addJob(NAME_A);
|
||||
addJob(NAME_B);
|
||||
const { ctx, select } = makeCtx({ pick: () => undefined });
|
||||
return showSchedulesMenu(ctx, scheduler).then(() => {
|
||||
const labels = select.mock.calls[0][1] as string[];
|
||||
expect(new Set(labels).size).toBe(labels.length);
|
||||
});
|
||||
});
|
||||
|
||||
it("cancels the second job when the second row is picked", async () => {
|
||||
const first = addJob(NAME_A);
|
||||
const second = addJob(NAME_B);
|
||||
const { ctx } = makeCtx({ pick: (labels) => labels[1] });
|
||||
|
||||
await showSchedulesMenu(ctx, scheduler);
|
||||
|
||||
expect(scheduler.list().map(j => j.id)).toEqual([first.id]);
|
||||
expect(scheduler.list().map(j => j.id)).not.toContain(second.id);
|
||||
});
|
||||
|
||||
it("cancels the first job when the first row is picked", async () => {
|
||||
// The other direction — an off-by-one or a last-match resolver would pass
|
||||
// the test above and fail this one.
|
||||
const first = addJob(NAME_A);
|
||||
const second = addJob(NAME_B);
|
||||
const { ctx } = makeCtx({ pick: (labels) => labels[0] });
|
||||
|
||||
await showSchedulesMenu(ctx, scheduler);
|
||||
|
||||
expect(scheduler.list().map(j => j.id)).toEqual([second.id]);
|
||||
expect(scheduler.list().map(j => j.id)).not.toContain(first.id);
|
||||
});
|
||||
|
||||
it("shows the SELECTED job's untruncated details in the confirm dialog", async () => {
|
||||
// Guards the half of the bug the user could never catch: deleting the
|
||||
// right job while confirming against the wrong one, or vice versa.
|
||||
addJob(NAME_A);
|
||||
addJob(NAME_B);
|
||||
const { ctx, confirm } = makeCtx({ pick: (labels) => labels[1] });
|
||||
|
||||
await showSchedulesMenu(ctx, scheduler);
|
||||
|
||||
expect(confirm.mock.calls[0][0]).toContain(NAME_B);
|
||||
expect(confirm.mock.calls[0][0]).not.toContain(NAME_A);
|
||||
});
|
||||
|
||||
it("cancels the right job past the single-digit boundary", async () => {
|
||||
// 11 rows: insurance against any refactor that parses the number back out
|
||||
// of the label and confuses "1" with "11".
|
||||
const jobs = Array.from({ length: 11 }, (_, i) => addJob(`review the auth module ${i}`));
|
||||
const { ctx } = makeCtx({ pick: (labels) => labels[10] });
|
||||
|
||||
await showSchedulesMenu(ctx, scheduler);
|
||||
|
||||
const surviving = scheduler.list().map(j => j.id);
|
||||
expect(surviving).toHaveLength(10);
|
||||
expect(surviving).not.toContain(jobs[10].id);
|
||||
});
|
||||
});
|
||||
|
||||
it("cancels nothing when the picker returns a label we never offered", async () => {
|
||||
addJob("alpha job");
|
||||
const { ctx, confirm } = makeCtx({ pick: () => "something else entirely" });
|
||||
|
||||
await showSchedulesMenu(ctx, scheduler);
|
||||
|
||||
expect(confirm).not.toHaveBeenCalled();
|
||||
expect(scheduler.list()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("leaves every job intact when the user escapes the picker", async () => {
|
||||
addJob("alpha job");
|
||||
addJob("beta job");
|
||||
const { ctx, confirm } = makeCtx({ pick: () => undefined });
|
||||
|
||||
await showSchedulesMenu(ctx, scheduler);
|
||||
|
||||
expect(confirm).not.toHaveBeenCalled();
|
||||
expect(scheduler.list()).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("leaves the job intact when the user declines the confirm", async () => {
|
||||
addJob("alpha job");
|
||||
const { ctx, notify } = makeCtx({ pick: (labels) => labels[0], confirm: false });
|
||||
|
||||
await showSchedulesMenu(ctx, scheduler);
|
||||
|
||||
expect(scheduler.list()).toHaveLength(1);
|
||||
expect(notify).not.toHaveBeenCalledWith(expect.stringContaining("Cancelled"), "info");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user