feat(ai-judge): show dialog advice widget with verdict and focus

- add AdvicePresenter rendering a pi-native setWidget panel while a
  permission dialog is up: judgment verdict with reason, high-risk skip
  with category, or an unavailable cause
- highlight the decision-relevant command fragment via a focus cascade:
  high-risk match, triggering unit, then executed unit
- clear the widget when permissions:decision resolves the same request
- add dialogAdvice config key (default true, invalid falls back with a
  diagnostic) and cover formatting, lifecycle, and config in tests
This commit is contained in:
2026-09-17 23:42:57 +08:00
parent 77ef5483ed
commit 7190bffd86
7 changed files with 592 additions and 7 deletions
+12
View File
@@ -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 的防线
@@ -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);
}
}
@@ -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<string, unknown>): {
* 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<string, unknown>,
): { 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,
});
}
@@ -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;
@@ -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([
"<accent>ai-judge defer — ambiguous intent</>",
]);
expect(render(component, 10)[0]).toBe(
"<accent>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),
);
});
});
@@ -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: [],
});
});
@@ -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<any>,
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<any>,
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<any>,
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<any>,
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<any>,
modelRegistry: { complete, find: vi.fn(), hasConfiguredAuth: vi.fn(() => true) },
ui: { notify },
ui: { notify, setWidget: vi.fn() },
} as unknown as ExtensionContext;
const harness = createFakePi();