fix(maestro): correct connection view race and approval decisions (#140)
Three fixes from acceptance review: - ConnectionPool.mount cached a connection's kind (terminal/structured) permanently from whatever `info` it saw first. A deep link or restored layout mounts before the catalog snapshot names a session's view, so `info` is undefined and a structured session got an xterm connection forever. The pool now tracks the view each cached connection was built for, subscribes to the catalog itself, and replaces a mistyped connection in place the moment the real view resolves — without downgrading a resolved connection just because a later caller couldn't resolve the session, and without treating a session merely absent from a snapshot (mid-removal) as "Terminal". - The approval card offered all three decisions unconditionally. Per the daemon's decision mapping (session.rs's permission_outcome), a decision with no matching PermissionOptionKind in the request's own options resolves Cancelled silently — so "Allow for session" could look like it worked while the daemon withdrew it. Buttons are now gated on the options actually offered. - Clicking a decision disabled the buttons and never re-enabled them if the POST failed. The decision path is async now: a rejection restores the buttons and surfaces the error only if the approval is still the pending one — a superseded/resolved approval's stale rejection is a no-op, not a wrongly revived control. Regression tests: 12 new (connections.test.ts, structured.test.ts, structuredRender.test.ts). 488 tests total.
This commit is contained in:
@@ -160,29 +160,41 @@ describe("buildTranscriptRow: tool diffs", () => {
|
||||
});
|
||||
|
||||
describe("buildTranscriptRow: approval", () => {
|
||||
const approvalRow = () =>
|
||||
const approvalRow = (options: Array<{ optionId: string; name: string; kind: string }>) =>
|
||||
row("approval", {
|
||||
sessionId: "s1",
|
||||
toolCall: { toolCallId: "t1", title: "Write outside worktree", locations: [{ path: "/etc/x" }] },
|
||||
options: [],
|
||||
options,
|
||||
});
|
||||
|
||||
it("shows the three decision buttons only when it is the pending approval", () => {
|
||||
const el = buildTranscriptRow(approvalRow(), { pending: true, onDecide: () => {} });
|
||||
const allThreeOptions = [
|
||||
{ optionId: "a1", name: "Allow once", kind: "allow_once" },
|
||||
{ optionId: "a2", name: "Allow always", kind: "allow_always" },
|
||||
{ optionId: "r1", name: "Reject once", kind: "reject_once" },
|
||||
];
|
||||
|
||||
it("shows the three decision buttons only when the request offers all three option kinds", () => {
|
||||
const el = buildTranscriptRow(approvalRow(allThreeOptions), {
|
||||
pending: true,
|
||||
onDecide: () => Promise.resolve(),
|
||||
});
|
||||
const buttons = el.querySelectorAll(".st-approval-acts button");
|
||||
expect(buttons).toHaveLength(3);
|
||||
expect(el.querySelector(".st-approval-path")!.textContent).toBe("/etc/x");
|
||||
});
|
||||
|
||||
it("hides the buttons and shows an answered note once resolved", () => {
|
||||
const el = buildTranscriptRow(approvalRow(), { pending: false, onDecide: () => {} });
|
||||
const el = buildTranscriptRow(approvalRow(allThreeOptions), {
|
||||
pending: false,
|
||||
onDecide: () => Promise.resolve(),
|
||||
});
|
||||
expect(el.querySelector(".st-approval-acts")).toBeNull();
|
||||
expect(el.querySelector(".st-approval-resolved")!.textContent).toBe("answered");
|
||||
});
|
||||
|
||||
it("invokes onDecide with the clicked decision and disables the buttons", () => {
|
||||
const onDecide = vi.fn();
|
||||
const el = buildTranscriptRow(approvalRow(), { pending: true, onDecide });
|
||||
const onDecide = vi.fn().mockResolvedValue(undefined);
|
||||
const el = buildTranscriptRow(approvalRow(allThreeOptions), { pending: true, onDecide });
|
||||
const buttons = el.querySelectorAll<HTMLButtonElement>(".st-approval-acts button");
|
||||
buttons[2].click();
|
||||
expect(onDecide).toHaveBeenCalledWith("deny");
|
||||
@@ -190,9 +202,65 @@ describe("buildTranscriptRow: approval", () => {
|
||||
});
|
||||
|
||||
it("treats a row with no approval state as not pending, defensively", () => {
|
||||
const el = buildTranscriptRow(approvalRow());
|
||||
const el = buildTranscriptRow(approvalRow(allThreeOptions));
|
||||
expect(el.querySelector(".st-approval-acts")).toBeNull();
|
||||
});
|
||||
|
||||
it("omits Allow for session when the request offers no allow_always option", () => {
|
||||
const el = buildTranscriptRow(
|
||||
approvalRow([
|
||||
{ optionId: "a1", name: "Allow once", kind: "allow_once" },
|
||||
{ optionId: "r1", name: "Reject once", kind: "reject_once" },
|
||||
]),
|
||||
{ pending: true, onDecide: () => Promise.resolve() },
|
||||
);
|
||||
const labels = [...el.querySelectorAll(".st-approval-acts button")].map((b) => b.textContent);
|
||||
expect(labels).toEqual(["Allow once", "Deny"]);
|
||||
});
|
||||
|
||||
it("offers Deny for either reject_once or reject_always, never for neither", () => {
|
||||
const rejectAlwaysOnly = buildTranscriptRow(
|
||||
approvalRow([{ optionId: "r2", name: "Reject always", kind: "reject_always" }]),
|
||||
{ pending: true, onDecide: () => Promise.resolve() },
|
||||
);
|
||||
expect([...rejectAlwaysOnly.querySelectorAll(".st-approval-acts button")].map((b) => b.textContent)).toEqual([
|
||||
"Deny",
|
||||
]);
|
||||
});
|
||||
|
||||
it("shows a 'no decision offered' note instead of an empty action row when nothing matches", () => {
|
||||
const el = buildTranscriptRow(approvalRow([{ optionId: "x", name: "Something else", kind: "allow_once" }]), {
|
||||
pending: true,
|
||||
onDecide: () => Promise.resolve(),
|
||||
});
|
||||
// allow_once is offered, so this specific case still shows a button —
|
||||
// use a kind the client doesn't map to any decision at all.
|
||||
expect(el.querySelectorAll(".st-approval-acts button")).toHaveLength(1);
|
||||
|
||||
const none = buildTranscriptRow(approvalRow([]), { pending: true, onDecide: () => Promise.resolve() });
|
||||
expect(none.querySelector(".st-approval-acts")).toBeNull();
|
||||
expect(none.querySelector(".st-approval-resolved")!.textContent).toBe("no decision offered");
|
||||
});
|
||||
|
||||
it("re-enables the buttons when onDecide rejects", async () => {
|
||||
const onDecide = vi.fn().mockRejectedValue(new Error("network down"));
|
||||
const el = buildTranscriptRow(approvalRow(allThreeOptions), { pending: true, onDecide });
|
||||
const buttons = el.querySelectorAll<HTMLButtonElement>(".st-approval-acts button");
|
||||
buttons[0].click();
|
||||
expect(buttons[0].disabled).toBe(true);
|
||||
await Promise.resolve().then(() => Promise.resolve());
|
||||
expect(buttons[0].disabled).toBe(false);
|
||||
expect(buttons[1].disabled).toBe(false);
|
||||
});
|
||||
|
||||
it("leaves the buttons disabled when onDecide resolves", async () => {
|
||||
const onDecide = vi.fn().mockResolvedValue(undefined);
|
||||
const el = buildTranscriptRow(approvalRow(allThreeOptions), { pending: true, onDecide });
|
||||
const buttons = el.querySelectorAll<HTMLButtonElement>(".st-approval-acts button");
|
||||
buttons[0].click();
|
||||
await Promise.resolve().then(() => Promise.resolve());
|
||||
expect(buttons[0].disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildTranscriptRow: notice / unknown kinds", () => {
|
||||
|
||||
Reference in New Issue
Block a user