Skip to content

Commit 0f31491

Browse files
committed
fix(approval): discard expired decisions instead of ordinary input
1 parent dc29b9b commit 0f31491

7 files changed

Lines changed: 703 additions & 49 deletions

File tree

‎docs/ARCHITECTURE.md‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,21 @@ The reactor (from `@intx/agent`) drives a single agent turn-by-turn. Each turn i
1414

1515
This repeats until the director emits `capabilities.done()`.
1616

17+
Explicit approval messages use `headers.interchangeType` (`approval.granted` or
18+
`approval.denied`) as intent, not authentication. The reactor accepts one only
19+
while its exact pending operation and matching gate remain live, before both
20+
epoch deadlines (equality expires), with a body agreeing with the header. Typed
21+
messages without an eligible suspended call, expired/consumed decisions and
22+
concurrent duplicates are discarded before body parsing, never ordinary input.
23+
Validator false/throw also discards typed decisions but leaves a still-live
24+
operation retryable; untyped and nonapproval response routing is unchanged.
25+
After validation and synchronous grant callbacks, the reactor rechecks ownership,
26+
deadlines and lifecycle, then consumes the operation and enqueues its continuation
27+
before publishing `message.correlated`. The claim is released on every settled
28+
exit; a never-settling validator retains it. Extension side effects are not rolled
29+
back. Discards produce no acceptance receipt: the TUI's existing bounded 30-second
30+
acceptance wait remains unchanged.
31+
1732
### Reactor Events (Partial)
1833

1934
This table is not the full reactor/stream event vocabulary. `tool.done` is

‎src/session/approval-resume.test.ts‎

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
createApprovalResume,
1616
resolveParkedCallIdFromStore,
1717
} from "./approval-resume.js";
18+
import { createSessionOperationQueue } from "../tui/delivery-queue.js";
1819

1920
function assistantTurn(
2021
calls: { id: string; name: string; command: string }[],
@@ -106,6 +107,59 @@ function deliveredCorrelationId(message: InboundMessage): string {
106107
return correlationId;
107108
}
108109

110+
describe("approval decision intent headers", () => {
111+
for (const allow of [true, false]) {
112+
test(`preserves ${allow ? "granted" : "denied"} intent and correlation through the session queue`, async () => {
113+
const delivered: InboundMessage[] = [];
114+
const queue = createSessionOperationQueue();
115+
let release!: () => void;
116+
const held = new Promise<void>((resolve) => {
117+
release = resolve;
118+
});
119+
queue.enqueue(() => held);
120+
const agent = {
121+
history: async () => [],
122+
deliver: (message: InboundMessage) => {
123+
delivered.push(message);
124+
},
125+
};
126+
const gate = {
127+
resolveSuspended: async () => ({ allow, message: "decision reason" }),
128+
} as unknown as PermissionGate;
129+
const resume = createApprovalResume({
130+
getAgent: () => agent,
131+
gate,
132+
resolveParkedCallId: () => "typed-call",
133+
deliver: (message) =>
134+
queue.enqueue(async () => {
135+
agent.deliver(message);
136+
}),
137+
});
138+
const handling = resume.handle(
139+
suspension("typed-correlation", "echo alpha"),
140+
);
141+
expect(delivered).toHaveLength(0);
142+
release();
143+
expect(await handling).toBe(true);
144+
await queue.awaitTail();
145+
expect(delivered).toHaveLength(1);
146+
const message = delivered[0];
147+
if (message === undefined) throw new Error("expected decision");
148+
expect(message.headers.interchangeType).toBe(
149+
allow ? "approval.granted" : "approval.denied",
150+
);
151+
expect(deliveredCorrelationId(message)).toBe("typed-correlation");
152+
expect(decisionBody(message).outcome).toBe(
153+
allow ? "approved" : "rejected",
154+
);
155+
if (!allow)
156+
expect(JSON.parse(message.content ?? "").message).toBe(
157+
"decision reason",
158+
);
159+
});
160+
}
161+
});
162+
109163
describe("approval-resume parallel-parked approvals", () => {
110164
test("delivers A's decision when a different parked call's approval times out", async () => {
111165
const { resume, delivered } = setup({

‎src/session/approval-resume.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ function decisionMessage(
106106
correlationId: string,
107107
outcome: "approved" | "rejected",
108108
message?: string,
109-
) {
109+
): InboundMessage {
110110
const body: { outcome: "approved" | "rejected"; message?: string } = {
111111
outcome,
112112
};
@@ -119,6 +119,8 @@ function decisionMessage(
119119
date: new Date().toISOString(),
120120
messageId: `approval-${correlationId}`,
121121
interchangeCorrelationId: correlationId,
122+
interchangeType:
123+
outcome === "approved" ? "approval.granted" : "approval.denied",
122124
},
123125
flags: [],
124126
content: JSON.stringify(body),

0 commit comments

Comments
 (0)