mirror of
https://github.com/SikongJueluo/pi-extensions.git
synced 2026-10-05 11:52:55 +08:00
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:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user