mirror of
https://github.com/SikongJueluo/pi-extensions.git
synced 2026-10-05 11:52:55 +08:00
feat(ai-judge): audit and complete the dialog advice surface
- emit a non-blocking ai-bash-judge auto-allowed notify with the judge reason whenever Enforce authority grants an ask without a dialog - raise reason and focus caps from 180/120 to 600/240 code points so ordinary model reasons wrap completely instead of ending mid-sentence - treat the cap as a pathological-output guard, not a display budget - cover sanitization, disabled paths, and completeness in tests
This commit is contained in:
@@ -20,6 +20,8 @@ focus: git push --force origin main (high-risk: history_rewrite)
|
|||||||
|
|
||||||
三态:`defer/deny/allow` 判决带理由(shadow 模式标 `(shadow)`);高风险跳过标类别;模型不可用/超时/异常标 `unavailable`。弹窗被处理后挂件自动消失。配置 `dialogAdvice: false` 关闭。
|
三态:`defer/deny/allow` 判决带理由(shadow 模式标 `(shadow)`);高风险跳过标类别;模型不可用/超时/异常标 `unavailable`。弹窗被处理后挂件自动消失。配置 `dialogAdvice: false` 关闭。
|
||||||
|
|
||||||
|
Enforce 模式下判官代批(auto-allow,无弹窗)时会发一条非阻塞通知 `ai-bash-judge auto-allowed — <理由>` 留痕,代批决策可审计。
|
||||||
|
|
||||||
## 安装
|
## 安装
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -40,6 +40,9 @@ export interface AdviceWidgetUi {
|
|||||||
): void;
|
): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Transient-notification seam (ctx.ui.notify's narrow shape). */
|
||||||
|
export type AdviceNotify = (message: string, kind: "info") => void;
|
||||||
|
|
||||||
/** What the judge concluded, in dialog-facing vocabulary. */
|
/** What the judge concluded, in dialog-facing vocabulary. */
|
||||||
export type AdviceView =
|
export type AdviceView =
|
||||||
| {
|
| {
|
||||||
@@ -66,10 +69,15 @@ export interface AdviceFocus {
|
|||||||
readonly category?: HighRiskCategory;
|
readonly category?: HighRiskCategory;
|
||||||
}
|
}
|
||||||
|
|
||||||
const REASON_MAX_CHARS = 180;
|
const REASON_MAX_CHARS = 600;
|
||||||
const SEGMENT_MAX_CHARS = 120;
|
const SEGMENT_MAX_CHARS = 240;
|
||||||
|
|
||||||
/** Collapse model prose to one line and clamp its length (code-point aware). */
|
/**
|
||||||
|
* Collapse model prose to one logical line and clamp pathological length
|
||||||
|
* (code-point aware). The cap is a sanity guard against runaway model
|
||||||
|
* output, not a display budget: rendering wraps to the terminal width, so
|
||||||
|
* everything under the cap is shown in full.
|
||||||
|
*/
|
||||||
function sanitizeLine(text: string, maxChars: number): string {
|
function sanitizeLine(text: string, maxChars: number): string {
|
||||||
const collapsed = text.replace(/\s+/g, " ").trim();
|
const collapsed = text.replace(/\s+/g, " ").trim();
|
||||||
const chars = [...collapsed];
|
const chars = [...collapsed];
|
||||||
@@ -155,10 +163,16 @@ export function formatAdvice(
|
|||||||
*/
|
*/
|
||||||
export class AdvicePresenter {
|
export class AdvicePresenter {
|
||||||
private readonly ui: AdviceWidgetUi | undefined;
|
private readonly ui: AdviceWidgetUi | undefined;
|
||||||
|
private readonly notify: AdviceNotify | undefined;
|
||||||
private currentRequestId: string | undefined;
|
private currentRequestId: string | undefined;
|
||||||
|
|
||||||
constructor(ui: AdviceWidgetUi | undefined, enabled: boolean) {
|
constructor(
|
||||||
|
ui: AdviceWidgetUi | undefined,
|
||||||
|
notify: AdviceNotify | undefined,
|
||||||
|
enabled: boolean,
|
||||||
|
) {
|
||||||
this.ui = enabled ? ui : undefined;
|
this.ui = enabled ? ui : undefined;
|
||||||
|
this.notify = enabled ? notify : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
present(requestId: string, view: AdviceView, focus?: AdviceFocus): void {
|
present(requestId: string, view: AdviceView, focus?: AdviceFocus): void {
|
||||||
@@ -179,6 +193,21 @@ export class AdvicePresenter {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transient trace for an Enforce auto-allow: no dialog ever shows, so
|
||||||
|
* there is no widget — a notify line keeps the delegation auditable
|
||||||
|
* (PIEXTENSIO-13 option 3: visibility without interruption).
|
||||||
|
*/
|
||||||
|
notifyAllowed(reason: string): void {
|
||||||
|
if (this.notify === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.notify(
|
||||||
|
`ai-bash-judge auto-allowed — ${sanitizeLine(reason, REASON_MAX_CHARS)}`,
|
||||||
|
"info",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** Clear the widget iff the decision resolves the request it describes. */
|
/** Clear the widget iff the decision resolves the request it describes. */
|
||||||
handleDecision(requestId: string): void {
|
handleDecision(requestId: string): void {
|
||||||
if (this.ui === undefined || requestId !== this.currentRequestId) {
|
if (this.ui === undefined || requestId !== this.currentRequestId) {
|
||||||
|
|||||||
@@ -543,8 +543,15 @@ function enforceAndEmit(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Dialog advice rides only the defer arm: an Enforce allow never shows
|
// Dialog advice rides only the defer arm: an Enforce allow never shows
|
||||||
// a dialog, so there is nothing for the widget to annotate.
|
// a dialog, so there is nothing for the widget to annotate — instead a
|
||||||
if (authority.kind !== "allow") {
|
// transient notify keeps the auto-approval auditable.
|
||||||
|
if (authority.kind === "allow") {
|
||||||
|
if (result.kind === "judgment") {
|
||||||
|
ctx.captured.advice.notifyAllowed(result.reason);
|
||||||
|
}
|
||||||
|
return { kind: "allow" };
|
||||||
|
}
|
||||||
|
|
||||||
const view: AdviceView =
|
const view: AdviceView =
|
||||||
result.kind === "judgment"
|
result.kind === "judgment"
|
||||||
? {
|
? {
|
||||||
@@ -562,11 +569,7 @@ function enforceAndEmit(
|
|||||||
view,
|
view,
|
||||||
adviceFocus,
|
adviceFocus,
|
||||||
);
|
);
|
||||||
}
|
return { kind: "defer" };
|
||||||
|
|
||||||
return authority.kind === "allow"
|
|
||||||
? { kind: "allow" }
|
|
||||||
: { kind: "defer" };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -770,7 +773,11 @@ export default function permissionAiJudge(pi: ExtensionAPI): void {
|
|||||||
}),
|
}),
|
||||||
conversation: conversationProbeFromSession(ctx.sessionManager),
|
conversation: conversationProbeFromSession(ctx.sessionManager),
|
||||||
getCwd: () => ctx.sessionManager.getCwd(),
|
getCwd: () => ctx.sessionManager.getCwd(),
|
||||||
advice: new AdvicePresenter(ctx.ui, config.dialogAdvice),
|
advice: new AdvicePresenter(
|
||||||
|
ctx.ui,
|
||||||
|
(message, kind) => ctx.ui.notify(message, kind),
|
||||||
|
config.dialogAdvice,
|
||||||
|
),
|
||||||
};
|
};
|
||||||
for (const diagnostic of root.config.diagnostics) {
|
for (const diagnostic of root.config.diagnostics) {
|
||||||
ctx.ui.notify(
|
ctx.ui.notify(
|
||||||
|
|||||||
@@ -103,19 +103,32 @@ describe("formatAdvice", () => {
|
|||||||
expect(advice.lines[1]).toBe("focus: x y (executed-unit)");
|
expect(advice.lines[1]).toBe("focus: x y (executed-unit)");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("clamps long reasons and segments with an ellipsis", () => {
|
it("clamps pathological reasons and segments with an ellipsis", () => {
|
||||||
const long = "a".repeat(400);
|
const long = "a".repeat(4000);
|
||||||
const advice = formatAdvice(
|
const advice = formatAdvice(
|
||||||
{ state: "judgment", verdict: "defer", reason: long, shadow: false },
|
{ state: "judgment", verdict: "defer", reason: long, shadow: false },
|
||||||
{ segment: long, origin: "triggering-unit" },
|
{ segment: long, origin: "triggering-unit" },
|
||||||
);
|
);
|
||||||
expect([...advice.lines[0]!].length).toBe("ai-judge defer — ".length + 180);
|
expect([...advice.lines[0]!].length).toBe("ai-judge defer — ".length + 600);
|
||||||
expect(advice.lines[0]!.endsWith("…")).toBe(true);
|
expect(advice.lines[0]!.endsWith("…")).toBe(true);
|
||||||
const focusLine = advice.lines[1]!;
|
const focusLine = advice.lines[1]!;
|
||||||
const segment = focusLine.slice("focus: ".length, focusLine.lastIndexOf(" ("));
|
const segment = focusLine.slice("focus: ".length, focusLine.lastIndexOf(" ("));
|
||||||
expect([...segment].length).toBe(120);
|
expect([...segment].length).toBe(240);
|
||||||
expect(segment.endsWith("…")).toBe(true);
|
expect(segment.endsWith("…")).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps ordinary multi-sentence reasons complete", () => {
|
||||||
|
const reason =
|
||||||
|
"命令会重写已发布的历史记录且用户意图未确立。".repeat(6);
|
||||||
|
const advice = formatAdvice({
|
||||||
|
state: "judgment",
|
||||||
|
verdict: "defer",
|
||||||
|
reason,
|
||||||
|
shadow: false,
|
||||||
|
});
|
||||||
|
expect(advice.lines[0]!.endsWith("。"));
|
||||||
|
expect(advice.lines[0]!.endsWith("…")).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function recordingUi(): AdviceWidgetUi & {
|
function recordingUi(): AdviceWidgetUi & {
|
||||||
@@ -133,7 +146,7 @@ function recordingUi(): AdviceWidgetUi & {
|
|||||||
describe("AdvicePresenter", () => {
|
describe("AdvicePresenter", () => {
|
||||||
it("sets a themed widget on present and clamps to render width", () => {
|
it("sets a themed widget on present and clamps to render width", () => {
|
||||||
const ui = recordingUi();
|
const ui = recordingUi();
|
||||||
const presenter = new AdvicePresenter(ui, true);
|
const presenter = new AdvicePresenter(ui, undefined, true);
|
||||||
presenter.present("req-1", {
|
presenter.present("req-1", {
|
||||||
state: "judgment",
|
state: "judgment",
|
||||||
verdict: "defer",
|
verdict: "defer",
|
||||||
@@ -162,7 +175,7 @@ describe("AdvicePresenter", () => {
|
|||||||
|
|
||||||
it("never renders wider than the terminal and loses no CJK text", () => {
|
it("never renders wider than the terminal and loses no CJK text", () => {
|
||||||
const ui = recordingUi();
|
const ui = recordingUi();
|
||||||
const presenter = new AdvicePresenter(ui, true);
|
const presenter = new AdvicePresenter(ui, undefined, true);
|
||||||
const reason = "命令会重写已发布的历史记录且用户意图未确立".repeat(10);
|
const reason = "命令会重写已发布的历史记录且用户意图未确立".repeat(10);
|
||||||
presenter.present("req-1", {
|
presenter.present("req-1", {
|
||||||
state: "judgment",
|
state: "judgment",
|
||||||
@@ -182,15 +195,14 @@ describe("AdvicePresenter", () => {
|
|||||||
}
|
}
|
||||||
expect(visibleWidth(lines[0]!)).toBeGreaterThan(0);
|
expect(visibleWidth(lines[0]!)).toBeGreaterThan(0);
|
||||||
}
|
}
|
||||||
// Wrapping, not truncation: the full (sanitized) reason survives.
|
// Wrapping, not truncation: the full reason survives the render.
|
||||||
const sanitized = [...reason].slice(0, 180).join("");
|
|
||||||
const joined = render(component, 20).map(stripAnsi).join("");
|
const joined = render(component, 20).map(stripAnsi).join("");
|
||||||
expect(joined).toContain(sanitized.slice(-REASON_TAIL));
|
expect(joined).toContain(reason.slice(-REASON_TAIL));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("is a no-op end to end when disabled", () => {
|
it("is a no-op end to end when disabled", () => {
|
||||||
const ui = recordingUi();
|
const ui = recordingUi();
|
||||||
const presenter = new AdvicePresenter(ui, false);
|
const presenter = new AdvicePresenter(ui, undefined, false);
|
||||||
presenter.present("req-1", {
|
presenter.present("req-1", {
|
||||||
state: "unavailable",
|
state: "unavailable",
|
||||||
cause: "off",
|
cause: "off",
|
||||||
@@ -202,7 +214,7 @@ describe("AdvicePresenter", () => {
|
|||||||
|
|
||||||
it("clears only on the decision that resolves the current request", () => {
|
it("clears only on the decision that resolves the current request", () => {
|
||||||
const ui = recordingUi();
|
const ui = recordingUi();
|
||||||
const presenter = new AdvicePresenter(ui, true);
|
const presenter = new AdvicePresenter(ui, undefined, true);
|
||||||
presenter.present("req-1", { state: "unavailable", cause: "x" });
|
presenter.present("req-1", { state: "unavailable", cause: "x" });
|
||||||
presenter.handleDecision("req-other");
|
presenter.handleDecision("req-other");
|
||||||
expect(ui.calls).toHaveLength(1);
|
expect(ui.calls).toHaveLength(1);
|
||||||
@@ -213,7 +225,7 @@ describe("AdvicePresenter", () => {
|
|||||||
|
|
||||||
it("a later present re-keys the clear guard onto the new request", () => {
|
it("a later present re-keys the clear guard onto the new request", () => {
|
||||||
const ui = recordingUi();
|
const ui = recordingUi();
|
||||||
const presenter = new AdvicePresenter(ui, true);
|
const presenter = new AdvicePresenter(ui, undefined, true);
|
||||||
presenter.present("req-1", { state: "unavailable", cause: "x" });
|
presenter.present("req-1", { state: "unavailable", cause: "x" });
|
||||||
presenter.present("req-2", { state: "unavailable", cause: "y" });
|
presenter.present("req-2", { state: "unavailable", cause: "y" });
|
||||||
presenter.handleDecision("req-1");
|
presenter.handleDecision("req-1");
|
||||||
@@ -225,7 +237,7 @@ describe("AdvicePresenter", () => {
|
|||||||
|
|
||||||
it("shutdown clears the widget", () => {
|
it("shutdown clears the widget", () => {
|
||||||
const ui = recordingUi();
|
const ui = recordingUi();
|
||||||
const presenter = new AdvicePresenter(ui, true);
|
const presenter = new AdvicePresenter(ui, undefined, true);
|
||||||
presenter.present("req-1", { state: "unavailable", cause: "x" });
|
presenter.present("req-1", { state: "unavailable", cause: "x" });
|
||||||
presenter.shutdown();
|
presenter.shutdown();
|
||||||
expect(ui.calls.at(-1)).toEqual({
|
expect(ui.calls.at(-1)).toEqual({
|
||||||
@@ -233,13 +245,31 @@ describe("AdvicePresenter", () => {
|
|||||||
content: undefined,
|
content: undefined,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("emits a sanitized auto-allow notify", () => {
|
||||||
|
const notify = vi.fn();
|
||||||
|
const presenter = new AdvicePresenter(recordingUi(), notify, true);
|
||||||
|
presenter.notifyAllowed(" command matches\nexplicit user intent ");
|
||||||
|
expect(notify).toHaveBeenCalledWith(
|
||||||
|
"ai-bash-judge auto-allowed — command matches explicit user intent",
|
||||||
|
"info",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips the auto-allow notify when disabled", () => {
|
||||||
|
const notify = vi.fn();
|
||||||
|
const presenter = new AdvicePresenter(recordingUi(), notify, false);
|
||||||
|
presenter.notifyAllowed("r");
|
||||||
|
presenter.present("r", { state: "unavailable", cause: "x" });
|
||||||
|
expect(notify).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("AdvicePresenter with a real ExtensionUIContext-shaped ui", () => {
|
describe("AdvicePresenter with a real ExtensionUIContext-shaped ui", () => {
|
||||||
it("accepts the overload-style setWidget surface", () => {
|
it("accepts the overload-style setWidget surface", () => {
|
||||||
const setWidget = vi.fn();
|
const setWidget = vi.fn();
|
||||||
const ui = { setWidget } as unknown as AdviceWidgetUi;
|
const ui = { setWidget } as unknown as AdviceWidgetUi;
|
||||||
const presenter = new AdvicePresenter(ui, true);
|
const presenter = new AdvicePresenter(ui, undefined, true);
|
||||||
presenter.present("r", { state: "unavailable", cause: "shape" });
|
presenter.present("r", { state: "unavailable", cause: "shape" });
|
||||||
expect(setWidget).toHaveBeenCalledWith(
|
expect(setWidget).toHaveBeenCalledWith(
|
||||||
ADVICE_WIDGET_KEY,
|
ADVICE_WIDGET_KEY,
|
||||||
|
|||||||
Reference in New Issue
Block a user