mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-08-28 08:35:57 +00:00
385 lines
13 KiB
TypeScript
385 lines
13 KiB
TypeScript
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" },
|
|
});
|
|
});
|
|
});
|
|
});
|