Skip to content
Closed
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
16 changes: 13 additions & 3 deletions src/permission/gate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ import { NOOP_TELEMETRY, type Telemetry } from "../telemetry/index.js";
import {
NOOP_APPROVAL_LOG,
type ApprovalLog,
type ApprovalMode,
type ApprovalOutcomeKind,
} from "./approval-log.js";

Expand All @@ -82,12 +83,15 @@ function finishApprovalWait(
waitSpanId: string,
tool: string,
outcome: ApprovalOutcome | undefined,
permissionMode: ApprovalMode,
): void {
const decision = outcome !== undefined && outcome.allow ? "allow" : "deny";
end(waitSpanId, outcome !== undefined ? { decision } : undefined);
const decision =
outcome === undefined ? "abandoned" : outcome.allow ? "allow" : "deny";
end(waitSpanId, { decision });
telemetry.capture("permission_prompt", {
decision,
permission_kind: classifyPermissionKind(tool),
permission_mode: permissionMode,
});
}

Expand Down Expand Up @@ -1089,7 +1093,13 @@ export function createPermissionGate(
try {
outcome = await prompt(request);
} finally {
finishApprovalWait(telemetry, waitSpanId, request.tool, outcome);
finishApprovalWait(
telemetry,
waitSpanId,
request.tool,
outcome,
"interactive",
);
ask.settle(classifyOutcome(outcome));
}
if (
Expand Down
2 changes: 1 addition & 1 deletion src/telemetry/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,7 @@ const EVENT_PROPERTY_ALLOWLIST: Record<TelemetryEvent, readonly string[]> = {
"parent_trace_id",
],

permission_prompt: ["decision", "permission_kind"],
permission_prompt: ["decision", "permission_kind", "permission_mode"],
compaction: ["mode", "duration_ms", "turns_before", "turns_after"],
// provider/model are the canonical runtime ids (same trust class as
// $ai_provider/$ai_model); error_kind is the summarizer's first-party
Expand Down
9 changes: 4 additions & 5 deletions src/tui/gate-wire.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -550,7 +550,7 @@ describe("wireGates", () => {
).toEqual(["Reject", "Accept once", "Allow git A"]);

closeInsetOverlay(shell);
expect(resolvedA).toEqual({ allow: false });
expect(resolvedA).toBeUndefined();
expect(shell.overlayList).toBeNull();

emitter.emit("permission.gate", {
Expand Down Expand Up @@ -621,7 +621,7 @@ describe("wireGates", () => {
},
});
closeInsetOverlay(shell);
expect(resolvedA).toEqual({ allow: false });
expect(resolvedA).toBeUndefined();
expect(shell.overlayList).toBeNull();

emitter.emit("permission.gate", {
Expand Down Expand Up @@ -1674,7 +1674,7 @@ describe("operator.gate auto-cancel", () => {
});

describe("Esc on a gate overlay settles the awaited promise", () => {
test("permission.gate: Esc denies instead of abandoning the promise", async () => {
test("permission.gate: Esc abandons instead of denying", async () => {
await withTestRenderer(async (h) => {
const shell = createAppShell(h.renderer, {
terminal: { columns: 80, rows: 24 },
Expand All @@ -1699,8 +1699,7 @@ describe("Esc on a gate overlay settles the awaited promise", () => {

expect(shell.overlayList).toBeNull();
expect(resolveCount).toBe(1);
expect(resolved).toEqual({ allow: false });
expect(resolved).not.toEqual(unavailable);
expect(resolved).toBeUndefined();
} finally {
shell.dispose();
}
Expand Down
22 changes: 11 additions & 11 deletions src/tui/gate-wire.ts
Original file line number Diff line number Diff line change
Expand Up @@ -378,8 +378,13 @@ export function wireGates(
// closeInsetOverlay, which fires onCancel after notifying close
// listeners) — settle's return value is how a call site tells that
// reentrant call apart from the original one.
const settle = (outcome: ApprovalOutcome): boolean =>
permissionQueue.settle(id, outcome);
const settle = (outcome: ApprovalOutcome | undefined): boolean =>
// An undefined outcome is an abandoned prompt (Esc): it flows through
// the queue to the gate, which records an "abandoned" telemetry
// decision and still settles the action as denied. The cast contains
// the widening to this seam — the queue and gate-event types are
// untouched.
permissionQueue.settle(id, outcome as ApprovalOutcome);
const id = permissionQueue.enqueue(ev.request, (outcome) => {
clearTimers();
if (openedGeneration === undefined) {
Expand Down Expand Up @@ -437,16 +442,11 @@ export function wireGates(
};
settle(approvalOutcomeFromSelection(choices, gateSelection));
},
// Esc must settle the awaited promise (as a deny), not abandon it —
// an unresolved gate hangs the run until the process is killed.
// Esc settles the awaited promise as abandoned, not as a deny — an
// unresolved gate hangs the run until the process is killed, so the
// gate must still settle either way.
onCancel: () => {
const denyId = choices.itemIds[0];
settle(
approvalOutcomeFromSelection(choices, {
index: 0,
...(denyId !== undefined ? { id: denyId } : {}),
}),
);
settle(undefined);
},
isGate: true,
});
Expand Down
9 changes: 5 additions & 4 deletions src/tui/runner/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -348,15 +348,16 @@ export function createCommandLayer(
};

const dispatchCommand = (name: string, args: string): void => {
// Captured before the unknown-command early return so plugin-authored
// names are bucketed (never sent raw) — the classifier maps anything it
// doesn't recognise to custom. Shared emitter so TUI and any headless
// path report the same event; known commands still emit exactly once.
captureSlashCommand(getTelemetry(), name);
const command = getCommand(name);
if (command === undefined) {
state.systemNotice?.(`Unknown command: ${name}`);
return;
}
// Plugins register into the same command registry as the built-ins, so an
// unrecognised name is plugin-authored and is bucketed rather than sent.
// Shared emitter so TUI and any headless path report the same event.
captureSlashCommand(getTelemetry(), command.name);
applyCommandResult(command.handler(args, commandContext));
};
state.dispatchCommand = dispatchCommand;
Expand Down
8 changes: 4 additions & 4 deletions src/tui/slash-popup-gate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -438,7 +438,7 @@ describe("slash/palette accept holds the host until dispatch settles", () => {
closeInsetOverlay(shell);
await Promise.resolve();
expect(shell.overlayKind).toBe("help");
expect(resolved).toEqual({ allow: false });
expect(resolved).toBeUndefined();
} finally {
dispose();
}
Expand Down Expand Up @@ -657,7 +657,7 @@ describe("slash/palette accept holds the host until dispatch settles", () => {
closeInsetOverlay(shell);
await Promise.resolve();
expect(shell.overlayKind).toBe("settings");
expect(resolved).toEqual({ allow: false });
expect(resolved).toBeUndefined();
} finally {
dispose();
}
Expand Down Expand Up @@ -720,7 +720,7 @@ describe("slash/palette accept holds the host until dispatch settles", () => {
closeInsetOverlay(shell);
await Promise.resolve();
expect(shell.overlayKind).toBe("mcp");
expect(resolved).toEqual({ allow: false });
expect(resolved).toBeUndefined();
} finally {
dispose();
}
Expand Down Expand Up @@ -1162,7 +1162,7 @@ describe("overlay host occupancy and opt-in deferral", () => {
closeInsetOverlay(shell);
await Promise.resolve();
expect(shell.overlayKind).toBe("add_provider");
expect(resolved).toEqual({ allow: false });
expect(resolved).toBeUndefined();
} finally {
dispose();
}
Expand Down
118 changes: 118 additions & 0 deletions tests/unit/telemetry-product-events.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ import {
NOOP_TELEMETRY,
type Telemetry,
} from "../../src/telemetry/index.js";
import { setTelemetry } from "../../src/telemetry/singleton.js";
import { registerCommand } from "../../src/tui/commands/registry.js";
import { createCommandLayer } from "../../src/tui/runner/commands.js";
import {
buildSubagentEndProperties,
captureSkillUsed,
Expand Down Expand Up @@ -755,3 +758,118 @@ test("keys outside an event's allowlist are stripped from the payload", async ()
expect(event?.properties.command).toBeUndefined();
expect(await wire()).not.toContain("acmecorp");
});

// ---------------------------------------------------------------------------
// 6. permission abandoned + permission_mode (CL-9013)
// ---------------------------------------------------------------------------

test('permission_prompt records "abandoned" when Esc settles the prompt with no outcome', async () => {
const { telemetry, wire, events } = harness();
const gate = createPermissionGate({
approvals: [],
interactive: true,
skipPermissions: false,
reactorGated: false,
// gate-wire.ts onCancel settles the approval promise with undefined when
// the operator presses Esc — no synthetic deny is manufactured there.
requestApproval: async () => undefined as never,
telemetry,
});

const verdict = await gate.evaluate({
id: "call-1",
name: "mcp__acme-internal__deploy",
arguments: {},
});

// Liveness: the gate still settles — the action is denied, never hung.
expect(verdict.allowed).toBe(false);
const [event] = await events();
expect(event?.event).toBe("permission_prompt");
expect(event?.properties.decision).toBe("abandoned");
expect(event?.properties.permission_kind).toBe("mcp");
expect(event?.properties.permission_mode).toBe("interactive");
expect(await wire()).not.toContain("acme-internal");
});

test("permission_prompt carries permission_mode on allow and deny", async () => {
for (const allow of [true, false] as const) {
const { telemetry, wire, events } = harness();
const gate = createPermissionGate({
approvals: [],
interactive: true,
skipPermissions: false,
reactorGated: false,
requestApproval: async () => ({ allow }),
telemetry,
});

await gate.evaluate({
id: "call-1",
name: "mcp__acme-internal__deploy",
arguments: {},
});

const [event] = await events();
expect(event?.properties.decision).toBe(allow ? "allow" : "deny");
expect(event?.properties.permission_mode).toBe("interactive");
expect(await wire()).not.toContain("acme-internal");
}
});

// ---------------------------------------------------------------------------
// 7. unknown slash commands (CL-9013)
// ---------------------------------------------------------------------------

test("dispatching an unknown slash command emits exactly one custom event", async () => {
const { telemetry, wire, events } = harness();
setTelemetry(telemetry);
try {
const notices: string[] = [];
const state = {
systemNotice: (text: string) => notices.push(text),
} as never;
createCommandLayer(state, {} as never);
const dispatch = (
state as unknown as {
dispatchCommand: (name: string, args: string) => void;
}
).dispatchCommand;

dispatch("acmecorp-deploy-thing", "");

expect(notices).toHaveLength(1);
const captured = await events();
expect(captured.filter((e) => e.event === "slash_command")).toHaveLength(1);
expect(captured[0]?.properties.command_name).toBe("custom");
expect(await wire()).not.toContain("acmecorp");
} finally {
setTelemetry(NOOP_TELEMETRY);
}
});

test("dispatching a known slash command emits exactly once", async () => {
const { telemetry, events } = harness();
setTelemetry(telemetry);
try {
registerCommand({
name: "cl9013-known-probe",
description: "CL-9013 exactly-once probe",
handler: () => ({ type: "noop" }),
});
const state = {} as never;
createCommandLayer(state, {} as never);
const dispatch = (
state as unknown as {
dispatchCommand: (name: string, args: string) => void;
}
).dispatchCommand;

dispatch("cl9013-known-probe", "");

const captured = await events();
expect(captured.filter((e) => e.event === "slash_command")).toHaveLength(1);
} finally {
setTelemetry(NOOP_TELEMETRY);
}
});
Loading