diff --git a/packages/pi-permission-ai-judge/README.md b/packages/pi-permission-ai-judge/README.md index 8b8ebbd..ddda987 100644 --- a/packages/pi-permission-ai-judge/README.md +++ b/packages/pi-permission-ai-judge/README.md @@ -9,6 +9,17 @@ pi 的 Bash 权限 AI 判官:每条待确认的 Bash 命令先交给模型, enforce 是自担风险的便利模式:模型误判,危险命令可能在无人确认时执行。退出方式:mode 改回 shadow。 +## 弹窗意见挂件 + +每次弹窗出现前,判官会在编辑器上方挂一个意见面板,说明自己的判断和理由,长命令会标注重点片段(focus 行): + +``` +ai-judge defer — command rewrites published history; intent not established +focus: git push --force origin main (high-risk: history_rewrite) +``` + +三态:`defer/deny/allow` 判决带理由(shadow 模式标 `(shadow)`);高风险跳过标类别;模型不可用/超时/异常标 `unavailable`。弹窗被处理后挂件自动消失。配置 `dialogAdvice: false` 关闭。 + ## 安装 ```bash @@ -47,6 +58,7 @@ pi install github.com/SikongJueluo/pi-extensions | `mode` | `shadow`(默认)或 `enforce`,非法值回退 shadow | | `model` | 可选,固定判官模型;不写则跟随会话模型。解析失败按故障处理并弹窗,绝不静默改用会话模型 | | `timeoutMs` | 单次判决等待上限,5000–30000,默认 15000 | +| `dialogAdvice` | `true`(默认)或 `false`;弹窗期间的判官意见挂件开关 | ## enforce 的防线 diff --git a/packages/pi-permission-ai-judge/src/advice/widget.ts b/packages/pi-permission-ai-judge/src/advice/widget.ts new file mode 100644 index 0000000..79b923e --- /dev/null +++ b/packages/pi-permission-ai-judge/src/advice/widget.ts @@ -0,0 +1,196 @@ +import type { HighRiskCategory } from "../authority/highrisk"; + +/** + * Dialog advice widget (PIEXTENSIO-13): a pi-native `setWidget` panel shown + * while a permission dialog is up, so the human sees what the judge said — + * or why it could not judge — instead of a silent dialog. + * + * The widget is set at defer time (the chain link runs before the terminal + * renders the dialog) and cleared when the permission system broadcasts a + * `permissions:decision` for the same request. Render-only: it never gates, + * allows, denies, or suppresses anything (ADR 0011 §8's annotation contract, + * applied on the judge's side of the seam). + */ + +export const ADVICE_WIDGET_KEY = "ai-bash-judge-advice"; + +/** Theme colors the advice lines use; all exist in pi's theme vocabulary. */ +export type AdviceColor = "accent" | "warning" | "dim"; + +/** The pi theme surface the widget factory needs (narrow seam for tests). */ +export interface AdviceTheme { + fg(color: AdviceColor, text: string): string; +} + +/** A minimal renderable component (structural subset of pi-tui's Component). */ +export interface AdviceComponent { + render(width: number): string[]; + invalidate(): void; +} + +/** The pi UI surface the presenter needs (narrow seam for tests). */ +export interface AdviceWidgetUi { + setWidget( + key: string, + content: + | string[] + | ((tui: unknown, theme: AdviceTheme) => AdviceComponent) + | undefined, + ): void; +} + +/** What the judge concluded, in dialog-facing vocabulary. */ +export type AdviceView = + | { + readonly state: "judgment"; + readonly verdict: "allow" | "deny" | "defer"; + readonly reason: string; + /** True in Shadow mode: the dialog shows regardless of the verdict. */ + readonly shadow: boolean; + } + | { + readonly state: "skipped"; + readonly category: HighRiskCategory; + readonly rule: string; + } + | { + readonly state: "unavailable"; + readonly cause: string; + }; + +/** The decision-relevant command fragment highlighted for long commands. */ +export interface AdviceFocus { + readonly segment: string; + readonly origin: "high-risk" | "triggering-unit" | "executed-unit"; + readonly category?: HighRiskCategory; +} + +const REASON_MAX_CHARS = 180; +const SEGMENT_MAX_CHARS = 120; + +/** Collapse model prose to one line and clamp its length (code-point aware). */ +function sanitizeLine(text: string, maxChars: number): string { + const collapsed = text.replace(/\s+/g, " ").trim(); + const chars = [...collapsed]; + if (chars.length <= maxChars) { + return collapsed; + } + return `${chars.slice(0, maxChars - 1).join("")}…`; +} + +/** Clamp a rendered line to the live terminal width. */ +function clampToWidth(line: string, width: number): string { + if (width <= 0) { + return ""; + } + const chars = [...line]; + if (chars.length <= width) { + return line; + } + return width >= 2 + ? `${chars.slice(0, width - 1).join("")}…` + : chars.slice(0, width).join(""); +} + +export interface AdviceLines { + readonly lines: readonly string[]; + readonly colors: readonly AdviceColor[]; +} + +/** + * Pure renderer: one verdict line plus an optional focus line. + * `skipped`/`unavailable` carry their own cause in line one. + */ +export function formatAdvice( + view: AdviceView, + focus?: AdviceFocus, +): AdviceLines { + const lines: string[] = []; + const colors: AdviceColor[] = []; + switch (view.state) { + case "judgment": { + const suffix = view.shadow ? " (shadow)" : ""; + lines.push( + `ai-judge ${view.verdict}${suffix} — ${sanitizeLine(view.reason, REASON_MAX_CHARS)}`, + ); + colors.push( + view.verdict === "deny" ? "warning" : "accent", + ); + break; + } + case "skipped": { + lines.push( + `ai-judge skipped — high-risk ${view.category} (${view.rule}); forced dialog`, + ); + colors.push("warning"); + break; + } + case "unavailable": { + lines.push( + `ai-judge unavailable — ${sanitizeLine(view.cause, REASON_MAX_CHARS)}; not judged`, + ); + colors.push("dim"); + break; + } + } + if (focus !== undefined) { + const label = + focus.category !== undefined + ? `high-risk: ${focus.category}` + : focus.origin; + lines.push( + `focus: ${sanitizeLine(focus.segment, SEGMENT_MAX_CHARS)} (${label})`, + ); + colors.push("dim"); + } + return { lines, colors }; +} + +/** + * Owns the widget lifecycle: present on defer, clear when the decision + * resolves the same request, clear on shutdown. Disabled at construction + * (`dialogAdvice: false`) makes every method a no-op. + */ +export class AdvicePresenter { + private readonly ui: AdviceWidgetUi | undefined; + private currentRequestId: string | undefined; + + constructor(ui: AdviceWidgetUi | undefined, enabled: boolean) { + this.ui = enabled ? ui : undefined; + } + + present(requestId: string, view: AdviceView, focus?: AdviceFocus): void { + if (this.ui === undefined) { + return; + } + this.currentRequestId = requestId; + const { lines, colors } = formatAdvice(view, focus); + this.ui.setWidget(ADVICE_WIDGET_KEY, (_tui, theme) => ({ + render: (width: number) => + lines.map((line, index) => + theme.fg(colors[index] ?? "dim", clampToWidth(line, width)), + ), + invalidate: () => {}, + })); + } + + /** Clear the widget iff the decision resolves the request it describes. */ + handleDecision(requestId: string): void { + if (this.ui === undefined || requestId !== this.currentRequestId) { + return; + } + this.clear(); + } + + shutdown(): void { + if (this.ui === undefined) { + return; + } + this.clear(); + } + + private clear(): void { + this.currentRequestId = undefined; + this.ui?.setWidget(ADVICE_WIDGET_KEY, undefined); + } +} diff --git a/packages/pi-permission-ai-judge/src/config/judge.ts b/packages/pi-permission-ai-judge/src/config/judge.ts index 2482904..24257e6 100644 --- a/packages/pi-permission-ai-judge/src/config/judge.ts +++ b/packages/pi-permission-ai-judge/src/config/judge.ts @@ -40,6 +40,8 @@ export interface EffectiveJudgeConfig { readonly timeoutCohort: "default" | number; /** Fixed judge model (v2 only); undefined follows the session model. */ readonly judgeModel: JudgeModelSelection | undefined; + /** Show the dialog-advice widget while a permission dialog is up. */ + readonly dialogAdvice: boolean; /** Validation diagnostics for the loaded raw file, newest wins per key. */ readonly diagnostics: readonly ConfigDiagnostic[]; } @@ -64,6 +66,7 @@ const DEFAULT_CONFIG: EffectiveJudgeConfig = { timeoutMs: DEFAULT_TIMEOUT_MS, timeoutCohort: "default", judgeModel: undefined, + dialogAdvice: true, diagnostics: [], }; @@ -224,6 +227,28 @@ function parseTimeout(record: Record): { * diagnostic. Version 1 / unversioned files keep v1 semantics except * that `enforce` fails closed to shadow pending explicit migration. */ +/** Parse `dialogAdvice`: absent keeps the default (true); invalid falls back. */ +function parseDialogAdvice( + record: Record, +): { dialogAdvice: boolean; diagnostics: ConfigDiagnostic[] } { + if (record.dialogAdvice === undefined) { + return { dialogAdvice: true, diagnostics: [] }; + } + if (typeof record.dialogAdvice === "boolean") { + return { dialogAdvice: record.dialogAdvice, diagnostics: [] }; + } + return { + dialogAdvice: true, + diagnostics: [ + { + key: "dialogAdvice", + problem: `not a boolean: ${JSON.stringify(record.dialogAdvice)}`, + fallback: "true", + }, + ], + }; +} + export function loadJudgeConfig( deps: ConfigLoadDeps, ): EffectiveJudgeConfig { @@ -289,12 +314,16 @@ export function loadJudgeConfig( const timeoutResult = parseTimeout(record); diagnostics.push(...timeoutResult.diagnostics); + const adviceResult = parseDialogAdvice(record); + diagnostics.push(...adviceResult.diagnostics); + return Object.freeze({ configVersion, mode, timeoutMs: timeoutResult.timeoutMs, timeoutCohort: timeoutResult.timeoutCohort, judgeModel: modelResult.judgeModel, + dialogAdvice: adviceResult.dialogAdvice, diagnostics, }); } diff --git a/packages/pi-permission-ai-judge/src/index.ts b/packages/pi-permission-ai-judge/src/index.ts index 375ef7d..8597704 100644 --- a/packages/pi-permission-ai-judge/src/index.ts +++ b/packages/pi-permission-ai-judge/src/index.ts @@ -6,7 +6,9 @@ import type { Model } from "@earendil-works/pi-ai"; import type { ModelRegistry } from "@earendil-works/pi-coding-agent"; import { getPermissionsService, + PERMISSIONS_DECISION_CHANNEL, PERMISSIONS_READY_CHANNEL, + type PermissionDecisionEvent, type PromptPermissionDetails, type AuthorizerLog, type AuthorizerVerdict, @@ -28,6 +30,11 @@ import { type ConversationEvidence, } from "./evidence/conversation"; import { classifyHighRisk, type HighRiskMatch } from "./authority/highrisk"; +import { + AdvicePresenter, + type AdviceFocus, + type AdviceView, +} from "./advice/widget"; import { evaluateEnforceAuthority, type EnforceGateState } from "./authority/enforce"; import { classifyModel, @@ -60,6 +67,8 @@ interface RootSession { readonly getCwd: () => string; /** Judge-owned audit log (ADR 0006); unhealthy refuses Enforce authority. */ readonly auditLog: AuditLog; + /** Dialog-advice widget presenter (no-op when disabled by config). */ + readonly advice: AdvicePresenter; } const EMPTY_CONVERSATION: ConversationEvidence = { @@ -363,6 +372,10 @@ function runPreflightGates( details.forwarding !== undefined || details.payload.kind === "forwarded" ) { + ctx.captured.advice.present(details.requestId, { + state: "unavailable", + cause: "forwarded ask carries no structured bash input", + }); return { kind: "stop", verdict: preflightDefer( @@ -378,6 +391,10 @@ function runPreflightGates( } if (captured.getSessionId() !== captured.expectedSessionId) { + ctx.captured.advice.present(details.requestId, { + state: "unavailable", + cause: "session ownership unproven", + }); return { kind: "stop", verdict: preflightDefer( @@ -390,6 +407,10 @@ function runPreflightGates( const evidence = buildBashJudgmentEvidence(details); if (evidence === undefined) { + ctx.captured.advice.present(details.requestId, { + state: "unavailable", + cause: "bash evidence missing or invalid", + }); return { kind: "stop", verdict: preflightDefer( @@ -402,6 +423,11 @@ function runPreflightGates( const risk = classifyHighRisk(evidence.fullCommand); if (risk !== undefined && captured.config.mode === "enforce") { + ctx.captured.advice.present( + details.requestId, + { state: "skipped", category: risk.category, rule: risk.rule }, + buildAdviceFocus(evidence, risk, details), + ); return { kind: "stop", verdict: preflightDefer( @@ -439,6 +465,10 @@ function prepareModelCall( captured.modelRegistry, ); if (resolved.kind === "unavailable") { + ctx.captured.advice.present(ctx.details.requestId, { + state: "unavailable", + cause: "judge model unresolved", + }); infrastructureDefer( ctx, "judge_model_unavailable", @@ -479,6 +509,7 @@ function enforceAndEmit( conversation: ConversationEvidence, risk: HighRiskMatch | undefined, modelSource: "configured" | "session", + adviceFocus: AdviceFocus | undefined, ): AuthorizerVerdict { const gateState: EnforceGateState = { auditHealthy: captured.auditLog.healthy(), @@ -511,6 +542,28 @@ function enforceAndEmit( ); } + // Dialog advice rides only the defer arm: an Enforce allow never shows + // a dialog, so there is nothing for the widget to annotate. + if (authority.kind !== "allow") { + const view: AdviceView = + result.kind === "judgment" + ? { + state: "judgment", + verdict: result.verdict, + reason: result.reason, + shadow: captured.config.mode === "shadow", + } + : { + state: "unavailable", + cause: `model call failed (${result.kind})`, + }; + ctx.captured.advice.present( + ctx.details.requestId, + view, + adviceFocus, + ); + } + return authority.kind === "allow" ? { kind: "allow" } : { kind: "defer" }; @@ -570,16 +623,52 @@ async function judgeAuthorize( return enforceAndEmit( ctx, captured, sink, result, conversation, gate.risk, prepared.modelSource, + buildAdviceFocus(gate.evidence, gate.risk, details), ); } catch { // A link exception would abort the whole authority chain. // Keep provider/payload/session failures fail-closed and do // not include raw errors or authorization evidence in logs. sink.debug("ai_bash_judge.exception"); + captured.advice.present(details.requestId, { + state: "unavailable", + cause: "judge internal error", + }); return { kind: "defer" }; } } +/** + * Focus cascade for the dialog-advice widget (PIEXTENSIO-13): the + * high-risk match segment when one exists, else the unit that triggered + * the ask, else the executed unit the payload carries. Omitted when the + * payload offers nothing narrower than the full command. + */ +function buildAdviceFocus( + evidence: BashJudgmentEvidence, + risk: HighRiskMatch | undefined, + details: PromptPermissionDetails, +): AdviceFocus | undefined { + if (risk !== undefined) { + return { + segment: evidence.triggeringUnit ?? evidence.fullCommand, + origin: "high-risk", + category: risk.category, + }; + } + if (evidence.triggeringUnit !== undefined) { + return { + segment: evidence.triggeringUnit, + origin: "triggering-unit", + }; + } + const executedUnit = details.payload.request.executedUnit; + if (executedUnit !== null && executedUnit !== undefined) { + return { segment: executedUnit, origin: "executed-unit" }; + } + return undefined; +} + /** * One non-blocking session notice in Enforce mode: the effective judge * model and the risk contract in one line (ADR 0008). Not repeated per @@ -681,6 +770,7 @@ export default function permissionAiJudge(pi: ExtensionAPI): void { }), conversation: conversationProbeFromSession(ctx.sessionManager), getCwd: () => ctx.sessionManager.getCwd(), + advice: new AdvicePresenter(ctx.ui, config.dialogAdvice), }; for (const diagnostic of root.config.diagnostics) { ctx.ui.notify( @@ -714,8 +804,20 @@ export default function permissionAiJudge(pi: ExtensionAPI): void { pi.events.on(PERMISSIONS_READY_CHANNEL, tryRegister); + // Dialog-advice lifecycle (PIEXTENSIO-13): the permission system + // broadcasts one decision per request; the one resolving the request + // the widget describes takes the widget down with it. + pi.events.on( + PERMISSIONS_DECISION_CHANNEL, + (data: unknown) => { + const event = data as PermissionDecisionEvent; + root?.advice.handleDecision(event.requestId); + }, + ); + pi.on("session_shutdown", () => { root?.shutdown.abort(); + root?.advice.shutdown(); disposeAuthorizer?.(); disposeAuthorizer = undefined; root = undefined; diff --git a/packages/pi-permission-ai-judge/test/advice.test.ts b/packages/pi-permission-ai-judge/test/advice.test.ts new file mode 100644 index 0000000..94d4b3c --- /dev/null +++ b/packages/pi-permission-ai-judge/test/advice.test.ts @@ -0,0 +1,208 @@ +import { describe, expect, it, vi } from "vitest"; +import { + ADVICE_WIDGET_KEY, + AdvicePresenter, + formatAdvice, + type AdviceComponent, + type AdviceTheme, + type AdviceWidgetUi, +} from "../src/advice/widget"; + +const theme: AdviceTheme = { + fg: (color, text) => `<${color}>${text}`, +}; + +function render(component: AdviceComponent, width = 200): string[] { + return component.render(width); +} + +describe("formatAdvice", () => { + it("renders a judgment line with verdict and reason", () => { + const advice = formatAdvice({ + state: "judgment", + verdict: "defer", + reason: "command rewrites published history; intent not established", + shadow: false, + }); + expect(advice.lines).toEqual([ + "ai-judge defer — command rewrites published history; intent not established", + ]); + expect(advice.colors).toEqual(["accent"]); + }); + + it("marks shadow verdicts and colors deny as warning", () => { + const advice = formatAdvice({ + state: "judgment", + verdict: "deny", + reason: "credential read without user intent", + shadow: true, + }); + expect(advice.lines[0]).toBe( + "ai-judge deny (shadow) — credential read without user intent", + ); + expect(advice.colors[0]).toBe("warning"); + }); + + it("renders the skipped state with category and rule", () => { + const advice = formatAdvice({ + state: "skipped", + category: "data_loss", + rule: "git clean -xfd", + }); + expect(advice.lines).toEqual([ + "ai-judge skipped — high-risk data_loss (git clean -xfd); forced dialog", + ]); + expect(advice.colors).toEqual(["warning"]); + }); + + it("renders the unavailable state with cause", () => { + const advice = formatAdvice({ + state: "unavailable", + cause: "model call failed (timeout)", + }); + expect(advice.lines).toEqual([ + "ai-judge unavailable — model call failed (timeout); not judged", + ]); + expect(advice.colors).toEqual(["dim"]); + }); + + it("appends a focus line for a high-risk match", () => { + const advice = formatAdvice( + { state: "skipped", category: "history_rewrite", rule: "git push --force" }, + { segment: "git push --force origin main", origin: "high-risk", category: "history_rewrite" }, + ); + expect(advice.lines[1]).toBe( + "focus: git push --force origin main (high-risk: history_rewrite)", + ); + expect(advice.colors[1]).toBe("dim"); + }); + + it("labels a plain triggering-unit focus by origin", () => { + const advice = formatAdvice( + { state: "judgment", verdict: "defer", reason: "r", shadow: false }, + { segment: "rm -rf build", origin: "triggering-unit" }, + ); + expect(advice.lines[1]).toBe("focus: rm -rf build (triggering-unit)"); + }); + + it("collapses whitespace in reason and focus", () => { + const advice = formatAdvice( + { state: "judgment", verdict: "defer", reason: "a\n b\t\tc", shadow: false }, + { segment: "x\n y", origin: "executed-unit" }, + ); + expect(advice.lines[0]).toContain("— a b c"); + expect(advice.lines[1]).toBe("focus: x y (executed-unit)"); + }); + + it("clamps long reasons and segments with an ellipsis", () => { + const long = "a".repeat(400); + const advice = formatAdvice( + { state: "judgment", verdict: "defer", reason: long, shadow: false }, + { segment: long, origin: "triggering-unit" }, + ); + expect([...advice.lines[0]!].length).toBe("ai-judge defer — ".length + 180); + expect(advice.lines[0]!.endsWith("…")).toBe(true); + const focusLine = advice.lines[1]!; + const segment = focusLine.slice("focus: ".length, focusLine.lastIndexOf(" (")); + expect([...segment].length).toBe(120); + expect(segment.endsWith("…")).toBe(true); + }); +}); + +function recordingUi(): AdviceWidgetUi & { + calls: Array<{ key: string; content: unknown }>; +} { + const calls: Array<{ key: string; content: unknown }> = []; + return { + calls, + setWidget(key, content) { + calls.push({ key, content }); + }, + }; +} + +describe("AdvicePresenter", () => { + it("sets a themed widget on present and clamps to render width", () => { + const ui = recordingUi(); + const presenter = new AdvicePresenter(ui, true); + presenter.present("req-1", { + state: "judgment", + verdict: "defer", + reason: "ambiguous intent", + shadow: false, + }); + expect(ui.calls).toHaveLength(1); + expect(ui.calls[0]!.key).toBe(ADVICE_WIDGET_KEY); + const factory = ui.calls[0]!.content as ( + tui: unknown, + theme: AdviceTheme, + ) => AdviceComponent; + const component = factory({}, theme); + expect(render(component, 200)).toEqual([ + "ai-judge defer — ambiguous intent", + ]); + expect(render(component, 10)[0]).toBe( + "ai-judge …", + ); + component.invalidate(); + }); + + it("is a no-op end to end when disabled", () => { + const ui = recordingUi(); + const presenter = new AdvicePresenter(ui, false); + presenter.present("req-1", { + state: "unavailable", + cause: "off", + }); + presenter.handleDecision("req-1"); + presenter.shutdown(); + expect(ui.calls).toHaveLength(0); + }); + + it("clears only on the decision that resolves the current request", () => { + const ui = recordingUi(); + const presenter = new AdvicePresenter(ui, true); + presenter.present("req-1", { state: "unavailable", cause: "x" }); + presenter.handleDecision("req-other"); + expect(ui.calls).toHaveLength(1); + presenter.handleDecision("req-1"); + expect(ui.calls).toHaveLength(2); + expect(ui.calls[1]).toEqual({ key: ADVICE_WIDGET_KEY, content: undefined }); + }); + + it("a later present re-keys the clear guard onto the new request", () => { + const ui = recordingUi(); + const presenter = new AdvicePresenter(ui, true); + presenter.present("req-1", { state: "unavailable", cause: "x" }); + presenter.present("req-2", { state: "unavailable", cause: "y" }); + presenter.handleDecision("req-1"); + expect(ui.calls).toHaveLength(2); // nothing cleared + presenter.handleDecision("req-2"); + expect(ui.calls).toHaveLength(3); + expect(ui.calls[2]).toEqual({ key: ADVICE_WIDGET_KEY, content: undefined }); + }); + + it("shutdown clears the widget", () => { + const ui = recordingUi(); + const presenter = new AdvicePresenter(ui, true); + presenter.present("req-1", { state: "unavailable", cause: "x" }); + presenter.shutdown(); + expect(ui.calls.at(-1)).toEqual({ + key: ADVICE_WIDGET_KEY, + content: undefined, + }); + }); +}); + +describe("AdvicePresenter with a real ExtensionUIContext-shaped ui", () => { + it("accepts the overload-style setWidget surface", () => { + const setWidget = vi.fn(); + const ui = { setWidget } as unknown as AdviceWidgetUi; + const presenter = new AdvicePresenter(ui, true); + presenter.present("r", { state: "unavailable", cause: "shape" }); + expect(setWidget).toHaveBeenCalledWith( + ADVICE_WIDGET_KEY, + expect.any(Function), + ); + }); +}); diff --git a/packages/pi-permission-ai-judge/test/config.test.ts b/packages/pi-permission-ai-judge/test/config.test.ts index 331b31d..59253c4 100644 --- a/packages/pi-permission-ai-judge/test/config.test.ts +++ b/packages/pi-permission-ai-judge/test/config.test.ts @@ -25,6 +25,7 @@ describe("loadJudgeConfig — missing and malformed", () => { timeoutMs: 15_000, timeoutCohort: "default", judgeModel: undefined, + dialogAdvice: true, diagnostics: [ expect.objectContaining({ key: "file", fallback: "all defaults" }), ], @@ -47,6 +48,42 @@ describe("loadJudgeConfig — missing and malformed", () => { }); }); +describe("loadJudgeConfig — dialogAdvice", () => { + it("defaults to true when absent", () => { + const config = loadJudgeConfig( + deps({ [CONFIG_PATH]: JSON.stringify({ version: 2 }) }), + ); + expect(config.dialogAdvice).toBe(true); + expect(config.diagnostics).toEqual([]); + }); + + it("accepts an explicit false", () => { + const config = loadJudgeConfig( + deps({ + [CONFIG_PATH]: JSON.stringify({ version: 2, dialogAdvice: false }), + }), + ); + expect(config.dialogAdvice).toBe(false); + expect(config.diagnostics).toEqual([]); + }); + + it("falls back to true with a diagnostic on a non-boolean", () => { + const config = loadJudgeConfig( + deps({ + [CONFIG_PATH]: JSON.stringify({ version: 2, dialogAdvice: "yes" }), + }), + ); + expect(config.dialogAdvice).toBe(true); + expect(config.diagnostics).toEqual([ + { + key: "dialogAdvice", + problem: 'not a boolean: "yes"', + fallback: "true", + }, + ]); + }); +}); + describe("loadJudgeConfig — version selection", () => { it("treats a missing version as v1", () => { const config = loadJudgeConfig(deps({ [CONFIG_PATH]: '{"mode":"shadow"}' })); @@ -246,6 +283,7 @@ describe("loadJudgeConfig — snapshot immutability", () => { timeoutMs: 20_000, timeoutCohort: 20_000, judgeModel: { provider: "p", id: "m" }, + dialogAdvice: true, diagnostics: [], }); }); diff --git a/packages/pi-permission-ai-judge/test/lifecycle.test.ts b/packages/pi-permission-ai-judge/test/lifecycle.test.ts index 02d18a8..69afa32 100644 --- a/packages/pi-permission-ai-judge/test/lifecycle.test.ts +++ b/packages/pi-permission-ai-judge/test/lifecycle.test.ts @@ -253,7 +253,7 @@ describe("AI judge lifecycle", () => { return currentModel; }, modelRegistry: { complete }, - ui: { notify: vi.fn() }, + ui: { notify: vi.fn(), setWidget: vi.fn() }, } as unknown as ExtensionContext; const harness = createFakePi(); @@ -342,7 +342,7 @@ describe("AI judge lifecycle", () => { api: "openai-codex-responses", } as Model, modelRegistry: { complete }, - ui: { notify: vi.fn() }, + ui: { notify: vi.fn(), setWidget: vi.fn() }, } as unknown as ExtensionContext; const harness = createFakePi(); @@ -406,7 +406,7 @@ describe("AI judge lifecycle", () => { sessionManager, model, modelRegistry: { complete }, - ui: { notify: vi.fn() }, + ui: { notify: vi.fn(), setWidget: vi.fn() }, } as unknown as ExtensionContext; const harness = createFakePi(); @@ -492,7 +492,7 @@ describe("AI judge lifecycle", () => { api: "openai-codex-responses", } as Model, modelRegistry: { complete }, - ui: { notify: vi.fn() }, + ui: { notify: vi.fn(), setWidget: vi.fn() }, } as unknown as ExtensionContext; const harness = createFakePi(); @@ -571,7 +571,7 @@ describe("AI judge lifecycle", () => { api: "openai-codex-responses", } as Model, modelRegistry: { complete }, - ui: { notify: vi.fn() }, + ui: { notify: vi.fn(), setWidget: vi.fn() }, } as unknown as ExtensionContext; const harness = createFakePi(); @@ -701,7 +701,7 @@ describe("AI judge Enforce authority seam (PIEXTENSIO-23, ADR 0008)", () => { api: "openai-codex-responses", } as Model, modelRegistry: { complete, find, hasConfiguredAuth }, - ui: { notify }, + ui: { notify, setWidget: vi.fn() }, } as unknown as ExtensionContext; const harness = createFakePi(); @@ -936,7 +936,7 @@ describe("AI judge Enforce authority seam (PIEXTENSIO-23, ADR 0008)", () => { api: "openai-codex-responses", } as Model, modelRegistry: { complete, find: vi.fn(), hasConfiguredAuth: vi.fn(() => true) }, - ui: { notify }, + ui: { notify, setWidget: vi.fn() }, } as unknown as ExtensionContext; const harness = createFakePi();