mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-08-28 16:45:22 +00:00
feat: vendor permission system source
This commit is contained in:
@@ -0,0 +1,384 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
initialPromptState,
|
||||
type PromptModelConfig,
|
||||
reducePrompt,
|
||||
} from "#src/authority/permission-prompt-decision";
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function makeConfig(
|
||||
overrides: Partial<PromptModelConfig> = {},
|
||||
): PromptModelConfig {
|
||||
return {
|
||||
doublePressToConfirm: true,
|
||||
sessionLabel: "Yes, for this session",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("reducePrompt", () => {
|
||||
describe("initial state", () => {
|
||||
it("starts on the decision step highlighting approve with nothing armed", () => {
|
||||
const state = initialPromptState(makeConfig());
|
||||
expect(state).toEqual({
|
||||
step: "decision",
|
||||
highlightedKey: "y",
|
||||
armedKey: undefined,
|
||||
hint: "",
|
||||
reasonError: undefined,
|
||||
scopeServing: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("double-press to confirm (enabled)", () => {
|
||||
it("arms the option on the first hotkey press without deciding", () => {
|
||||
const config = makeConfig();
|
||||
const outcome = reducePrompt(config, initialPromptState(config), {
|
||||
type: "hotkey",
|
||||
key: "y",
|
||||
});
|
||||
expect(outcome).toEqual({
|
||||
kind: "render",
|
||||
state: {
|
||||
step: "decision",
|
||||
highlightedKey: "y",
|
||||
armedKey: "y",
|
||||
hint: "Press y again to approve.",
|
||||
reasonError: undefined,
|
||||
scopeServing: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("commits the decision on the confirming second press of the same key", () => {
|
||||
const config = makeConfig();
|
||||
const armed = reducePrompt(config, initialPromptState(config), {
|
||||
type: "hotkey",
|
||||
key: "y",
|
||||
});
|
||||
if (armed.kind !== "render") throw new Error("expected render");
|
||||
const outcome = reducePrompt(config, armed.state, {
|
||||
type: "hotkey",
|
||||
key: "y",
|
||||
});
|
||||
expect(outcome).toEqual({
|
||||
kind: "decision",
|
||||
decision: { approved: true, state: "approved" },
|
||||
});
|
||||
});
|
||||
|
||||
it("re-arms when a different hotkey is pressed", () => {
|
||||
const config = makeConfig();
|
||||
const armedY = reducePrompt(config, initialPromptState(config), {
|
||||
type: "hotkey",
|
||||
key: "y",
|
||||
});
|
||||
if (armedY.kind !== "render") throw new Error("expected render");
|
||||
const armedN = reducePrompt(config, armedY.state, {
|
||||
type: "hotkey",
|
||||
key: "n",
|
||||
});
|
||||
expect(armedN).toEqual({
|
||||
kind: "render",
|
||||
state: {
|
||||
step: "decision",
|
||||
highlightedKey: "n",
|
||||
armedKey: "n",
|
||||
hint: "Press n again to deny.",
|
||||
reasonError: undefined,
|
||||
scopeServing: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("commits deny on the second press of n", () => {
|
||||
const config = makeConfig();
|
||||
const armed = reducePrompt(config, initialPromptState(config), {
|
||||
type: "hotkey",
|
||||
key: "n",
|
||||
});
|
||||
if (armed.kind !== "render") throw new Error("expected render");
|
||||
const outcome = reducePrompt(config, armed.state, {
|
||||
type: "hotkey",
|
||||
key: "n",
|
||||
});
|
||||
expect(outcome).toEqual({
|
||||
kind: "decision",
|
||||
decision: { approved: false, state: "denied" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("double-press to confirm (disabled)", () => {
|
||||
it("commits immediately on the first hotkey press", () => {
|
||||
const config = makeConfig({ doublePressToConfirm: false });
|
||||
const outcome = reducePrompt(config, initialPromptState(config), {
|
||||
type: "hotkey",
|
||||
key: "y",
|
||||
});
|
||||
expect(outcome).toEqual({
|
||||
kind: "decision",
|
||||
decision: { approved: true, state: "approved" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("navigation and enter", () => {
|
||||
it("moves the highlight and clears any armed key without deciding", () => {
|
||||
const config = makeConfig();
|
||||
const armed = reducePrompt(config, initialPromptState(config), {
|
||||
type: "hotkey",
|
||||
key: "y",
|
||||
});
|
||||
if (armed.kind !== "render") throw new Error("expected render");
|
||||
const outcome = reducePrompt(config, armed.state, {
|
||||
type: "nav",
|
||||
direction: "down",
|
||||
});
|
||||
expect(outcome).toEqual({
|
||||
kind: "render",
|
||||
state: {
|
||||
step: "decision",
|
||||
highlightedKey: "s",
|
||||
armedKey: undefined,
|
||||
hint: "",
|
||||
reasonError: undefined,
|
||||
scopeServing: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("wraps the highlight from the last option back to the first", () => {
|
||||
const config = makeConfig();
|
||||
let state = initialPromptState(config);
|
||||
for (const _ of [0, 1, 2, 3]) {
|
||||
const outcome = reducePrompt(config, state, {
|
||||
type: "nav",
|
||||
direction: "up",
|
||||
});
|
||||
if (outcome.kind !== "render") throw new Error("expected render");
|
||||
state = outcome.state;
|
||||
}
|
||||
// up from y wraps to r, then walks r→n→s→y over four presses
|
||||
expect(state.highlightedKey).toBe("y");
|
||||
});
|
||||
|
||||
it("confirms the highlighted option in a single enter press even when double-press is enabled", () => {
|
||||
const config = makeConfig();
|
||||
const down = reducePrompt(config, initialPromptState(config), {
|
||||
type: "nav",
|
||||
direction: "down",
|
||||
});
|
||||
if (down.kind !== "render") throw new Error("expected render");
|
||||
// highlight is now s; move once more to n
|
||||
const down2 = reducePrompt(config, down.state, {
|
||||
type: "nav",
|
||||
direction: "down",
|
||||
});
|
||||
if (down2.kind !== "render") throw new Error("expected render");
|
||||
const outcome = reducePrompt(config, down2.state, { type: "confirm" });
|
||||
expect(outcome).toEqual({
|
||||
kind: "decision",
|
||||
decision: { approved: false, state: "denied" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("escape", () => {
|
||||
it("denies from the decision step", () => {
|
||||
const config = makeConfig();
|
||||
const outcome = reducePrompt(config, initialPromptState(config), {
|
||||
type: "cancel",
|
||||
});
|
||||
expect(outcome).toEqual({
|
||||
kind: "decision",
|
||||
decision: { approved: false, state: "denied" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("deny with reason", () => {
|
||||
it("opens the reason step on confirming r (double-press enabled)", () => {
|
||||
const config = makeConfig();
|
||||
const armed = reducePrompt(config, initialPromptState(config), {
|
||||
type: "hotkey",
|
||||
key: "r",
|
||||
});
|
||||
if (armed.kind !== "render") throw new Error("expected render");
|
||||
const outcome = reducePrompt(config, armed.state, {
|
||||
type: "hotkey",
|
||||
key: "r",
|
||||
});
|
||||
expect(outcome).toEqual({
|
||||
kind: "render",
|
||||
state: {
|
||||
step: "reason",
|
||||
highlightedKey: "r",
|
||||
armedKey: undefined,
|
||||
hint: "",
|
||||
reasonError: undefined,
|
||||
scopeServing: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("opens the reason step immediately when double-press is disabled", () => {
|
||||
const config = makeConfig({ doublePressToConfirm: false });
|
||||
const outcome = reducePrompt(config, initialPromptState(config), {
|
||||
type: "hotkey",
|
||||
key: "r",
|
||||
});
|
||||
expect(outcome.kind).toBe("render");
|
||||
if (outcome.kind !== "render") throw new Error("expected render");
|
||||
expect(outcome.state.step).toBe("reason");
|
||||
});
|
||||
|
||||
it("rejects an empty reason and keeps the reason step open", () => {
|
||||
const config = makeConfig({ doublePressToConfirm: false });
|
||||
const opened = reducePrompt(config, initialPromptState(config), {
|
||||
type: "hotkey",
|
||||
key: "r",
|
||||
});
|
||||
if (opened.kind !== "render") throw new Error("expected render");
|
||||
const outcome = reducePrompt(config, opened.state, {
|
||||
type: "submitReason",
|
||||
draft: " ",
|
||||
});
|
||||
expect(outcome).toEqual({
|
||||
kind: "render",
|
||||
state: {
|
||||
step: "reason",
|
||||
highlightedKey: "r",
|
||||
armedKey: undefined,
|
||||
hint: "",
|
||||
reasonError: "A reason is required.",
|
||||
scopeServing: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("commits a denied_with_reason decision for a non-empty reason", () => {
|
||||
const config = makeConfig({ doublePressToConfirm: false });
|
||||
const opened = reducePrompt(config, initialPromptState(config), {
|
||||
type: "hotkey",
|
||||
key: "r",
|
||||
});
|
||||
if (opened.kind !== "render") throw new Error("expected render");
|
||||
const outcome = reducePrompt(config, opened.state, {
|
||||
type: "submitReason",
|
||||
draft: " not now ",
|
||||
});
|
||||
expect(outcome).toEqual({
|
||||
kind: "decision",
|
||||
decision: {
|
||||
approved: false,
|
||||
state: "denied_with_reason",
|
||||
denialReason: "not now",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("navigates back to the decision step on escape from the reason step", () => {
|
||||
const config = makeConfig({ doublePressToConfirm: false });
|
||||
const opened = reducePrompt(config, initialPromptState(config), {
|
||||
type: "hotkey",
|
||||
key: "r",
|
||||
});
|
||||
if (opened.kind !== "render") throw new Error("expected render");
|
||||
const outcome = reducePrompt(config, opened.state, { type: "cancel" });
|
||||
expect(outcome).toEqual({
|
||||
kind: "render",
|
||||
state: {
|
||||
step: "decision",
|
||||
highlightedKey: "r",
|
||||
armedKey: undefined,
|
||||
hint: "",
|
||||
reasonError: undefined,
|
||||
scopeServing: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("approve-for-session scope (forwarded asks)", () => {
|
||||
const sessionScope = {
|
||||
subagentLabel: "This subagent only",
|
||||
servingSessionLabel: "The whole session",
|
||||
};
|
||||
|
||||
it("opens the scope step when s is confirmed and a sessionScope is offered", () => {
|
||||
const config = makeConfig({ doublePressToConfirm: false, sessionScope });
|
||||
const outcome = reducePrompt(config, initialPromptState(config), {
|
||||
type: "hotkey",
|
||||
key: "s",
|
||||
});
|
||||
expect(outcome.kind).toBe("render");
|
||||
if (outcome.kind !== "render") throw new Error("expected render");
|
||||
expect(outcome.state.step).toBe("scope");
|
||||
expect(outcome.state.scopeServing).toBe(false);
|
||||
});
|
||||
|
||||
it("commits the least-privilege subagent scope by default", () => {
|
||||
const config = makeConfig({ doublePressToConfirm: false, sessionScope });
|
||||
const opened = reducePrompt(config, initialPromptState(config), {
|
||||
type: "hotkey",
|
||||
key: "s",
|
||||
});
|
||||
if (opened.kind !== "render") throw new Error("expected render");
|
||||
const outcome = reducePrompt(config, opened.state, { type: "confirm" });
|
||||
expect(outcome).toEqual({
|
||||
kind: "decision",
|
||||
decision: { approved: true, state: "approved_for_session" },
|
||||
});
|
||||
});
|
||||
|
||||
it("commits the serving-session scope when the second option is chosen", () => {
|
||||
const config = makeConfig({ doublePressToConfirm: false, sessionScope });
|
||||
const opened = reducePrompt(config, initialPromptState(config), {
|
||||
type: "hotkey",
|
||||
key: "s",
|
||||
});
|
||||
if (opened.kind !== "render") throw new Error("expected render");
|
||||
const moved = reducePrompt(config, opened.state, {
|
||||
type: "nav",
|
||||
direction: "down",
|
||||
});
|
||||
if (moved.kind !== "render") throw new Error("expected render");
|
||||
expect(moved.state.scopeServing).toBe(true);
|
||||
const outcome = reducePrompt(config, moved.state, { type: "confirm" });
|
||||
expect(outcome).toEqual({
|
||||
kind: "decision",
|
||||
decision: { approved: true, state: "approved_for_serving_session" },
|
||||
});
|
||||
});
|
||||
|
||||
it("navigates back to the decision step on escape from the scope step", () => {
|
||||
const config = makeConfig({ doublePressToConfirm: false, sessionScope });
|
||||
const opened = reducePrompt(config, initialPromptState(config), {
|
||||
type: "hotkey",
|
||||
key: "s",
|
||||
});
|
||||
if (opened.kind !== "render") throw new Error("expected render");
|
||||
const outcome = reducePrompt(config, opened.state, { type: "cancel" });
|
||||
expect(outcome.kind).toBe("render");
|
||||
if (outcome.kind !== "render") throw new Error("expected render");
|
||||
expect(outcome.state.step).toBe("decision");
|
||||
});
|
||||
|
||||
it("commits approved_for_session directly when no sessionScope is offered", () => {
|
||||
const config = makeConfig({ doublePressToConfirm: false });
|
||||
const outcome = reducePrompt(config, initialPromptState(config), {
|
||||
type: "hotkey",
|
||||
key: "s",
|
||||
});
|
||||
expect(outcome).toEqual({
|
||||
kind: "decision",
|
||||
decision: { approved: true, state: "approved_for_session" },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user