Skip to content

Commit 9fdbc4f

Browse files
committed
test(director): prove mid-session promotion reshapes the wire set
1 parent 57fd048 commit 9fdbc4f

2 files changed

Lines changed: 98 additions & 0 deletions

File tree

‎src/director.test.ts‎

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
advertisedTools,
66
createActivatedToolTracker,
77
} from "./agent/tool-search.js";
8+
import { createAdvertisedToolset } from "./session/assemble-runtime.js";
89
import { createPermissionGate } from "./permission/gate.js";
910
import {
1011
COMPACTOR_KEEP_RECENT_TURNS,
@@ -1145,6 +1146,64 @@ describe("updateToolDefinitions rewrites infer tools", () => {
11451146
expect(JSON.stringify(after)).toBe(JSON.stringify(before));
11461147
});
11471148

1149+
// CL-7868: the provider cache is a prefix cache keyed on the tools array,
1150+
// so a tool_search turn that promotes a genuinely new tool must not reshape
1151+
// the wire set mid-session. The call gate opens (the model invokes the tool
1152+
// from the search result's schema) while the advertised array holds steady
1153+
// until the next cache-safe boundary.
1154+
test("a tool_search turn promoting a genuinely new tool leaves the wire byte-identical", async () => {
1155+
const linearTool = {
1156+
name: "mcp__linear__list_issues",
1157+
description: "list issues",
1158+
inputSchema: { type: "object", properties: {}, required: [] },
1159+
};
1160+
const toolset = await createAgentToolset({
1161+
cwd: process.cwd(),
1162+
permissionGate: createPermissionGate({
1163+
approvals: [],
1164+
interactive: false,
1165+
skipPermissions: true,
1166+
reactorGated: false,
1167+
}),
1168+
onOperatorGate: async () => ({ kind: "cancel" }),
1169+
});
1170+
toolset.dynamicRunner.addTools([
1171+
{ kind: "string", definition: linearTool, handler: async () => "ok" },
1172+
]);
1173+
1174+
const advertised = createAdvertisedToolset({
1175+
sessionMode: "orchestrator",
1176+
toolAvailability: { languageServerAvailable: false },
1177+
getProvider: () => ({ providerName: "openai", model: "gpt-5" }),
1178+
});
1179+
const director = createChatDirector(
1180+
"base-prompt",
1181+
advertised.computeAdvertised(toolset.dynamicRunner.currentDefinitions()),
1182+
{ onTasksChange: () => undefined },
1183+
);
1184+
1185+
const before = await firstInferTools(
1186+
director,
1187+
makeMessageReceivedEvent("hello"),
1188+
);
1189+
1190+
// tool_search matched a genuinely new tool: the runner opens the call
1191+
// gate and refreshes the director exactly as it does today.
1192+
advertised.activated.activate(["mcp__linear__list_issues"]);
1193+
director.updateToolDefinitions(
1194+
advertised.computeAdvertised(toolset.dynamicRunner.currentDefinitions()),
1195+
);
1196+
1197+
const after = await firstInferTools(
1198+
director,
1199+
makeMessageReceivedEvent("continue"),
1200+
);
1201+
expect(JSON.stringify(after)).toBe(JSON.stringify(before));
1202+
expect(advertised.isAdvertised("mcp__linear__list_issues")).toBe(true);
1203+
1204+
await toolset.dispose();
1205+
});
1206+
11481207
// submit_output is always on the wire so a workflow going active never grows
11491208
// the array and busts the provider cache prefix.
11501209
test("submit_output is advertised even with no active workflow", async () => {

‎tests/unit/openai-responses-adapter.test.ts‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,14 @@ import {
33
createOpenAIResponsesAdapter,
44
OPENAI_SESSION_ID_OPTION,
55
} from "../../src/provider/openai-responses.js";
6+
import { createAdvertisedToolset } from "../../src/session/assemble-runtime.js";
67
import { OPENCODE_SESSION_ID_OPTION } from "../../src/provider/opencode-session.js";
78
import { BEARER_CREDENTIAL_SENTINEL } from "@intx/inference";
89
import type {
910
ConversationTurn,
1011
InferenceOptions,
1112
LastCycleSource,
13+
ToolDefinition,
1214
} from "@intx/types/runtime";
1315

1416
const SOURCE: LastCycleSource = {
@@ -70,6 +72,43 @@ describe("openai-responses buildRequest", () => {
7072
});
7173
});
7274

75+
describe("openai-responses promotion cache safety", () => {
76+
function def(name: string): ToolDefinition {
77+
return {
78+
name,
79+
description: `${name} tool`,
80+
inputSchema: { type: "object", properties: {} },
81+
};
82+
}
83+
84+
// CL-7868: the tools array is the head of the provider's cached prefix, so
85+
// a mid-session activation must not change the serialized request body —
86+
// the turns differ only in activated tools.
87+
test("activating a tool mid-session leaves the serialized wire body byte-identical", () => {
88+
const advertised = createAdvertisedToolset({
89+
sessionMode: "orchestrator",
90+
toolAvailability: { languageServerAvailable: false },
91+
getProvider: () => ({ providerName: "openai", model: "gpt-5.6-luna" }),
92+
});
93+
const defs = [
94+
def("read_file"),
95+
def("write_file"),
96+
def("tool_search"),
97+
def("mcp__linear__list_issues"),
98+
];
99+
const bodyFor = (tools: ToolDefinition[]): string =>
100+
adapter().buildRequest([userTurn("hi")], "gpt-5.6-luna", { tools }).body;
101+
const before = bodyFor(advertised.computeAdvertised(defs));
102+
expect(JSON.parse(before)).toHaveProperty("tools");
103+
104+
advertised.activated.activate(["mcp__linear__list_issues"]);
105+
106+
const after = bodyFor(advertised.computeAdvertised(defs));
107+
expect(after).toBe(before);
108+
expect(advertised.isAdvertised("mcp__linear__list_issues")).toBe(true);
109+
});
110+
});
111+
73112
describe("openai-responses x-opencode-session header", () => {
74113
test("sets the header from the opencode session id without leaking it into the body", () => {
75114
const req = adapter().buildRequest([userTurn("hi")], "gpt-5.6-luna", {

0 commit comments

Comments
 (0)