Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions integrations/pi-villani/src/extension.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -450,7 +450,7 @@ test("approvalMessage never renders object and includes Bash command", async ()
assert.match(msg, /Command:\necho hi/);
assert.equal(
approvalTitle({ tool: "Bash" }),
"Villani wants to run a shell command",
"Villani requests command authority",
);
});

Expand Down Expand Up @@ -512,9 +512,10 @@ test("/villani approval pending sets status/widget and accepted clears widget",
else process.env.VILLANI_USE_PI_MODEL = oldUsePi;
}
assert.ok(
statuses.some((a) => a[0] === "villani" && a[1] === "Waiting for approval..."),
statuses.some((a) => a[0] === "villani" && /approval|authorization|authority|clearance/i.test(String(a[1]))),
);
assert.ok(widgets.some((a) => a[0] === "villani" && Array.isArray(a[1])));
assert.ok(widgets.some((a) => a[0] === "villani" && Array.isArray(a[1]) && a[1][0] === "Villaniclearance required"));
assert.doesNotMatch(JSON.stringify(widgets), /Pending approval|Allow this operation|\[object Object\]/);
assert.ok(widgets.some((a) => a[0] === "villani" && a[1] === undefined));
assert.equal(confirms[0][2].signal instanceof AbortSignal, true);
});
Expand Down Expand Up @@ -600,7 +601,7 @@ test("repeated model_request_started updates status without notify spam", async
};
await renderBridgeEvent({ type: "model_request_started" }, {}, ctx);
await renderBridgeEvent({ type: "model_request_started" }, {}, ctx);
assert.equal(statuses.length, 2);
assert.equal(statuses.length, 1);
assert.ok(statuses.every((s) => /^Villani/.test(s)));
assert.deepEqual(notes, []);
});
Expand Down
74 changes: 49 additions & 25 deletions integrations/pi-villani/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
import {
confirm,
finalMessage,
nextVillaniStatus,
notify,
renderBridgeEvent,
resetVillaniUiState,
Expand Down Expand Up @@ -79,10 +80,23 @@ export async function safeCommand(
}
}
export function approvalTitle(request: any) {
if (request.tool === "Bash") return "Villani wants to run a shell command";
if (request.tool === "Write") return "Villani wants to write a file";
if (request.tool === "Patch") return "Villani wants to apply a patch";
return "Villani wants approval";
const tool = String(request.tool || "");
if (tool === "Bash") return "Villani requests command authority";
if (tool === "Read") return "Villani requests dossier access";
if (tool === "Write") return "Villani requests edit authority";
if (tool === "Patch") return "Villani requests patch authority";
if (tool === "GitStatus") return "Villani requests repository inspection";
if (tool === "GitDiff") return "Villani requests diff inspection";
return "Villani requests approval";
}
function approvalLead(tool: string): string {
if (tool === "Bash") return "Villani requests command authority.";
if (tool === "Read") return "Villani requests dossier access.";
if (tool === "Write") return "Villani requests edit authority.";
if (tool === "Patch") return "Villani requests patch authority.";
if (tool === "GitStatus") return "Villani requests repository inspection.";
if (tool === "GitDiff") return "Villani requests diff inspection.";
return "Villaniclearance required.";
}
export function approvalMessage(request: any) {
const input =
Expand All @@ -91,16 +105,23 @@ export function approvalMessage(request: any) {
!Array.isArray(request.input)
? request.input
: {};
const path = typeof input.path === "string" ? input.path : undefined;

const tool = String(request.tool || "operation");
const command = typeof input.command === "string" ? input.command : undefined;
const safeSummary =
typeof request.summary === "string" && request.summary.trim()
? request.summary
: "Approval required";
const lines = [safeSummary, ""];
const path =
typeof input.path === "string" ? input.path :
typeof input.file_path === "string" ? input.file_path :
typeof request.path === "string" ? request.path :
undefined;

const lines = [approvalLead(tool), ""];

lines.push("Operation:", tool, "");

if (command) lines.push("Command:", command, "");
else if (path) lines.push("File:", path, "");
lines.push("Allow this operation?");
if (path) lines.push("File:", path, "");

lines.push("Approve this Villani action?");
return lines.join("\n");
}
async function handleApproval(run: ActiveRun, ctx: any, e: any) {
Expand All @@ -111,8 +132,8 @@ async function handleApproval(run: ActiveRun, ctx: any, e: any) {
let approved = false;
const message = approvalMessage(e);
try {
await setStatus(ctx, "Waiting for approval...");
await setWidget(ctx, ["Pending approval", message]);
await setStatus(ctx, nextVillaniStatus("approval", requestId) ?? "Villani pauses for approval...");
await setWidget(ctx, ["Villaniclearance required", message]);
approved = await confirm(ctx, approvalTitle(e), message, {
signal: run.abort.signal,
});
Expand All @@ -128,7 +149,7 @@ async function handleApproval(run: ActiveRun, ctx: any, e: any) {
}
if (run.pending.get(requestId) !== false) return;
run.pending.set(requestId, true);
await setStatus(ctx, "Villani is thinking...");
await setStatus(ctx, nextVillaniStatus("thinking", "approval-resolved") ?? "Villanithoughts classified...");
run.bridge?.respondToApproval(run.id, requestId, approved);
if (process.env.VILLANI_PI_DEBUG === "1")
console.error("[pi-villani bridge] approval response sent");
Expand Down Expand Up @@ -232,7 +253,7 @@ export async function runVillani(
): Promise<void> {
resetVillaniUiState();
await notify(ctx, "Villani starting...", "info");
await setStatus(ctx, "Starting Villani...");
await setStatus(ctx, nextVillaniStatus("thinking", "startup") ?? "Villaniplan forming...");
if (!task?.trim()) {
await notify(ctx, "/villani requires a task argument", "warn");
return;
Expand Down Expand Up @@ -271,10 +292,10 @@ export async function runVillani(
let config: any;
let env: any;
if (usePi) {
await setStatus(ctx, "Preparing model connection...");
await setStatus(ctx, nextVillaniStatus("analysis", "model-connection") ?? "Villanalysis begins...");
model = await resolvePiModel(ctx);
const auth = await resolveModelAuth(ctx, model);
await setStatus(ctx, "Preparing model connection...");
await setStatus(ctx, nextVillaniStatus("analysis", "model-connection") ?? "Villanalysis begins...");
proxy = await startModelProxyFromPiModel({
model,
apiKey: auth.apiKey,
Expand All @@ -296,13 +317,13 @@ export async function runVillani(
config = envConfig();
env = process.env;
}
await setStatus(ctx, "Starting Villani...");
await setStatus(ctx, nextVillaniStatus("thinking", "runtime-start") ?? "Villaniplan forming...");
const executable = await resolveVillaniRuntime({ signal: abort.signal });
if (process.env.VILLANI_COMMAND) {
await setStatus(ctx, "Starting Villani...");
await setStatus(ctx, nextVillaniStatus("thinking", "runtime-start") ?? "Villaniplan forming...");
await assertBridgePing(executable, ctx, env, abort.signal);
}
await setStatus(ctx, "Starting Villani...");
await setStatus(ctx, nextVillaniStatus("thinking", "runtime-start") ?? "Villaniplan forming...");
const bridge = new VillaniBridgeProcess(executable, {
env,
startupTimeoutMs: 30000,
Expand All @@ -311,16 +332,19 @@ export async function runVillani(
cwd: ctx.cwd ?? process.cwd(),
});
run.bridge = bridge;
await setStatus(ctx, "Starting Villani...");
await setStatus(ctx, nextVillaniStatus("thinking", "runtime-start") ?? "Villaniplan forming...");
await bridge.waitUntilReady(undefined, abort.signal);
await setStatus(ctx, "Villani is thinking...");
await setStatus(ctx, nextVillaniStatus("thinking", "approval-resolved") ?? "Villanithoughts classified...");
const startPostToolTimer = (eventType: string) => {
clearPostToolTimer();
postToolTimer = setTimeout(async () => {
try {
bridge.send({ type: "ping", id: `${runId}-post-tool-ping` });
const pong = await bridge.waitForEvent("pong", 3000, abort.signal);
if (pong) await setStatus(ctx, "Villani is thinking...");
if (pong) {
const status = nextVillaniStatus("thinking", "post-tool-ping");
if (status) await setStatus(ctx, status);
}
else
await notify(
ctx,
Expand Down Expand Up @@ -419,7 +443,7 @@ export async function runVillani(
mode: "runner",
config,
});
await setStatus(ctx, "Villani is thinking...");
await setStatus(ctx, nextVillaniStatus("thinking", "approval-resolved") ?? "Villanithoughts classified...");
try {
await runStartedPromise;
} catch (e) {
Expand Down
63 changes: 62 additions & 1 deletion integrations/pi-villani/src/render.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
cleanAssistantText,
reduceVillaniUiState,
renderBridgeEvent,
nextVillaniStatus,
resetVillaniCopyCounters,
resetVillaniUiState,
toolStartedMessage,
Expand Down Expand Up @@ -93,8 +94,68 @@ test('cleanAssistantText trims without Villani prefix', () => {

test('approval message remains literal and readable', () => {
const request = { tool: 'Bash', summary: 'Run command', input: { command: 'python -m pytest -v' } };
assert.equal(approvalTitle(request), 'Villani wants to run a shell command');
assert.equal(approvalTitle(request), 'Villani requests command authority');
const msg = approvalMessage(request);
assert.match(msg, /Villani requests command authority\./);
assert.match(msg, /Command:\npython -m pytest -v/);
assert.match(msg, /Approve this Villani action\?/);
assert.doesNotMatch(msg, /Allow this operation\?/);
assert.doesNotMatch(msg, /\[object Object\]/);
});


test('repeated model_request_started only updates status once within 12 seconds', () => {
resetVillaniCopyCounters();
const first = reduceVillaniUiState({ phase: 'x' }, { type: 'model_request_started' });
const second = reduceVillaniUiState(first, { type: 'model_request_started' });
assert.equal(first.phase, 'Villani is make plan...');
assert.equal(second.phase, first.phase);
});

test('proxy_request_started does not spam new thinking copy when already thinking', () => {
resetVillaniCopyCounters();
const first = reduceVillaniUiState({ phase: 'x' }, { type: 'model_request_started' });
const second = reduceVillaniUiState(first, { type: 'proxy_request_started' });
assert.equal(second.phase, first.phase);
});

test('approval_required uses approval category copy and widget avoids pending approval', async () => {
resetVillaniCopyCounters();
const rec = ctxRecorder();
await renderBridgeEvent({ type: 'approval_required', request_id: 'r1', tool: 'Read', summary: 'Read' }, {}, rec.ctx);
assert.equal(rec.statuses.at(-1), 'Villani requires authorization...');
assert.doesNotMatch(JSON.stringify(rec.widgets), /Pending approval/);
assert.match(JSON.stringify(rec.widgets), /Villaniclearance required/);
});

test('approval titles use Villani-style copy for Read and Bash', () => {
assert.equal(approvalTitle({ tool: 'Read' }), 'Villani requests dossier access');
assert.equal(approvalTitle({ tool: 'Bash' }), 'Villani requests command authority');
});

test('approval message for Read without path is readable', () => {
const msg = approvalMessage({ tool: 'Read', input: {} });
assert.match(msg, /Villani requests dossier access\./);
assert.match(msg, /Operation:\nRead/);
assert.match(msg, /Approve this Villani action\?/);
assert.doesNotMatch(msg, /unknown|\[object Object\]|Allow this operation\?/i);
});

test('after approval_resolved status does not hardcode old thinking copy', () => {
resetVillaniCopyCounters();
const state = reduceVillaniUiState({ phase: 'approval' }, { type: 'approval_resolved' });
assert.notEqual(state.phase, 'Villani is thinking...');
});

test('villaniCopy approval rotates deterministically', () => {
resetVillaniCopyCounters();
assert.equal(villaniCopy('approval'), 'Villani requires authorization...');
assert.equal(villaniCopy('approval'), 'Villaniclearance required...');
});

test('nextVillaniStatus suppresses repeated status until detail changes', () => {
resetVillaniCopyCounters();
assert.equal(nextVillaniStatus('thinking', 'same'), 'Villani is make plan...');
assert.equal(nextVillaniStatus('thinking', 'same'), undefined);
assert.equal(nextVillaniStatus('thinking', 'different'), 'Villaniplan forming...');
});
Loading
Loading