Skip to content

Commit ac289c1

Browse files
committed
merge main into the reactor boot import cut
2 parents d2f2d6c + 0f353ac commit ac289c1

11 files changed

Lines changed: 395 additions & 60 deletions

‎src/agent/tools.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
import { manageTasksDefinition } from "./tasks.js";
1616
import { validateView } from "../tui/view/validate.js";
1717
import { SETTINGS_DIR_NAME } from "../branding.js";
18+
import { startBoundMcpHandshake } from "../exec/mcp-handshake.js";
1819
import {
1920
advertiseShellGuardTimeout,
2021
type ShellTimeoutConfig,
@@ -312,6 +313,10 @@ export interface MCPConnectCallbacks {
312313
// Headless hosts must not advertise an auth callback they cannot complete;
313314
// its presence marks the OAuth flow as interactive.
314315
interactiveAuth: boolean;
316+
// The TUI sets this so a dial abort (the 15s handshake cap) is dropped once
317+
// the browser auth URL is showing. The browser-auth budget and a real
318+
// cancel still bound that wait. Headless leaves it unset.
319+
browserAuthOutlivesDialAbort?: boolean;
315320
// Fired whenever a server's connection state changes.
316321
onStatus: (state: MCPServerState) => void;
317322
// Fired after a server connects and its tools register, with the full
@@ -1269,6 +1274,7 @@ export async function createAgentToolset(
12691274
return;
12701275
}
12711276
}
1277+
startBoundMcpHandshake(signal);
12721278
if (staleOrDisabled()) return;
12731279
callbacks.onStatus({ name: config.name, state: "connecting" });
12741280
let result: MCPConnectResult;
@@ -1279,6 +1285,12 @@ export async function createAgentToolset(
12791285
...(callbacks.interactiveAuth
12801286
? {
12811287
onAuthURL: (name: string, url: string) => {
1288+
// The parent signal is the dial cap plus exit. Once the
1289+
// operator has an auth URL, that cap must not close the
1290+
// redirect or burn the single browser attempt. Dispose and
1291+
// per-server cancel stay on connectionSignal.
1292+
if (callbacks.browserAuthOutlivesDialAbort === true)
1293+
forwarded?.disarm();
12821294
if (!disposed && !staleOrDisabled()) {
12831295
callbacks.onStatus({ name, state: "needs-auth", url });
12841296
}

‎src/exec/mcp-handshake.ts‎

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,26 +25,56 @@ export async function awaitExecMcpConnect(
2525
}
2626
}
2727

28+
const deferredHandshakeStarts = new WeakMap<AbortSignal, () => void>();
29+
30+
export function bindDeferredMcpHandshake(
31+
signal: AbortSignal,
32+
start: () => void,
33+
): void {
34+
deferredHandshakeStarts.set(signal, start);
35+
}
36+
37+
// First post-trust dial starts a deferred cap. Later servers in the same
38+
// batch share that window. Unbound signals (headless) are already armed.
39+
export function startBoundMcpHandshake(signal: AbortSignal | undefined): void {
40+
if (signal === undefined) return;
41+
const start = deferredHandshakeStarts.get(signal);
42+
if (start === undefined) return;
43+
deferredHandshakeStarts.delete(signal);
44+
start();
45+
}
46+
2847
// Connect-only abort. The MCP client ties `signal` to the transport lifecycle,
2948
// so AbortSignal.timeout would kill a handshake that already succeeded. The
3049
// toolset forwards this signal per server and detaches on settle, so abort
3150
// only reaches handshakes still in flight. Disarm only when the batch
3251
// fulfills — a rejected Promise.all still leaves sibling forwards armed.
33-
export function armExecMcpHandshakeAbort(timeoutMs: number): {
52+
// `when` "later" does not start the timer until `start` (TUI trust gate).
53+
export function armExecMcpHandshakeAbort(
54+
timeoutMs: number,
55+
when: "now" | "later" = "now",
56+
): {
3457
signal: AbortSignal;
3558
disarm: () => void;
59+
start: () => void;
3660
} {
3761
const controller = new AbortController();
38-
const timer = setTimeout(() => {
39-
controller.abort();
40-
}, timeoutMs);
62+
let timer: ReturnType<typeof setTimeout> | undefined;
4163
let disarmed = false;
64+
const start = (): void => {
65+
if (disarmed || timer !== undefined) return;
66+
timer = setTimeout(() => {
67+
controller.abort();
68+
}, timeoutMs);
69+
};
70+
if (when === "now") start();
4271
return {
4372
signal: controller.signal,
73+
start,
4474
disarm: () => {
4575
if (disarmed) return;
4676
disarmed = true;
47-
clearTimeout(timer);
77+
if (timer !== undefined) clearTimeout(timer);
4878
},
4979
};
5080
}

‎src/session/optimized-context-store.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import {
1313
TokenUsage,
1414
type ConversationTurn,
1515
type PendingOperation,
16-
} from "@intx/types/runtime";
16+
} from "@intx/types/runtime-core";
1717
import { getLogger } from "@intx/log";
1818
import {
1919
createSegmentedJSONLWriter,

‎src/session/pending-approval-record.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import {
33
ApprovalSnapshot,
44
type PendingOperation,
55
ToolCall,
6-
} from "@intx/types/runtime";
6+
} from "@intx/types/runtime-core";
77

88
/**
99
* A pending operation as it rests in session state (metadata.json, or a turn
Lines changed: 65 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,74 @@
1-
import { describe, expect, test } from "bun:test";
1+
// Loading the @intx/types/runtime barrel evaluates inference-events.ts and
2+
// builds the InferenceEvent union. Session modules that need runtime values
3+
// import @intx/types/runtime-core so that boot does not pay for the union.
4+
5+
import { expect, test } from "bun:test";
26
import { spawnSync } from "node:child_process";
7+
import path from "node:path";
38
import { fileURLToPath } from "node:url";
49

5-
const repoRoot = fileURLToPath(new URL("../..", import.meta.url));
6-
7-
function probeImport(specifier: string): number | null {
8-
const script = `
9-
import { mock } from "bun:test";
10-
mock.module("@intx/types/inference-events", () => {
11-
throw new Error("evaluated-inference-events");
12-
});
13-
await import(${JSON.stringify(specifier)});
14-
`;
15-
const result = spawnSync("bun", ["-e", script], {
10+
const repoRoot = path.resolve(
11+
path.dirname(fileURLToPath(import.meta.url)),
12+
"../..",
13+
);
14+
15+
const probe = `
16+
import { mock } from "bun:test";
17+
mock.module("@intx/types/inference-events", () => {
18+
throw new Error("evaluated-inference-events");
19+
});
20+
const specifier = process.env.PROBE_SPECIFIER;
21+
if (specifier === undefined || specifier.length === 0) {
22+
throw new Error("missing PROBE_SPECIFIER");
23+
}
24+
for (const target of specifier.split(",")) {
25+
await import(target);
26+
}
27+
`;
28+
29+
function importUnderProbe(specifier: string): {
30+
status: number | null;
31+
stderr: string;
32+
} {
33+
const result = spawnSync("bun", ["-e", probe], {
1634
cwd: repoRoot,
1735
encoding: "utf8",
36+
env: { ...process.env, PROBE_SPECIFIER: specifier },
1837
});
19-
if (
20-
result.status !== 0 &&
21-
!`${result.stderr}${result.stdout}`.includes("evaluated-inference-events")
22-
) {
23-
throw new Error(
24-
`probe of ${specifier} failed without evaluated-inference-events:\n${result.stderr}\n${result.stdout}`,
25-
);
26-
}
27-
return result.status;
38+
return {
39+
status: result.status,
40+
stderr: result.stderr,
41+
};
2842
}
2943

30-
describe("runtime-core import boundary", () => {
31-
test("importing reactor-authorize does not evaluate inference-events", () => {
32-
expect(probeImport("./src/permission/reactor-authorize.ts")).toBe(0);
33-
});
44+
const sessionBootModules = [
45+
"./src/session/optimized-context-store.ts",
46+
"./src/session/pending-approval-record.ts",
47+
"./src/session/summarizer.ts",
48+
].join(",");
49+
50+
test("the runtime barrel evaluates the inference event union", () => {
51+
const result = importUnderProbe("@intx/types/runtime");
52+
expect(result.status).not.toBe(0);
53+
expect(result.stderr).toContain("evaluated-inference-events");
3454
});
55+
56+
test(
57+
"session value imports do not evaluate the inference event union",
58+
() => {
59+
const result = importUnderProbe(sessionBootModules);
60+
expect(result.stderr).not.toContain("evaluated-inference-events");
61+
expect(result.status).toBe(0);
62+
},
63+
{ timeout: 60_000 },
64+
);
65+
66+
test(
67+
"importing reactor-authorize does not evaluate inference-events",
68+
() => {
69+
const result = importUnderProbe("./src/permission/reactor-authorize.ts");
70+
expect(result.stderr).not.toContain("evaluated-inference-events");
71+
expect(result.status).toBe(0);
72+
},
73+
{ timeout: 60_000 },
74+
);

‎src/session/summarizer.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import {
1414
type ConversationTurn,
1515
type InferenceSource,
1616
type RetryPolicy,
17-
} from "@intx/types/runtime";
17+
} from "@intx/types/runtime-core";
1818
import { LOG_NAMESPACE_ROOT } from "../branding.js";
1919
import { NOOP_TELEMETRY, type Telemetry } from "../telemetry/index.js";
2020
import {

0 commit comments

Comments
 (0)