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:
+46
-11
@@ -7,6 +7,7 @@ import { escapeHtml } from "./html.js";
|
||||
import { computeDiffLines, diffStats } from "./structuredDiff.js";
|
||||
import type {
|
||||
AcpContentBlock,
|
||||
AcpPermissionOption,
|
||||
AcpPlan,
|
||||
AcpToolCallContent,
|
||||
AcpToolCallLike,
|
||||
@@ -271,11 +272,29 @@ function buildPlainRow(row: TranscriptRow): HTMLElement {
|
||||
|
||||
export interface ApprovalRowState {
|
||||
pending: boolean;
|
||||
onDecide: (decision: StructuredApprovalDecision) => void;
|
||||
/** Resolves once the decision has been submitted; rejects when the POST
|
||||
* failed and this approval is still the one awaiting a decision, so the
|
||||
* caller knows to restore its controls. Never rejects for a stale click
|
||||
* (the approval already moved on) — there is nothing to restore then. */
|
||||
onDecide: (decision: StructuredApprovalDecision) => Promise<void>;
|
||||
}
|
||||
|
||||
/** Which decisions a permission request actually backs, by the
|
||||
* `PermissionOptionKind` values it offers (docs/STRUCTURED-VIEW.md's
|
||||
* "Decision mapping"). A decision with no matching option resolves
|
||||
* `Cancelled` server-side regardless of what the client sent — offering its
|
||||
* button would let the operator "allow" something that silently no-ops. */
|
||||
function offeredDecisions(options: AcpPermissionOption[]): StructuredApprovalDecision[] {
|
||||
const kinds = new Set(options.map((o) => o.kind));
|
||||
const decisions: StructuredApprovalDecision[] = [];
|
||||
if (kinds.has("allow_once")) decisions.push("allow_once");
|
||||
if (kinds.has("allow_always")) decisions.push("allow_for_session");
|
||||
if (kinds.has("reject_once") || kinds.has("reject_always")) decisions.push("deny");
|
||||
return decisions;
|
||||
}
|
||||
|
||||
function buildApprovalRow(row: TranscriptRow, approval: ApprovalRowState): HTMLElement {
|
||||
const request = (row.data ?? {}) as { toolCall?: AcpToolCallLike };
|
||||
const request = (row.data ?? {}) as { toolCall?: AcpToolCallLike; options?: AcpPermissionOption[] };
|
||||
const toolCall = request.toolCall ?? {};
|
||||
const wrap = el("div", "st-row st-row-approval");
|
||||
const card = el("div", "st-approval");
|
||||
@@ -304,17 +323,33 @@ function buildApprovalRow(row: TranscriptRow, approval: ApprovalRowState): HTMLE
|
||||
btn.className = cls ? `act ${cls}` : "act";
|
||||
btn.textContent = label;
|
||||
btn.addEventListener("click", () => {
|
||||
acts.querySelectorAll("button").forEach((b) => (b.disabled = true));
|
||||
approval.onDecide(decision);
|
||||
const buttons = [...acts.querySelectorAll<HTMLButtonElement>("button")];
|
||||
buttons.forEach((b) => (b.disabled = true));
|
||||
approval.onDecide(decision).catch(() => {
|
||||
// Rejects only when this approval is still pending (see
|
||||
// ApprovalRowState) — restore the controls so the operator can
|
||||
// retry; the error itself is surfaced by the connection.
|
||||
buttons.forEach((b) => (b.disabled = false));
|
||||
});
|
||||
});
|
||||
return btn;
|
||||
};
|
||||
acts.append(
|
||||
mk("Allow once", "allow_once", "primary"),
|
||||
mk("Allow for session", "allow_for_session", ""),
|
||||
mk("Deny", "deny", "deny"),
|
||||
);
|
||||
card.appendChild(acts);
|
||||
const offered = offeredDecisions(request.options ?? []);
|
||||
const specs: [StructuredApprovalDecision, string, string][] = [
|
||||
["allow_once", "Allow once", "primary"],
|
||||
["allow_for_session", "Allow for session", ""],
|
||||
["deny", "Deny", "deny"],
|
||||
];
|
||||
for (const [decision, label, cls] of specs) {
|
||||
if (offered.includes(decision)) acts.appendChild(mk(label, decision, cls));
|
||||
}
|
||||
if (acts.children.length > 0) {
|
||||
card.appendChild(acts);
|
||||
} else {
|
||||
const none = el("div", "st-approval-resolved");
|
||||
none.textContent = "no decision offered";
|
||||
card.appendChild(none);
|
||||
}
|
||||
} else {
|
||||
const resolved = el("div", "st-approval-resolved");
|
||||
resolved.textContent = "answered";
|
||||
@@ -343,7 +378,7 @@ export function buildTranscriptRow(row: TranscriptRow, approval?: ApprovalRowSta
|
||||
built = buildToolRow(row);
|
||||
break;
|
||||
case "approval":
|
||||
built = buildApprovalRow(row, approval ?? { pending: false, onDecide: () => {} });
|
||||
built = buildApprovalRow(row, approval ?? { pending: false, onDecide: () => Promise.resolve() });
|
||||
break;
|
||||
default:
|
||||
built = buildPlainRow(row);
|
||||
|
||||
Reference in New Issue
Block a user