Skip to content
Merged
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
15 changes: 15 additions & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,21 @@ The reactor (from `@intx/agent`) drives a single agent turn-by-turn. Each turn i

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

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

### Reactor Events (Partial)

This table is not the full reactor/stream event vocabulary. `tool.done` is
Expand Down
54 changes: 54 additions & 0 deletions src/session/approval-resume.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
createApprovalResume,
resolveParkedCallIdFromStore,
} from "./approval-resume.js";
import { createSessionOperationQueue } from "../tui/delivery-queue.js";

function assistantTurn(
calls: { id: string; name: string; command: string }[],
Expand Down Expand Up @@ -106,6 +107,59 @@ function deliveredCorrelationId(message: InboundMessage): string {
return correlationId;
}

describe("approval decision intent headers", () => {
for (const allow of [true, false]) {
test(`preserves ${allow ? "granted" : "denied"} intent and correlation through the session queue`, async () => {
const delivered: InboundMessage[] = [];
const queue = createSessionOperationQueue();
let release!: () => void;
const held = new Promise<void>((resolve) => {
release = resolve;
});
queue.enqueue(() => held);
const agent = {
history: async () => [],
deliver: (message: InboundMessage) => {
delivered.push(message);
},
};
const gate = {
resolveSuspended: async () => ({ allow, message: "decision reason" }),
} as unknown as PermissionGate;
const resume = createApprovalResume({
getAgent: () => agent,
gate,
resolveParkedCallId: () => "typed-call",
deliver: (message) =>
queue.enqueue(async () => {
agent.deliver(message);
}),
});
const handling = resume.handle(
suspension("typed-correlation", "echo alpha"),
);
expect(delivered).toHaveLength(0);
release();
expect(await handling).toBe(true);
await queue.awaitTail();
expect(delivered).toHaveLength(1);
const message = delivered[0];
if (message === undefined) throw new Error("expected decision");
expect(message.headers.interchangeType).toBe(
allow ? "approval.granted" : "approval.denied",
);
expect(deliveredCorrelationId(message)).toBe("typed-correlation");
expect(decisionBody(message).outcome).toBe(
allow ? "approved" : "rejected",
);
if (!allow)
expect(JSON.parse(message.content ?? "").message).toBe(
"decision reason",
);
});
}
});

describe("approval-resume parallel-parked approvals", () => {
test("delivers A's decision when a different parked call's approval times out", async () => {
const { resume, delivered } = setup({
Expand Down
4 changes: 3 additions & 1 deletion src/session/approval-resume.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ function decisionMessage(
correlationId: string,
outcome: "approved" | "rejected",
message?: string,
) {
): InboundMessage {
const body: { outcome: "approved" | "rejected"; message?: string } = {
outcome,
};
Expand All @@ -119,6 +119,8 @@ function decisionMessage(
date: new Date().toISOString(),
messageId: `approval-${correlationId}`,
interchangeCorrelationId: correlationId,
interchangeType:
outcome === "approved" ? "approval.granted" : "approval.denied",
},
flags: [],
content: JSON.stringify(body),
Expand Down
Loading
Loading