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:
@@ -45,6 +45,12 @@ function row(id: string, kind: TranscriptRow["kind"], data: unknown): Transcript
|
||||
|
||||
const idleState: ReducedState = { turn: { responding: false, thinking: false, active_tool_calls: [] } };
|
||||
|
||||
const allApprovalOptions = [
|
||||
{ optionId: "a1", name: "Allow once", kind: "allow_once" as const },
|
||||
{ optionId: "a2", name: "Allow always", kind: "allow_always" as const },
|
||||
{ optionId: "r1", name: "Reject once", kind: "reject_once" as const },
|
||||
];
|
||||
|
||||
function mount(opts: {
|
||||
sockets: FakeSocket[];
|
||||
shouldReconnect?: (id: string, code: number) => boolean;
|
||||
@@ -175,9 +181,9 @@ describe("structured connection: approvals", () => {
|
||||
type: "projection_update",
|
||||
transcript: {
|
||||
type: "append",
|
||||
row: row("approval:1", "approval", { toolCall: { title: "Write x" }, options: [] }),
|
||||
row: row("approval:1", "approval", { toolCall: { title: "Write x" }, options: allApprovalOptions }),
|
||||
},
|
||||
state: { ...idleState, pending_approval: { nonce: "n1", request: { sessionId: "s1", toolCall: {}, options: [] } } },
|
||||
state: { ...idleState, pending_approval: { nonce: "n1", request: { sessionId: "s1", toolCall: {}, options: allApprovalOptions } } },
|
||||
});
|
||||
const buttons = conn.el.querySelectorAll<HTMLButtonElement>(".st-approval-acts button");
|
||||
expect(buttons).toHaveLength(3);
|
||||
@@ -192,11 +198,11 @@ describe("structured connection: approvals", () => {
|
||||
ws.open();
|
||||
ws.emit({
|
||||
type: "transcript_snapshot",
|
||||
snapshot: { cursor: 0, rows: [row("approval:1", "approval", { toolCall: { title: "Write x" }, options: [] })] },
|
||||
snapshot: { cursor: 0, rows: [row("approval:1", "approval", { toolCall: { title: "Write x" }, options: allApprovalOptions })] },
|
||||
});
|
||||
ws.emit({
|
||||
type: "reduced_state",
|
||||
state: { ...idleState, pending_approval: { nonce: "n1", request: { sessionId: "s1", toolCall: {}, options: [] } } },
|
||||
state: { ...idleState, pending_approval: { nonce: "n1", request: { sessionId: "s1", toolCall: {}, options: allApprovalOptions } } },
|
||||
});
|
||||
expect(conn.el.querySelectorAll(".st-approval-acts button")).toHaveLength(3);
|
||||
// Resolved on another client: state clears with no transcript delta at all.
|
||||
@@ -212,11 +218,11 @@ describe("structured connection: approvals", () => {
|
||||
ws.open();
|
||||
ws.emit({
|
||||
type: "transcript_snapshot",
|
||||
snapshot: { cursor: 0, rows: [row("approval:1", "approval", { toolCall: {}, options: [] })] },
|
||||
snapshot: { cursor: 0, rows: [row("approval:1", "approval", { toolCall: {}, options: allApprovalOptions })] },
|
||||
});
|
||||
ws.emit({
|
||||
type: "reduced_state",
|
||||
state: { ...idleState, pending_approval: { nonce: "n1", request: { sessionId: "s1", toolCall: {}, options: [] } } },
|
||||
state: { ...idleState, pending_approval: { nonce: "n1", request: { sessionId: "s1", toolCall: {}, options: allApprovalOptions } } },
|
||||
});
|
||||
const buttons = conn.el.querySelectorAll<HTMLButtonElement>(".st-approval-acts button");
|
||||
// Approval is superseded before the click is handled (buttons already
|
||||
@@ -227,6 +233,65 @@ describe("structured connection: approvals", () => {
|
||||
expect(resolveStructuredApproval).not.toHaveBeenCalled();
|
||||
conn.dispose();
|
||||
});
|
||||
|
||||
it("restores the buttons and surfaces an error when the POST rejects while still pending", async () => {
|
||||
vi.mocked(resolveStructuredApproval).mockRejectedValueOnce(new Error("network down"));
|
||||
const ws = new FakeSocket();
|
||||
const conn = mount({ sockets: [ws] });
|
||||
ws.open();
|
||||
ws.emit({
|
||||
type: "transcript_snapshot",
|
||||
snapshot: { cursor: 0, rows: [row("approval:1", "approval", { toolCall: {}, options: allApprovalOptions })] },
|
||||
});
|
||||
ws.emit({
|
||||
type: "reduced_state",
|
||||
state: { ...idleState, pending_approval: { nonce: "n1", request: { sessionId: "s1", toolCall: {}, options: allApprovalOptions } } },
|
||||
});
|
||||
const buttons = conn.el.querySelectorAll<HTMLButtonElement>(".st-approval-acts button");
|
||||
buttons[0].click();
|
||||
expect(buttons[0].disabled).toBe(true);
|
||||
await Promise.resolve().then(() => Promise.resolve()).then(() => Promise.resolve());
|
||||
expect(buttons[0].disabled).toBe(false);
|
||||
expect(buttons[1].disabled).toBe(false);
|
||||
expect(conn.el.textContent).toContain("network down");
|
||||
conn.dispose();
|
||||
});
|
||||
|
||||
it("does not resurrect buttons or show an error for a decision the approval already moved past", async () => {
|
||||
let reject!: (e: unknown) => void;
|
||||
vi.mocked(resolveStructuredApproval).mockImplementationOnce(
|
||||
() => new Promise((_, r) => { reject = r; }),
|
||||
);
|
||||
const ws = new FakeSocket();
|
||||
const conn = mount({ sockets: [ws] });
|
||||
ws.open();
|
||||
ws.emit({
|
||||
type: "transcript_snapshot",
|
||||
snapshot: { cursor: 0, rows: [row("approval:1", "approval", { toolCall: {}, options: allApprovalOptions })] },
|
||||
});
|
||||
ws.emit({
|
||||
type: "reduced_state",
|
||||
state: { ...idleState, pending_approval: { nonce: "n1", request: { sessionId: "s1", toolCall: {}, options: allApprovalOptions } } },
|
||||
});
|
||||
const buttons = conn.el.querySelectorAll<HTMLButtonElement>(".st-approval-acts button");
|
||||
buttons[0].click();
|
||||
expect(resolveStructuredApproval).toHaveBeenCalledWith("s1", "n1", "allow_once");
|
||||
|
||||
// Superseded by a new approval before the in-flight POST settles.
|
||||
ws.emit({
|
||||
type: "projection_update",
|
||||
transcript: { type: "append", row: row("approval:2", "approval", { toolCall: {}, options: allApprovalOptions }) },
|
||||
state: { ...idleState, pending_approval: { nonce: "n2", request: { sessionId: "s1", toolCall: {}, options: allApprovalOptions } } },
|
||||
});
|
||||
reject(new Error("too late"));
|
||||
await Promise.resolve().then(() => Promise.resolve()).then(() => Promise.resolve());
|
||||
|
||||
expect(conn.el.textContent).not.toContain("too late");
|
||||
const newButtons = conn.el.querySelectorAll<HTMLButtonElement>(".st-approval-acts button");
|
||||
expect(newButtons).toHaveLength(3);
|
||||
expect(newButtons[0].disabled).toBe(false);
|
||||
conn.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
describe("structured connection: reconnection/error/loading", () => {
|
||||
|
||||
Reference in New Issue
Block a user