mirror of
https://github.com/SikongJueluo/pi-extensions.git
synced 2026-10-05 11:52:55 +08:00
refactor(permission): consume structured bash payload
- require @gotgenes/pi-permission-system >=25.3.0 and read the complete local bash command from PromptPermissionDetails.payload instead of session-walking recovery - remove the @sikongjueluo/pi-permission-shared package - pass the triggering command unit to handlers via HandlerContext.unit in place of details.command - add shadow-only AI judge modules for evidence projection, structured verdict requests, and prompt building, with vitest coverage - record ADR 0004 and mark the ADR 0001 recovery mechanism superseded - exclude pi-permission-system 25.3.0 from the pnpm minimumReleaseAge guard
This commit is contained in:
@@ -0,0 +1,259 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { AssistantMessage, Context } from "@earendil-works/pi-ai";
|
||||
import {
|
||||
requestStructuredVerdict,
|
||||
type ModelAvailability,
|
||||
} from "../src/model";
|
||||
|
||||
const metadata = {
|
||||
provider: "test-provider",
|
||||
model: "test-model",
|
||||
api: "openai-codex-responses",
|
||||
};
|
||||
|
||||
function response(
|
||||
content: AssistantMessage["content"],
|
||||
output = 12,
|
||||
): AssistantMessage {
|
||||
return {
|
||||
role: "assistant",
|
||||
content,
|
||||
api: metadata.api,
|
||||
provider: metadata.provider,
|
||||
model: metadata.model,
|
||||
usage: {
|
||||
input: 10,
|
||||
output,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 10 + output,
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
total: 0,
|
||||
},
|
||||
},
|
||||
stopReason: "toolUse",
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
function ready(
|
||||
complete: (
|
||||
context: Context,
|
||||
signal: AbortSignal,
|
||||
) => Promise<AssistantMessage>,
|
||||
): ModelAvailability {
|
||||
return { kind: "ready", metadata, complete };
|
||||
}
|
||||
|
||||
const evidence = {
|
||||
fullCommand: "pnpm test && git push",
|
||||
triggeringUnit: "git push",
|
||||
};
|
||||
|
||||
describe("requestStructuredVerdict", () => {
|
||||
it("makes one call and parses exactly one report_verdict tool call", async () => {
|
||||
const complete = vi.fn(
|
||||
async (_context: Context, _signal: AbortSignal) =>
|
||||
response([
|
||||
{
|
||||
type: "toolCall",
|
||||
id: "call-1",
|
||||
name: "report_verdict",
|
||||
arguments: {
|
||||
verdict: "defer",
|
||||
reason: "User intent is unavailable.",
|
||||
},
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await requestStructuredVerdict(
|
||||
ready(complete),
|
||||
evidence,
|
||||
new AbortController().signal,
|
||||
);
|
||||
|
||||
expect(complete).toHaveBeenCalledTimes(1);
|
||||
expect(complete.mock.calls[0]?.[0].tools).toHaveLength(1);
|
||||
expect(result).toEqual({
|
||||
kind: "judgment",
|
||||
verdict: "defer",
|
||||
reason: "User intent is unavailable.",
|
||||
metadata,
|
||||
outputTokens: 12,
|
||||
});
|
||||
});
|
||||
|
||||
it("never parses prose or duplicate tool calls", async () => {
|
||||
const prose = await requestStructuredVerdict(
|
||||
ready(async () =>
|
||||
response([{ type: "text", text: '{"verdict":"allow"}' }]),
|
||||
),
|
||||
evidence,
|
||||
new AbortController().signal,
|
||||
);
|
||||
expect(prose).toMatchObject({
|
||||
kind: "infrastructure_failure",
|
||||
code: "missing_tool_call",
|
||||
});
|
||||
|
||||
const call = {
|
||||
type: "toolCall" as const,
|
||||
id: "call-1",
|
||||
name: "report_verdict",
|
||||
arguments: { verdict: "allow", reason: "bounded" },
|
||||
};
|
||||
const duplicate = await requestStructuredVerdict(
|
||||
ready(async () => response([call, { ...call, id: "call-2" }])),
|
||||
evidence,
|
||||
new AbortController().signal,
|
||||
);
|
||||
expect(duplicate).toMatchObject({
|
||||
kind: "infrastructure_failure",
|
||||
code: "missing_tool_call",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects invalid arguments, verdicts, reasons, and output usage", async () => {
|
||||
const extraArguments = await requestStructuredVerdict(
|
||||
ready(async () =>
|
||||
response([
|
||||
{
|
||||
type: "toolCall",
|
||||
id: "call-1",
|
||||
name: "report_verdict",
|
||||
arguments: {
|
||||
verdict: "allow",
|
||||
reason: "bounded",
|
||||
extra: true,
|
||||
},
|
||||
},
|
||||
]),
|
||||
),
|
||||
evidence,
|
||||
new AbortController().signal,
|
||||
);
|
||||
expect(extraArguments).toMatchObject({ code: "invalid_arguments" });
|
||||
|
||||
const invalidVerdict = await requestStructuredVerdict(
|
||||
ready(async () =>
|
||||
response([
|
||||
{
|
||||
type: "toolCall",
|
||||
id: "call-1",
|
||||
name: "report_verdict",
|
||||
arguments: { verdict: "approve", reason: "no" },
|
||||
},
|
||||
]),
|
||||
),
|
||||
evidence,
|
||||
new AbortController().signal,
|
||||
);
|
||||
expect(invalidVerdict).toMatchObject({ code: "invalid_verdict" });
|
||||
|
||||
const invalidReason = await requestStructuredVerdict(
|
||||
ready(async () =>
|
||||
response([
|
||||
{
|
||||
type: "toolCall",
|
||||
id: "call-1",
|
||||
name: "report_verdict",
|
||||
arguments: { verdict: "defer", reason: " ".repeat(241) },
|
||||
},
|
||||
]),
|
||||
),
|
||||
evidence,
|
||||
new AbortController().signal,
|
||||
);
|
||||
expect(invalidReason).toMatchObject({ code: "invalid_reason" });
|
||||
|
||||
const excessiveUsage = await requestStructuredVerdict(
|
||||
ready(async () =>
|
||||
response(
|
||||
[
|
||||
{
|
||||
type: "toolCall",
|
||||
id: "call-1",
|
||||
name: "report_verdict",
|
||||
arguments: { verdict: "allow", reason: "bounded" },
|
||||
},
|
||||
],
|
||||
257,
|
||||
),
|
||||
),
|
||||
evidence,
|
||||
new AbortController().signal,
|
||||
);
|
||||
expect(excessiveUsage).toMatchObject({ code: "model_error" });
|
||||
});
|
||||
|
||||
it("maps the bounded deadline to timeout", async () => {
|
||||
const waiting = ready(
|
||||
async (_context, signal) =>
|
||||
new Promise<AssistantMessage>((_resolve, reject) => {
|
||||
signal.addEventListener(
|
||||
"abort",
|
||||
() => reject(new Error("aborted")),
|
||||
{ once: true },
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await requestStructuredVerdict(
|
||||
waiting,
|
||||
evidence,
|
||||
new AbortController().signal,
|
||||
5,
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
kind: "infrastructure_failure",
|
||||
code: "timeout",
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes no-model, unsupported API, and shutdown abort", async () => {
|
||||
await expect(
|
||||
requestStructuredVerdict(
|
||||
{ kind: "no_model" },
|
||||
evidence,
|
||||
new AbortController().signal,
|
||||
),
|
||||
).resolves.toEqual({
|
||||
kind: "infrastructure_failure",
|
||||
code: "no_model",
|
||||
metadata: undefined,
|
||||
modelCalled: false,
|
||||
});
|
||||
|
||||
await expect(
|
||||
requestStructuredVerdict(
|
||||
{ kind: "unsupported_api", metadata },
|
||||
evidence,
|
||||
new AbortController().signal,
|
||||
),
|
||||
).resolves.toEqual({
|
||||
kind: "infrastructure_failure",
|
||||
code: "unsupported_api",
|
||||
metadata,
|
||||
modelCalled: false,
|
||||
});
|
||||
|
||||
const shutdown = new AbortController();
|
||||
shutdown.abort();
|
||||
const complete = vi.fn(async () => response([]));
|
||||
const aborted = await requestStructuredVerdict(
|
||||
ready(complete),
|
||||
evidence,
|
||||
shutdown.signal,
|
||||
);
|
||||
expect(aborted).toMatchObject({
|
||||
code: "aborted",
|
||||
modelCalled: false,
|
||||
});
|
||||
expect(complete).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user