Files
openclaw/extensions/codex/src/app-server/thread-lifecycle.binding.test.ts
Peter Steinberger 96f0983a85 fix(onboarding): skip setup for configured gateways and require inference first (#102883)
* fix(crestodian): keep onboarding RPCs restart-safe

* fix(profiles): isolate approval state migrations

* fix(crestodian): bypass configured gateway setup

* test(crestodian): type onboarding mocks

* fix(onboarding): require inference before Crestodian

* fix(onboarding): enforce verified inference handoff

* fix(macos): reset setup on gateway endpoint edits

* chore(i18n): refresh native source inventory

* fix(gateway): keep socket on request cancellation

* test(packaging): require workspace templates

* fix(onboarding): bind setup to verified inference

* fix(onboarding): align inference gate contracts

* fix(crestodian): classify concurrent policy rejection

* test(crestodian): expect registry restoration

* fix(onboarding): bind setup to configured gateways

* fix(codex): preserve startup phase deadlines

* test(crestodian): match fail-closed policy ordering

* test(onboarding): assert bound gateway handoff

* fix(codex): bind runtime resolution to spawn cwd

* test(crestodian): assert policy rejection order

* fix(cli): preserve gateway routing across restarts

* fix(macos): fail closed during gateway edits

* test(macos): cover gateway route generation races

* chore: keep release notes out of onboarding PR

* fix(ci): refresh onboarding generated checks

* style(swift): align gateway channel formatting

* fix(ci): refresh plugin SDK surface budgets

* fix(ci): resync native string inventory

* refactor(swift): split gateway channel support

* test(doctor): isolate plugin compatibility registry

* test(macos): isolate gateway onboarding fixtures

* test(macos): assert gateway lease health ordering

* fix(codex): reconcile computer-use startup changes
2026-07-11 10:25:14 -07:00

2871 lines
100 KiB
TypeScript

// Codex tests cover thread lifecycle.binding plugin behavior.
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import { CodexAppServerRpcError } from "./client.js";
import { fingerprintCodexAppServerNetworkProxyConfigPatch } from "./config.js";
import type { CodexDynamicToolFunctionSpec } from "./protocol.js";
import {
createParams as createRunAttemptParams,
setupRunAttemptTestHooks,
tempDir,
threadStartResult,
} from "./run-attempt-test-harness.js";
import {
readCodexAppServerBinding,
registerCodexTestSessionIdentity,
testCodexAppServerBindingStore,
writeCodexAppServerBinding as writeRawCodexAppServerBinding,
} from "./session-binding.test-helpers.js";
import {
shouldRotateCodexAppServerBindingForRuntime,
startOrResumeThread as startOrResumeThreadImpl,
} from "./thread-lifecycle.js";
function startOrResumeThread(
params: Omit<Parameters<typeof startOrResumeThreadImpl>[0], "bindingStore">,
) {
registerCodexTestSessionIdentity(
params.params.sessionFile,
params.params.sessionId,
params.params.sessionKey,
);
return startOrResumeThreadImpl({
...params,
bindingStore: testCodexAppServerBindingStore,
});
}
function createThreadLifecycleAppServerOptions(): Parameters<
typeof startOrResumeThread
>[0]["appServer"] {
return {
start: {
transport: "stdio",
command: "codex",
args: ["app-server"],
headers: {},
},
requestTimeoutMs: 60_000,
turnCompletionIdleTimeoutMs: 60_000,
approvalPolicy: "never",
approvalsReviewer: "user",
sandbox: "workspace-write",
codeModeOnly: false,
connectionClass: "local-loopback",
remoteAppsSubstrate: "preconfigured",
};
}
function createNetworkProxyThreadLifecycleAppServerOptions() {
const configPatch = {
"features.network_proxy.enabled": true,
default_permissions: "openclaw-network",
permissions: {
"openclaw-network": {
filesystem: {
":minimal": "read",
":project_roots": {
".": "write",
},
},
network: {
enabled: true,
domains: {
"api.openai.com": "allow",
},
proxy_url: "http://127.0.0.1:3128",
},
},
},
};
return {
...createThreadLifecycleAppServerOptions(),
networkProxy: {
profileName: "openclaw-network",
configFingerprint: fingerprintCodexAppServerNetworkProxyConfigPatch(configPatch),
configPatch,
},
};
}
function createParams(sessionFile: string, workspaceDir: string) {
const params = createRunAttemptParams(sessionFile, workspaceDir);
params.disableTools = false;
return params;
}
const DEFAULT_CODEX_RUNTIME_THREAD_CONFIG = {
"features.code_mode": true,
"features.code_mode_only": false,
"features.apply_patch_streaming_events": true,
"features.standalone_web_search": false,
web_search: "cached",
} as const;
const DEFAULT_CODEX_WEB_SEARCH_THREAD_CONFIG_FINGERPRINT = JSON.stringify({
"features.standalone_web_search": false,
web_search: "cached",
});
function writeCodexAppServerBinding(...args: Parameters<typeof writeRawCodexAppServerBinding>) {
const [sessionFile, binding] = args;
registerCodexTestSessionIdentity(sessionFile, "session-1", "agent:main:session-1");
return writeRawCodexAppServerBinding(sessionFile, {
webSearchThreadConfigFingerprint: DEFAULT_CODEX_WEB_SEARCH_THREAD_CONFIG_FINGERPRINT,
...binding,
});
}
function createMessageDynamicTool(
description: string,
actions: string[] = ["send"],
): CodexDynamicToolFunctionSpec {
return {
type: "function",
name: "message",
description,
inputSchema: {
type: "object",
properties: {
action: {
type: "string",
enum: actions,
},
},
required: ["action"],
additionalProperties: false,
},
};
}
function createNamedDynamicTool(name: string): CodexDynamicToolFunctionSpec {
return {
type: "function",
name,
description: `${name} test tool`,
inputSchema: {
type: "object",
properties: {},
additionalProperties: false,
},
};
}
function createDeferredNamedDynamicTool(
name: string,
): Parameters<typeof startOrResumeThread>[0]["dynamicTools"][number] {
return {
type: "namespace",
name: "openclaw",
description: "",
tools: [{ ...createNamedDynamicTool(name), deferLoading: true }],
};
}
function createPluginAppConfigPatch(options: { approvalsReviewer?: "user" } = {}) {
return {
apps: {
_default: {
enabled: false,
destructive_enabled: false,
open_world_enabled: false,
},
"google-calendar-app": {
enabled: true,
destructive_enabled: true,
open_world_enabled: true,
default_tools_approval_mode: "auto",
...(options.approvalsReviewer ? { approvals_reviewer: options.approvalsReviewer } : {}),
},
},
};
}
function createPluginAppPolicyContext() {
return {
fingerprint: "plugin-policy-1",
apps: {
"google-calendar-app": {
configKey: "google-calendar",
marketplaceName: "openai-curated" as const,
pluginName: "google-calendar",
allowDestructiveActions: true,
mcpServerNames: ["google-calendar"],
},
},
pluginAppIds: {
"google-calendar": ["google-calendar-app"],
},
};
}
function createTwoPluginAppConfigPatch() {
return {
apps: {
...createPluginAppConfigPatch().apps,
"gmail-app": {
enabled: true,
destructive_enabled: true,
open_world_enabled: true,
default_tools_approval_mode: "auto",
},
},
};
}
function createTwoPluginAppPolicyContext() {
return {
fingerprint: "plugin-policy-2",
apps: {
...createPluginAppPolicyContext().apps,
"gmail-app": {
configKey: "gmail",
marketplaceName: "openai-curated" as const,
pluginName: "gmail",
allowDestructiveActions: false,
mcpServerNames: ["gmail"],
},
},
pluginAppIds: {
...createPluginAppPolicyContext().pluginAppIds,
gmail: ["gmail-app"],
},
};
}
function createTwoCalendarAppConfigPatch() {
return {
apps: {
...createPluginAppConfigPatch().apps,
"google-calendar-secondary-app": {
enabled: true,
destructive_enabled: true,
open_world_enabled: true,
default_tools_approval_mode: "auto",
},
},
};
}
function createTwoCalendarAppPolicyContext() {
return {
fingerprint: "plugin-policy-calendar-2",
apps: {
...createPluginAppPolicyContext().apps,
"google-calendar-secondary-app": {
configKey: "google-calendar",
marketplaceName: "openai-curated" as const,
pluginName: "google-calendar",
allowDestructiveActions: false,
mcpServerNames: ["google-calendar"],
},
},
pluginAppIds: {
"google-calendar": ["google-calendar-app", "google-calendar-secondary-app"],
},
};
}
setupRunAttemptTestHooks();
describe("Codex app-server thread lifecycle bindings", () => {
it("resumes the same restricted Crestodian thread so turn two retains native memory", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
await writeCodexAppServerBinding(sessionFile, {
threadId: "thread-normal",
cwd: workspaceDir,
model: "gpt-5.4-codex",
modelProvider: "openai",
dynamicToolsFingerprint: "[]",
});
const params = createParams(sessionFile, workspaceDir);
params.toolsAllow = ["crestodian"];
let nextThread = 1;
const request = vi.fn(async (method: string, _requestParams?: unknown) => {
if (method === "config/read") {
return {
layers: [],
config: {
mcp_servers: {
"arbitrary.server": { command: "ignored" },
"local helper": { url: "https://mcp.example.test" },
},
},
};
}
if (method === "configRequirements/read") {
return { requirements: null };
}
if (method === "thread/start") {
return threadStartResult(`thread-ring-zero-${nextThread++}`);
}
if (method === "thread/resume") {
return threadStartResult("thread-ring-zero-1");
}
if (method === "mcpServerStatus/list") {
return { data: [], nextCursor: null };
}
throw new Error(`unexpected method: ${method}`);
});
const common = {
client: { request } as never,
params,
cwd: workspaceDir,
dynamicTools: [createNamedDynamicTool("crestodian")],
appServer: createThreadLifecycleAppServerOptions(),
nativeCodeModeEnabled: false,
userMcpServersEnabled: false,
hostCrestodianActive: true,
};
const first = await startOrResumeThread(common);
const second = await startOrResumeThread(common);
expect(first.lifecycle.action).toBe("started");
expect(second.lifecycle.action).toBe("resumed");
expect(request.mock.calls.map(([method]) => method)).toEqual([
"config/read",
"configRequirements/read",
"thread/start",
"mcpServerStatus/list",
"config/read",
"configRequirements/read",
"thread/resume",
"mcpServerStatus/list",
]);
const startCalls = request.mock.calls.filter(([method]) => method === "thread/start");
expect(startCalls.map(([, startParams]) => startParams)).toEqual([
expect.objectContaining({
config: expect.objectContaining({
mcp_servers: {
"arbitrary.server": { enabled: false },
"local helper": { enabled: false },
},
}),
}),
]);
const binding = await readCodexAppServerBinding(sessionFile);
expect(binding?.threadId).toBe("thread-ring-zero-1");
expect(binding?.ringZeroConfigFingerprint).toEqual(expect.any(String));
expect(binding?.ringZeroClientInstanceId).toEqual(expect.any(String));
});
it("starts a fresh restricted Crestodian thread for a new app-server client", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
const params = createParams(sessionFile, workspaceDir);
params.toolsAllow = ["crestodian"];
let nextThread = 1;
const request = vi.fn(async (method: string, _requestParams?: unknown) => {
if (method === "config/read") {
return { config: {}, layers: [] };
}
if (method === "configRequirements/read") {
return { requirements: null };
}
if (method === "thread/start") {
return threadStartResult(`thread-ring-zero-${nextThread++}`);
}
if (method === "mcpServerStatus/list") {
return { data: [], nextCursor: null };
}
throw new Error(`unexpected method: ${method}`);
});
const common = {
params,
cwd: workspaceDir,
dynamicTools: [createNamedDynamicTool("crestodian")],
appServer: createThreadLifecycleAppServerOptions(),
nativeCodeModeEnabled: false,
userMcpServersEnabled: false,
hostCrestodianActive: true,
};
const first = await startOrResumeThread({ ...common, client: { request } as never });
const second = await startOrResumeThread({ ...common, client: { request } as never });
expect(first.lifecycle.action).toBe("started");
expect(second.lifecycle.action).toBe("started");
expect(request.mock.calls.map(([method]) => method)).not.toContain("thread/resume");
const startCalls = request.mock.calls.filter(([method]) => method === "thread/start");
expect(startCalls).toHaveLength(2);
expect(startCalls.map(([, startParams]) => startParams)).toEqual([
expect.objectContaining({ environments: [] }),
expect.objectContaining({ environments: [] }),
]);
expect((await readCodexAppServerBinding(sessionFile))?.threadId).toBe("thread-ring-zero-2");
});
it("retires a warm Crestodian binding when resume MCP attestation fails", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
const params = createParams(sessionFile, workspaceDir);
params.toolsAllow = ["crestodian"];
let attestationCount = 0;
const request = vi.fn(async (method: string) => {
if (method === "config/read") {
return { config: {}, layers: [] };
}
if (method === "configRequirements/read") {
return { requirements: null };
}
if (method === "thread/start" || method === "thread/resume") {
return threadStartResult("thread-ring-zero");
}
if (method === "mcpServerStatus/list") {
attestationCount += 1;
return attestationCount === 1
? { data: [], nextCursor: null }
: { data: [{ name: "late-server" }], nextCursor: null };
}
throw new Error(`unexpected method: ${method}`);
});
const client = { request } as never;
const abandonClient = vi.fn(async () => {});
const common = {
client,
abandonClient,
params,
cwd: workspaceDir,
dynamicTools: [createNamedDynamicTool("crestodian")],
appServer: createThreadLifecycleAppServerOptions(),
nativeCodeModeEnabled: false,
userMcpServersEnabled: false,
hostCrestodianActive: true,
};
await startOrResumeThread(common);
await expect(startOrResumeThread(common)).rejects.toThrow(
"Codex ring-zero MCP attestation failed",
);
expect(abandonClient).toHaveBeenCalledTimes(1);
expect(request.mock.calls.map(([method]) => method)).toEqual([
"config/read",
"configRequirements/read",
"thread/start",
"mcpServerStatus/list",
"config/read",
"configRequirements/read",
"thread/resume",
"mcpServerStatus/list",
]);
expect(request.mock.calls.some(([method]) => method === "turn/start")).toBe(false);
expect(await readCodexAppServerBinding(sessionFile)).toBeUndefined();
});
it("fails closed before starting Crestodian when inherited MCP enumeration fails", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
await writeCodexAppServerBinding(sessionFile, {
threadId: "thread-normal",
cwd: workspaceDir,
model: "gpt-5.4-codex",
modelProvider: "openai",
dynamicToolsFingerprint: "[]",
});
const params = createParams(sessionFile, workspaceDir);
params.toolsAllow = ["crestodian"];
const request = vi.fn(async (method: string) => {
if (method === "config/read") {
throw new Error("config unavailable");
}
throw new Error(`unexpected method: ${method}`);
});
await expect(
startOrResumeThread({
client: { request } as never,
params,
cwd: workspaceDir,
dynamicTools: [createNamedDynamicTool("crestodian")],
appServer: createThreadLifecycleAppServerOptions(),
nativeCodeModeEnabled: false,
userMcpServersEnabled: false,
hostCrestodianActive: true,
}),
).rejects.toThrow("config unavailable");
expect(request.mock.calls.map(([method]) => method)).toEqual(["config/read"]);
expect((await readCodexAppServerBinding(sessionFile))?.threadId).toBe("thread-normal");
});
it.each([
{ name: "legacy managed file", layer: { name: { type: "legacyManagedConfigTomlFromFile" } } },
{ name: "legacy managed MDM", layer: { name: { type: "legacyManagedConfigTomlFromMdm" } } },
{ name: "unknown future", layer: { name: { type: "futureManaged" } } },
{ name: "malformed", layer: { name: {} } },
])("fails closed on $name config layers before Crestodian thread/start", async ({ layer }) => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
const params = createParams(sessionFile, workspaceDir);
params.toolsAllow = ["crestodian"];
const request = vi.fn(async (method: string) => {
if (method === "config/read") {
return { config: {}, layers: [layer] };
}
throw new Error(`unexpected method: ${method}`);
});
await expect(
startOrResumeThread({
client: { request } as never,
params,
cwd: workspaceDir,
dynamicTools: [createNamedDynamicTool("crestodian")],
appServer: createThreadLifecycleAppServerOptions(),
nativeCodeModeEnabled: false,
userMcpServersEnabled: false,
hostCrestodianActive: true,
}),
).rejects.toThrow(/config layer|config layers/u);
expect(request.mock.calls.map(([method]) => method)).toEqual(["config/read"]);
});
it.each(["hooks", "managed_hooks"] as const)(
"fails closed on non-empty %s requirements before Crestodian thread/start",
async (requirementsKey) => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
const params = createParams(sessionFile, workspaceDir);
params.toolsAllow = ["crestodian"];
const request = vi.fn(async (method: string) => {
if (method === "config/read") {
return { config: {}, layers: [] };
}
if (method === "configRequirements/read") {
return {
requirements: {
[requirementsKey]: {
PreToolUse: [{ matcher: "*", hooks: [{ type: "command" }] }],
},
},
};
}
throw new Error(`unexpected method: ${method}`);
});
await expect(
startOrResumeThread({
client: { request } as never,
params,
cwd: workspaceDir,
dynamicTools: [createNamedDynamicTool("crestodian")],
appServer: createThreadLifecycleAppServerOptions(),
nativeCodeModeEnabled: false,
userMcpServersEnabled: false,
hostCrestodianActive: true,
}),
).rejects.toThrow("cannot override managed hooks");
expect(request.mock.calls.map(([method]) => method)).toEqual([
"config/read",
"configRequirements/read",
]);
},
);
it("fails closed when requirements pin a restricted Codex feature on", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
const params = createParams(sessionFile, workspaceDir);
params.toolsAllow = ["crestodian"];
const request = vi.fn(async (method: string) => {
if (method === "config/read") {
return { config: {}, layers: [] };
}
if (method === "configRequirements/read") {
return { requirements: { featureRequirements: { hooks: true } } };
}
throw new Error(`unexpected method: ${method}`);
});
await expect(
startOrResumeThread({
client: { request } as never,
params,
cwd: workspaceDir,
dynamicTools: [createNamedDynamicTool("crestodian")],
appServer: createThreadLifecycleAppServerOptions(),
nativeCodeModeEnabled: false,
userMcpServersEnabled: false,
hostCrestodianActive: true,
}),
).rejects.toThrow("cannot override required feature hooks");
expect(request.mock.calls.map(([method]) => method)).toEqual([
"config/read",
"configRequirements/read",
]);
});
it.each([
{ name: "a newly raced server", attestation: { data: [{ name: "raced" }] } },
{ name: "a malformed inventory", attestation: { data: "invalid" } },
{ name: "an inventory RPC failure", attestation: new Error("inventory failed") },
])("retires the cold Crestodian thread when attestation finds $name", async ({ attestation }) => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
await writeCodexAppServerBinding(sessionFile, {
threadId: "thread-normal",
cwd: workspaceDir,
model: "gpt-5.4-codex",
modelProvider: "openai",
dynamicToolsFingerprint: "[]",
});
const params = createParams(sessionFile, workspaceDir);
params.toolsAllow = ["crestodian"];
const abandonClient = vi.fn(async () => {});
const request = vi.fn(async (method: string) => {
if (method === "config/read") {
return { config: {}, layers: [] };
}
if (method === "configRequirements/read") {
return { requirements: null };
}
if (method === "thread/start") {
return threadStartResult("thread-ring-zero");
}
if (method === "mcpServerStatus/list") {
if (attestation instanceof Error) {
throw attestation;
}
return attestation;
}
throw new Error(`unexpected method: ${method}`);
});
await expect(
startOrResumeThread({
client: { request } as never,
abandonClient,
params,
cwd: workspaceDir,
dynamicTools: [createNamedDynamicTool("crestodian")],
appServer: createThreadLifecycleAppServerOptions(),
nativeCodeModeEnabled: false,
userMcpServersEnabled: false,
hostCrestodianActive: true,
}),
).rejects.toThrow();
expect(abandonClient).toHaveBeenCalledTimes(1);
expect(request.mock.calls.map(([method]) => method)).toEqual([
"config/read",
"configRequirements/read",
"thread/start",
"mcpServerStatus/list",
]);
expect(request.mock.calls.some(([method]) => method === "turn/start")).toBe(false);
expect(await readCodexAppServerBinding(sessionFile)).toBeUndefined();
});
it("rotates remote runtime bindings when the app-server fingerprint is missing or changed", () => {
expect(
shouldRotateCodexAppServerBindingForRuntime({
connectionClass: "remote",
current: "remote-runtime-v1",
}),
).toBe(true);
expect(
shouldRotateCodexAppServerBindingForRuntime({
connectionClass: "remote",
current: "remote-runtime-v1",
binding: "remote-runtime-v0",
}),
).toBe(true);
expect(
shouldRotateCodexAppServerBindingForRuntime({
connectionClass: "remote",
current: "remote-runtime-v1",
binding: "remote-runtime-v1",
}),
).toBe(false);
expect(
shouldRotateCodexAppServerBindingForRuntime({
connectionClass: "local-loopback",
current: "local-runtime-v1",
}),
).toBe(false);
});
it("does not write a binding when thread start resolves after abort", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
const params = createParams(sessionFile, workspaceDir);
const appServer = createThreadLifecycleAppServerOptions();
const abortController = new AbortController();
let resolveStart: ((value: ReturnType<typeof threadStartResult>) => void) | undefined;
const request = vi.fn(async (method: string, _requestParams?: unknown) => {
if (method === "thread/start") {
return await new Promise<ReturnType<typeof threadStartResult>>((resolve) => {
resolveStart = resolve;
});
}
throw new Error(`unexpected method: ${method}`);
});
const run = startOrResumeThread({
client: { request } as never,
params,
cwd: workspaceDir,
dynamicTools: [],
appServer,
signal: abortController.signal,
});
await vi.waitFor(() =>
expect(request).toHaveBeenCalledWith("thread/start", expect.any(Object), {
signal: abortController.signal,
}),
);
abortController.abort("test_abort");
resolveStart?.(threadStartResult("thread-after-abort"));
await expect(run).rejects.toThrow("test_abort");
await expect(readCodexAppServerBinding(sessionFile)).resolves.toBeUndefined();
});
it("resumes a bound Codex thread when only dynamic tool descriptions change", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
const params = createParams(sessionFile, workspaceDir);
const appServer = createThreadLifecycleAppServerOptions();
const request = vi.fn(async (method: string, _requestParams?: unknown) => {
if (method === "thread/start") {
return threadStartResult("thread-existing");
}
if (method === "thread/resume") {
return threadStartResult("thread-existing");
}
throw new Error(`unexpected method: ${method}`);
});
await startOrResumeThread({
client: { request } as never,
params,
cwd: workspaceDir,
dynamicTools: [
createMessageDynamicTool("Send and manage messages for the current Slack thread."),
],
appServer,
});
const binding = await startOrResumeThread({
client: { request } as never,
params,
cwd: workspaceDir,
dynamicTools: [
createMessageDynamicTool("Send and manage messages for the current Discord channel."),
],
appServer,
});
expect(binding.threadId).toBe("thread-existing");
expect(request.mock.calls.map(([method]) => method)).toEqual(["thread/start", "thread/resume"]);
});
it.each([
["gpt-5.6-luna", "gpt-5.6-sol"],
["gpt-5.6-luna", "gpt-5.6-terra"],
["gpt-5.6-sol", "gpt-5.6-luna"],
["gpt-5.6-terra", "gpt-5.6-luna"],
])("starts a fresh thread when switching from %s to %s", async (bindingModel, requestedModel) => {
const sessionFile = path.join(tempDir, `${bindingModel}-${requestedModel}.jsonl`);
const workspaceDir = path.join(tempDir, "workspace");
await writeCodexAppServerBinding(sessionFile, {
threadId: "thread-existing",
cwd: workspaceDir,
model: bindingModel,
});
const params = createParams(sessionFile, workspaceDir);
params.modelId = requestedModel;
const request = vi.fn(async (method: string, requestParams?: unknown) => {
if (method === "thread/start") {
const response = threadStartResult("thread-rebound");
response.model = (requestParams as { model: string }).model;
return response;
}
throw new Error(`unexpected method: ${method}`);
});
const binding = await startOrResumeThread({
client: { request } as never,
params,
cwd: workspaceDir,
dynamicTools: [],
appServer: createThreadLifecycleAppServerOptions(),
});
expect(request.mock.calls.map(([method]) => method)).toEqual(["thread/start"]);
expect(request.mock.calls[0]?.[1]).toMatchObject({ model: requestedModel });
expect(binding).toMatchObject({
threadId: "thread-rebound",
model: requestedModel,
lifecycle: { action: "started" },
});
});
it.each([
["gpt-5.6-sol", "gpt-5.6-terra"],
["gpt-5.6-terra", "gpt-5.6-sol"],
])("resumes the thread when switching from %s to %s", async (bindingModel, requestedModel) => {
const sessionFile = path.join(tempDir, `${bindingModel}-${requestedModel}.jsonl`);
const workspaceDir = path.join(tempDir, "workspace");
await writeCodexAppServerBinding(sessionFile, {
threadId: "thread-existing",
cwd: workspaceDir,
model: bindingModel,
});
const params = createParams(sessionFile, workspaceDir);
params.modelId = requestedModel;
const request = vi.fn(async (method: string, requestParams?: unknown) => {
if (method === "thread/resume") {
const response = threadStartResult("thread-existing");
response.model = (requestParams as { model: string }).model;
return response;
}
throw new Error(`unexpected method: ${method}`);
});
const binding = await startOrResumeThread({
client: { request } as never,
params,
cwd: workspaceDir,
dynamicTools: [],
appServer: createThreadLifecycleAppServerOptions(),
});
expect(request.mock.calls.map(([method]) => method)).toEqual(["thread/resume"]);
expect(request.mock.calls[0]?.[1]).toMatchObject({
threadId: "thread-existing",
model: requestedModel,
});
expect(binding).toMatchObject({
threadId: "thread-existing",
model: requestedModel,
lifecycle: { action: "resumed" },
});
});
it("sends canonical typed dynamic tools on thread start", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
const params = createParams(sessionFile, workspaceDir);
const appServer = createThreadLifecycleAppServerOptions();
const request = vi.fn(async (method: string, _requestParams?: unknown) => {
if (method === "thread/start") {
return threadStartResult("thread-typed-tools");
}
throw new Error(`unexpected method: ${method}`);
});
await startOrResumeThread({
client: { request } as never,
params,
cwd: workspaceDir,
dynamicTools: [
createMessageDynamicTool("Send a message."),
createDeferredNamedDynamicTool("web_search"),
],
appServer,
});
const startParams = request.mock.calls.find(([method]) => method === "thread/start")?.[1] as
| { dynamicTools?: unknown[] }
| undefined;
expect(startParams?.dynamicTools).toEqual([
expect.objectContaining({
type: "function",
name: "message",
description: "Send a message.",
}),
expect.objectContaining({
type: "namespace",
name: "openclaw",
tools: [
expect.objectContaining({
type: "function",
name: "web_search",
deferLoading: true,
}),
],
}),
]);
});
it("keeps the bound local provider when recoverable resume failure starts a fresh thread", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
await writeCodexAppServerBinding(sessionFile, {
threadId: "thread-existing",
cwd: workspaceDir,
model: "local-model",
modelProvider: "lmstudio",
approvalPolicy: "on-request",
sandbox: "workspace-write",
});
const params = createParams(sessionFile, workspaceDir);
params.provider = "codex";
params.modelId = "local-model-2";
const appServer = createThreadLifecycleAppServerOptions();
const request = vi.fn(async (method: string, _requestParams?: unknown) => {
if (method === "thread/resume") {
// Only a structured RPC rejection proves Codex holds no resume
// subscription; anything else retires the client instead.
throw new CodexAppServerRpcError({ code: -32_000, message: "stale thread" }, method);
}
if (method === "thread/unsubscribe") {
return { status: "not_subscribed" };
}
if (method === "thread/start") {
const response = threadStartResult("thread-new");
response.model = "local-model-2";
response.modelProvider = "lmstudio";
response.thread.modelProvider = "lmstudio";
return response;
}
throw new Error(`unexpected method: ${method}`);
});
const binding = await startOrResumeThread({
client: { request } as never,
params,
cwd: workspaceDir,
dynamicTools: [],
appServer,
});
const startParams = request.mock.calls.find(([method]) => method === "thread/start")?.[1] as
| Record<string, unknown>
| undefined;
expect(request.mock.calls.map(([method]) => method)).toEqual([
"thread/resume",
"thread/unsubscribe",
"thread/start",
]);
expect(startParams?.model).toBe("local-model-2");
expect(startParams?.modelProvider).toBe("lmstudio");
expect(binding.threadId).toBe("thread-new");
expect(binding.modelProvider).toBe("lmstudio");
});
it("falls back to a fresh thread when a rejected resume also fails unsubscribe", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
await writeCodexAppServerBinding(sessionFile, {
threadId: "thread-existing",
cwd: workspaceDir,
model: "gpt-5.4-codex",
modelProvider: "openai",
dynamicToolsFingerprint: "[]",
});
const request = vi.fn(async (method: string) => {
if (method === "thread/resume") {
throw new CodexAppServerRpcError({ code: -32_000, message: "thread not found" }, method);
}
if (method === "thread/unsubscribe") {
throw new Error("unsubscribe rejected");
}
if (method === "thread/start") {
return threadStartResult("thread-recovered");
}
throw new Error(`unexpected method: ${method}`);
});
// The RPC rejection already proves no resume subscription exists, so a
// failing cosmetic unsubscribe must not block stale-binding recovery.
const binding = await startOrResumeThread({
client: { request } as never,
params: createParams(sessionFile, workspaceDir),
cwd: workspaceDir,
dynamicTools: [],
appServer: createThreadLifecycleAppServerOptions(),
});
expect(binding.threadId).toBe("thread-recovered");
expect(request.mock.calls.map(([method]) => method)).toEqual([
"thread/resume",
"thread/unsubscribe",
"thread/start",
]);
});
it("keeps the bound local provider when stale fingerprints force a fresh thread", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
await writeCodexAppServerBinding(sessionFile, {
threadId: "thread-existing",
cwd: workspaceDir,
model: "local-model",
modelProvider: "lmstudio",
dynamicToolsFingerprint: "stale-fingerprint",
dynamicToolsContainDeferred: false,
approvalPolicy: "on-request",
sandbox: "workspace-write",
});
const params = createParams(sessionFile, workspaceDir);
params.provider = "codex";
params.modelId = "local-model-2";
const appServer = createThreadLifecycleAppServerOptions();
const request = vi.fn(async (method: string, _requestParams?: unknown) => {
if (method === "thread/start") {
const response = threadStartResult("thread-new");
response.model = "local-model-2";
response.modelProvider = "lmstudio";
response.thread.modelProvider = "lmstudio";
return response;
}
throw new Error(`unexpected method: ${method}`);
});
const binding = await startOrResumeThread({
client: { request } as never,
params,
cwd: workspaceDir,
dynamicTools: [createNamedDynamicTool("web_search")],
appServer,
});
const startParams = request.mock.calls[0]?.[1] as Record<string, unknown> | undefined;
expect(request.mock.calls.map(([method]) => method)).toEqual(["thread/start"]);
expect(startParams?.model).toBe("local-model-2");
expect(startParams?.modelProvider).toBe("lmstudio");
expect(binding.threadId).toBe("thread-new");
expect(binding.modelProvider).toBe("lmstudio");
});
it("keeps the bound local provider when the bound model id contains a slash", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
await writeCodexAppServerBinding(sessionFile, {
threadId: "thread-existing",
cwd: workspaceDir,
model: "openai/gpt-oss-20b",
modelProvider: "lmstudio",
dynamicToolsFingerprint: "[]",
approvalPolicy: "on-request",
sandbox: "workspace-write",
});
const params = createParams(sessionFile, workspaceDir);
params.provider = "codex";
params.modelId = "openai/gpt-oss-20b";
const appServer = createThreadLifecycleAppServerOptions();
const request = vi.fn(async (method: string, _requestParams?: unknown) => {
if (method === "thread/resume") {
const response = threadStartResult("thread-existing");
response.model = "openai/gpt-oss-20b";
response.modelProvider = "lmstudio";
response.thread.modelProvider = "lmstudio";
return response;
}
throw new Error(`unexpected method: ${method}`);
});
const binding = await startOrResumeThread({
client: { request } as never,
params,
cwd: workspaceDir,
dynamicTools: [],
appServer,
});
const resumeParams = request.mock.calls[0]?.[1] as Record<string, unknown> | undefined;
expect(request.mock.calls.map(([method]) => method)).toEqual(["thread/resume"]);
expect(resumeParams?.model).toBe("openai/gpt-oss-20b");
expect(resumeParams?.modelProvider).toBe("lmstudio");
expect(binding.threadId).toBe("thread-existing");
expect(binding.modelProvider).toBe("lmstudio");
});
it("starts a fresh Codex thread when web search switches to a managed provider", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
const params = createParams(sessionFile, workspaceDir);
params.disableTools = false;
const appServer = createThreadLifecycleAppServerOptions();
let starts = 0;
const request = vi.fn(async (method: string, requestParams?: unknown) => {
if (method === "thread/start") {
starts += 1;
return threadStartResult(`thread-${starts}`);
}
if (method === "thread/resume") {
// Resume must echo the requested thread; anything else is rejected as
// an unsafe subscription.
return threadStartResult((requestParams as { threadId: string }).threadId);
}
throw new Error(`unexpected method: ${method}`);
});
await startOrResumeThread({
client: { request } as never,
params,
cwd: workspaceDir,
dynamicTools: [createDeferredNamedDynamicTool("web_search")],
appServer,
});
params.config = {
tools: {
web: {
search: { provider: "brave" },
},
},
};
const binding = await startOrResumeThread({
client: { request } as never,
params,
cwd: workspaceDir,
dynamicTools: [createDeferredNamedDynamicTool("web_search")],
appServer,
});
expect(binding.threadId).toBe("thread-2");
expect(request.mock.calls.map(([method]) => method)).toEqual(["thread/start", "thread/start"]);
expect(request.mock.calls[0]?.[1]).toMatchObject({
config: { web_search: "cached" },
});
expect(request.mock.calls[1]?.[1]).toMatchObject({
config: { web_search: "disabled" },
});
});
it("uses a transient Codex thread when runtime toolsAllow denies web_search", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
const params = createParams(sessionFile, workspaceDir);
params.disableTools = false;
const appServer = createThreadLifecycleAppServerOptions();
let starts = 0;
const request = vi.fn(async (method: string, _params?: unknown) => {
if (method === "thread/start") {
starts += 1;
return threadStartResult(`thread-${starts}`);
}
if (method === "thread/resume") {
return threadStartResult("thread-1");
}
throw new Error(`unexpected method: ${method}`);
});
await startOrResumeThread({
client: { request } as never,
params,
cwd: workspaceDir,
dynamicTools: [createDeferredNamedDynamicTool("web_search")],
webSearchAllowed: true,
appServer,
});
params.toolsAllow = ["message"];
const restrictedBinding = await startOrResumeThread({
client: { request } as never,
params,
cwd: workspaceDir,
dynamicTools: [createDeferredNamedDynamicTool("web_search")],
webSearchAllowed: false,
appServer,
});
const savedAfterRestriction = await readCodexAppServerBinding(sessionFile);
params.toolsAllow = undefined;
const resumedBinding = await startOrResumeThread({
client: { request } as never,
params,
cwd: workspaceDir,
dynamicTools: [createDeferredNamedDynamicTool("web_search")],
webSearchAllowed: true,
appServer,
});
expect(restrictedBinding.threadId).toBe("thread-2");
expect(savedAfterRestriction?.threadId).toBe("thread-1");
expect(resumedBinding.threadId).toBe("thread-1");
expect(request.mock.calls.map(([method]) => method)).toEqual([
"thread/start",
"thread/start",
"thread/resume",
]);
expect(request.mock.calls[0]?.[1]).toMatchObject({
config: { web_search: "cached" },
});
expect(request.mock.calls[1]?.[1]).toMatchObject({
config: { web_search: "disabled" },
});
});
it("preserves the native-search binding when provider capability support is unknown", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
const params = createParams(sessionFile, workspaceDir);
const appServer = createThreadLifecycleAppServerOptions();
let starts = 0;
const request = vi.fn(async (method: string, requestParams?: unknown) => {
if (method === "thread/start") {
starts += 1;
return threadStartResult(`thread-${starts}`);
}
if (method === "thread/resume") {
return threadStartResult((requestParams as { threadId: string }).threadId);
}
throw new Error(`unexpected method: ${method}`);
});
await startOrResumeThread({
client: { request } as never,
params,
cwd: workspaceDir,
dynamicTools: [],
nativeProviderWebSearchSupport: "supported",
webSearchAllowed: true,
appServer,
});
const transientBinding = await startOrResumeThread({
client: { request } as never,
params,
cwd: workspaceDir,
dynamicTools: [],
nativeProviderWebSearchSupport: "unknown",
webSearchAllowed: true,
appServer,
});
const savedAfterUnknownSupport = await readCodexAppServerBinding(sessionFile);
const resumedBinding = await startOrResumeThread({
client: { request } as never,
params,
cwd: workspaceDir,
dynamicTools: [],
nativeProviderWebSearchSupport: "supported",
webSearchAllowed: true,
appServer,
});
expect(transientBinding.threadId).toBe("thread-2");
expect(savedAfterUnknownSupport?.threadId).toBe("thread-1");
expect(resumedBinding.threadId).toBe("thread-1");
expect(request.mock.calls.map(([method]) => method)).toEqual([
"thread/start",
"thread/start",
"thread/resume",
]);
expect(request.mock.calls[0]?.[1]).toMatchObject({
config: { web_search: "cached" },
});
expect(request.mock.calls[1]?.[1]).toMatchObject({
config: { web_search: "disabled" },
});
});
it("does not persist a first-turn managed fallback when provider capability support is unknown", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
const request = vi.fn(async (method: string, _requestParams?: unknown) => {
if (method === "thread/start") {
return threadStartResult("thread-transient");
}
throw new Error(`unexpected method: ${method}`);
});
const binding = await startOrResumeThread({
client: { request } as never,
params: createParams(sessionFile, workspaceDir),
cwd: workspaceDir,
dynamicTools: [],
nativeProviderWebSearchSupport: "unknown",
webSearchAllowed: true,
appServer: createThreadLifecycleAppServerOptions(),
});
expect(binding.threadId).toBe("thread-transient");
expect(await readCodexAppServerBinding(sessionFile)).toBeUndefined();
expect(request.mock.calls[0]?.[1]).toMatchObject({
config: { web_search: "disabled" },
});
});
it("persists a restricted Codex thread when effective config policy denies web_search", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
const params = createParams(sessionFile, workspaceDir);
const appServer = createThreadLifecycleAppServerOptions();
let starts = 0;
const request = vi.fn(async (method: string, requestParams?: unknown) => {
if (method === "thread/start") {
starts += 1;
return threadStartResult(`thread-${starts}`);
}
if (method === "thread/resume") {
return threadStartResult((requestParams as { threadId: string }).threadId);
}
throw new Error(`unexpected method: ${method}`);
});
await startOrResumeThread({
client: { request } as never,
params,
cwd: workspaceDir,
dynamicTools: [createDeferredNamedDynamicTool("web_search")],
webSearchAllowed: true,
appServer,
});
params.config = { tools: { deny: ["web_search"] } };
params.toolsAllow = [];
const restrictedBinding = await startOrResumeThread({
client: { request } as never,
params,
cwd: workspaceDir,
dynamicTools: [],
persistentWebSearchAllowed: false,
webSearchAllowed: false,
appServer,
});
const resumedRestrictedBinding = await startOrResumeThread({
client: { request } as never,
params,
cwd: workspaceDir,
dynamicTools: [],
persistentWebSearchAllowed: false,
webSearchAllowed: false,
appServer,
});
expect(restrictedBinding.threadId).toBe("thread-2");
expect(resumedRestrictedBinding.threadId).toBe("thread-2");
expect((await readCodexAppServerBinding(sessionFile))?.threadId).toBe("thread-2");
expect(request.mock.calls.map(([method]) => method)).toEqual([
"thread/start",
"thread/start",
"thread/resume",
]);
});
it("persists config-denied search when runtime toolsAllow also excludes web_search", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
const params = createParams(sessionFile, workspaceDir);
const appServer = createThreadLifecycleAppServerOptions();
let starts = 0;
const request = vi.fn(async (method: string, requestParams?: unknown) => {
if (method === "thread/start") {
starts += 1;
return threadStartResult(`thread-${starts}`);
}
if (method === "thread/resume") {
return threadStartResult((requestParams as { threadId: string }).threadId);
}
throw new Error(`unexpected method: ${method}`);
});
await startOrResumeThread({
client: { request } as never,
params,
cwd: workspaceDir,
dynamicTools: [createDeferredNamedDynamicTool("web_search")],
persistentWebSearchAllowed: true,
webSearchAllowed: true,
appServer,
});
params.config = { tools: { deny: ["web_search"] } };
params.toolsAllow = ["message"];
const restrictedBinding = await startOrResumeThread({
client: { request } as never,
params,
cwd: workspaceDir,
dynamicTools: [],
nativeCodeModeEnabled: false,
persistentWebSearchAllowed: false,
webSearchAllowed: false,
appServer,
});
const resumedRestrictedBinding = await startOrResumeThread({
client: { request } as never,
params,
cwd: workspaceDir,
dynamicTools: [],
nativeCodeModeEnabled: false,
persistentWebSearchAllowed: false,
webSearchAllowed: false,
appServer,
});
expect(restrictedBinding.threadId).toBe("thread-2");
expect(resumedRestrictedBinding.threadId).toBe("thread-2");
expect((await readCodexAppServerBinding(sessionFile))?.threadId).toBe("thread-2");
expect(request.mock.calls.map(([method]) => method)).toEqual([
"thread/start",
"thread/start",
"thread/resume",
]);
});
it("replaces the Codex binding when web search is persistently disabled", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
const params = createParams(sessionFile, workspaceDir);
const appServer = createThreadLifecycleAppServerOptions();
let starts = 0;
const request = vi.fn(async (method: string, _params?: unknown) => {
if (method === "thread/start") {
starts += 1;
return threadStartResult(`thread-${starts}`);
}
throw new Error(`unexpected method: ${method}`);
});
await startOrResumeThread({
client: { request } as never,
params,
cwd: workspaceDir,
dynamicTools: [createDeferredNamedDynamicTool("web_search")],
appServer,
});
params.config = {
tools: {
web: {
search: { enabled: false },
},
},
};
const binding = await startOrResumeThread({
client: { request } as never,
params,
cwd: workspaceDir,
dynamicTools: [],
webSearchAllowed: false,
appServer,
});
expect(binding.threadId).toBe("thread-2");
expect((await readCodexAppServerBinding(sessionFile))?.threadId).toBe("thread-2");
expect(request.mock.calls.map(([method]) => method)).toEqual(["thread/start", "thread/start"]);
});
it("starts a fresh Codex thread for default hosted search on a legacy binding", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
await writeRawCodexAppServerBinding(sessionFile, {
threadId: "thread-legacy",
cwd: workspaceDir,
model: "gpt-5.5",
modelProvider: "openai",
dynamicToolsFingerprint: "[]",
});
const request = vi.fn(async (method: string, _params?: unknown) => {
if (method === "thread/start") {
return threadStartResult("thread-fresh");
}
throw new Error(`unexpected method: ${method}`);
});
const binding = await startOrResumeThread({
client: { request } as never,
params: createParams(sessionFile, workspaceDir),
cwd: workspaceDir,
dynamicTools: [],
appServer: createThreadLifecycleAppServerOptions(),
});
expect(binding.threadId).toBe("thread-fresh");
expect(request.mock.calls.map(([method]) => method)).toEqual(["thread/start"]);
expect(request.mock.calls[0]?.[1]).toMatchObject({
config: {
"features.standalone_web_search": false,
web_search: "cached",
},
});
});
it("starts a fresh Codex thread for a restrictive web search policy on a legacy binding", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
await writeRawCodexAppServerBinding(sessionFile, {
threadId: "thread-legacy",
cwd: workspaceDir,
model: "gpt-5.5",
modelProvider: "openai",
dynamicToolsFingerprint: "[]",
});
const params = createParams(sessionFile, workspaceDir);
params.disableTools = false;
params.config = {
tools: {
web: {
search: { openaiCodex: { enabled: false } },
},
},
};
const request = vi.fn(async (method: string, _params?: unknown) => {
if (method === "thread/start") {
return threadStartResult("thread-fresh");
}
throw new Error(`unexpected method: ${method}`);
});
const binding = await startOrResumeThread({
client: { request } as never,
params,
cwd: workspaceDir,
dynamicTools: [],
appServer: createThreadLifecycleAppServerOptions(),
});
expect(binding.threadId).toBe("thread-fresh");
expect(request.mock.calls.map(([method]) => method)).toEqual(["thread/start"]);
expect(request.mock.calls[0]?.[1]).toMatchObject({
config: { web_search: "disabled" },
});
});
it("starts a fresh Codex thread for hosted search restrictions on a legacy binding", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
await writeRawCodexAppServerBinding(sessionFile, {
threadId: "thread-legacy",
cwd: workspaceDir,
model: "gpt-5.5",
modelProvider: "openai",
dynamicToolsFingerprint: "[]",
});
const params = createParams(sessionFile, workspaceDir);
params.disableTools = false;
params.config = {
tools: {
web: {
search: { openaiCodex: { allowedDomains: ["example.com"] } },
},
},
};
const request = vi.fn(async (method: string, _params?: unknown) => {
if (method === "thread/start") {
return threadStartResult("thread-fresh");
}
throw new Error(`unexpected method: ${method}`);
});
const binding = await startOrResumeThread({
client: { request } as never,
params,
cwd: workspaceDir,
dynamicTools: [],
appServer: createThreadLifecycleAppServerOptions(),
});
expect(binding.threadId).toBe("thread-fresh");
expect(request.mock.calls.map(([method]) => method)).toEqual(["thread/start"]);
expect(request.mock.calls[0]?.[1]).toMatchObject({
config: {
web_search: "cached",
"tools.web_search.allowed_domains": ["example.com"],
},
});
});
it("starts a fresh Codex thread when an existing session enters tool-disabled mode", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
const params = createParams(sessionFile, workspaceDir);
params.disableTools = false;
const appServer = createThreadLifecycleAppServerOptions();
let starts = 0;
const request = vi.fn(async (method: string, requestParams?: unknown) => {
if (method === "thread/start") {
starts += 1;
return threadStartResult(`thread-${starts}`);
}
if (method === "thread/resume") {
// Resume must echo the requested thread; anything else is rejected as
// an unsafe subscription.
return threadStartResult((requestParams as { threadId: string }).threadId);
}
throw new Error(`unexpected method: ${method}`);
});
await startOrResumeThread({
client: { request } as never,
params,
cwd: workspaceDir,
dynamicTools: [],
appServer,
});
params.disableTools = true;
const restrictedBinding = await startOrResumeThread({
client: { request } as never,
params,
cwd: workspaceDir,
dynamicTools: [],
appServer,
});
const savedAfterRestriction = await readCodexAppServerBinding(sessionFile);
params.disableTools = false;
const resumedBinding = await startOrResumeThread({
client: { request } as never,
params,
cwd: workspaceDir,
dynamicTools: [],
appServer,
});
expect(restrictedBinding.threadId).toBe("thread-2");
expect(savedAfterRestriction?.threadId).toBe("thread-1");
expect(resumedBinding.threadId).toBe("thread-1");
expect(request.mock.calls.map(([method]) => method)).toEqual([
"thread/start",
"thread/start",
"thread/resume",
]);
expect(request.mock.calls[1]?.[1]).toMatchObject({
config: { web_search: "disabled" },
});
});
it("starts a fresh Codex thread when dynamic tools switch from deferred to direct", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
const params = createParams(sessionFile, workspaceDir);
const appServer = createThreadLifecycleAppServerOptions();
let starts = 0;
const request = vi.fn(async (method: string) => {
if (method === "thread/start") {
starts += 1;
return threadStartResult(`thread-${starts}`);
}
if (method === "thread/resume") {
return threadStartResult("thread-existing");
}
throw new Error(`unexpected method: ${method}`);
});
await startOrResumeThread({
client: { request } as never,
params,
cwd: workspaceDir,
dynamicTools: [createDeferredNamedDynamicTool("web_search")],
appServer,
});
const binding = await startOrResumeThread({
client: { request } as never,
params,
cwd: workspaceDir,
dynamicTools: [createNamedDynamicTool("web_search")],
appServer,
});
expect(binding.threadId).toBe("thread-2");
expect(request.mock.calls.map(([method]) => method)).toEqual(["thread/start", "thread/start"]);
});
it("resumes a bound Codex thread when dynamic tools are reordered", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
const params = createParams(sessionFile, workspaceDir);
const appServer = createThreadLifecycleAppServerOptions();
const request = vi.fn(async (method: string) => {
if (method === "thread/start") {
return threadStartResult("thread-existing");
}
if (method === "thread/resume") {
return threadStartResult("thread-existing");
}
throw new Error(`unexpected method: ${method}`);
});
await startOrResumeThread({
client: { request } as never,
params,
cwd: workspaceDir,
dynamicTools: [createNamedDynamicTool("wiki_status"), createNamedDynamicTool("diffs")],
appServer,
});
const binding = await startOrResumeThread({
client: { request } as never,
params,
cwd: workspaceDir,
dynamicTools: [createNamedDynamicTool("diffs"), createNamedDynamicTool("wiki_status")],
appServer,
});
expect(binding.threadId).toBe("thread-existing");
expect(request.mock.calls.map(([method]) => method)).toEqual(["thread/start", "thread/resume"]);
});
it("starts a fresh Codex thread for legacy context-engine sidecars without metadata", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
await writeCodexAppServerBinding(sessionFile, {
threadId: "thread-existing",
cwd: workspaceDir,
model: "gpt-5.4-codex",
modelProvider: "openai",
dynamicToolsFingerprint: "[]",
});
const params = createParams(sessionFile, workspaceDir);
params.contextEngine = {
info: { id: "lossless-claw", name: "Lossless Claw", ownsCompaction: true },
assemble: vi.fn(),
compact: vi.fn(),
} as never;
params.contextTokenBudget = 400_000;
const appServer = createThreadLifecycleAppServerOptions();
const request = vi.fn(async (method: string) => {
if (method === "thread/start") {
return threadStartResult("thread-fresh");
}
throw new Error(`unexpected method: ${method}`);
});
const binding = await startOrResumeThread({
client: { request } as never,
params,
cwd: workspaceDir,
dynamicTools: [],
appServer,
});
expect(binding.threadId).toBe("thread-fresh");
expect(binding.lifecycle).toEqual({
action: "started",
rotatedContextEngineBinding: true,
});
expect(request.mock.calls.map(([method]) => method)).toEqual(["thread/start"]);
const savedBinding = await readCodexAppServerBinding(sessionFile);
expect(savedBinding?.contextEngine?.engineId).toBe("lossless-claw");
expect(savedBinding?.contextEngine?.policyFingerprint).toContain('"contextTokenBudget":400000');
});
it("resumes a Codex thread when context-engine sidecar metadata is compatible", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
const contextEngine = {
schemaVersion: 1 as const,
engineId: "lossless-claw",
policyFingerprint:
'{"schemaVersion":1,"engineId":"lossless-claw","ownsCompaction":true,"contextTokenBudget":400000,"projectionMaxChars":1000000}',
};
await writeCodexAppServerBinding(sessionFile, {
threadId: "thread-existing",
cwd: workspaceDir,
model: "gpt-5.4-codex",
modelProvider: "openai",
dynamicToolsFingerprint: "[]",
contextEngine,
});
const params = createParams(sessionFile, workspaceDir);
params.contextEngine = {
info: { id: "lossless-claw", name: "Lossless Claw", ownsCompaction: true },
assemble: vi.fn(),
compact: vi.fn(),
} as never;
params.contextTokenBudget = 400_000;
const appServer = createThreadLifecycleAppServerOptions();
const request = vi.fn(async (method: string) => {
if (method === "thread/resume") {
return threadStartResult("thread-existing");
}
throw new Error(`unexpected method: ${method}`);
});
const binding = await startOrResumeThread({
client: { request } as never,
params,
cwd: workspaceDir,
dynamicTools: [],
appServer,
});
expect(binding.threadId).toBe("thread-existing");
expect(binding.lifecycle).toEqual({ action: "resumed" });
expect(request.mock.calls.map(([method]) => method)).toEqual(["thread/resume"]);
});
it("starts a fresh Codex thread when context-engine sidecar metadata is no longer active", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
await writeCodexAppServerBinding(sessionFile, {
threadId: "thread-existing",
cwd: workspaceDir,
model: "gpt-5.4-codex",
modelProvider: "openai",
dynamicToolsFingerprint: "[]",
contextEngine: {
schemaVersion: 1,
engineId: "lossless-claw",
policyFingerprint:
'{"schemaVersion":1,"engineId":"lossless-claw","ownsCompaction":true,"contextTokenBudget":400000,"projectionMaxChars":1000000}',
},
});
const params = createParams(sessionFile, workspaceDir);
const appServer = createThreadLifecycleAppServerOptions();
const request = vi.fn(async (method: string) => {
if (method === "thread/start") {
return threadStartResult("thread-fresh");
}
throw new Error(`unexpected method: ${method}`);
});
const binding = await startOrResumeThread({
client: { request } as never,
params,
cwd: workspaceDir,
dynamicTools: [],
appServer,
});
expect(binding.threadId).toBe("thread-fresh");
expect(binding.lifecycle).toEqual({
action: "started",
rotatedContextEngineBinding: true,
});
expect(request.mock.calls.map(([method]) => method)).toEqual(["thread/start"]);
const savedBinding = await readCodexAppServerBinding(sessionFile);
expect(savedBinding?.contextEngine).toBeUndefined();
});
it("starts a fresh Codex thread when context-engine policy metadata changes", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
await writeCodexAppServerBinding(sessionFile, {
threadId: "thread-existing",
cwd: workspaceDir,
model: "gpt-5.4-codex",
modelProvider: "openai",
dynamicToolsFingerprint: "[]",
contextEngine: {
schemaVersion: 1,
engineId: "lossless-claw",
policyFingerprint:
'{"schemaVersion":1,"engineId":"lossless-claw","engineVersion":"1.0.0","ownsCompaction":true,"turnMaintenanceMode":"foreground","citationsMode":"inline","contextTokenBudget":400000,"projectionMaxChars":1000000}',
},
});
const params = createParams(sessionFile, workspaceDir);
params.contextEngine = {
info: {
id: "lossless-claw",
name: "Lossless Claw",
version: "1.0.1",
ownsCompaction: true,
turnMaintenanceMode: "foreground",
},
assemble: vi.fn(),
compact: vi.fn(),
} as never;
params.config = { memory: { citations: "inline" } } as never;
params.contextTokenBudget = 400_000;
const appServer = createThreadLifecycleAppServerOptions();
const request = vi.fn(async (method: string) => {
if (method === "thread/start") {
return threadStartResult("thread-fresh");
}
throw new Error(`unexpected method: ${method}`);
});
const binding = await startOrResumeThread({
client: { request } as never,
params,
cwd: workspaceDir,
dynamicTools: [],
appServer,
});
expect(binding.threadId).toBe("thread-fresh");
expect(binding.lifecycle).toEqual({
action: "started",
rotatedContextEngineBinding: true,
});
expect(request.mock.calls.map(([method]) => method)).toEqual(["thread/start"]);
const savedBinding = await readCodexAppServerBinding(sessionFile);
expect(savedBinding?.contextEngine?.policyFingerprint).toContain('"engineVersion":"1.0.1"');
expect(savedBinding?.contextEngine?.policyFingerprint).toContain(
'"turnMaintenanceMode":"foreground"',
);
expect(savedBinding?.contextEngine?.policyFingerprint).toContain('"citationsMode":"inline"');
});
it("keeps the previous dynamic tool fingerprint for transient no-tool maintenance turns", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
const params = createParams(sessionFile, workspaceDir);
const appServer = createThreadLifecycleAppServerOptions();
let nextThread = 1;
const request = vi.fn(async (method: string) => {
if (method === "thread/start") {
return threadStartResult(`thread-${nextThread++}`);
}
if (method === "thread/resume") {
return threadStartResult("thread-1");
}
throw new Error(`unexpected method: ${method}`);
});
await startOrResumeThread({
client: { request } as never,
params,
cwd: workspaceDir,
dynamicTools: [createDeferredNamedDynamicTool("message")],
appServer,
});
const fingerprint = (await readCodexAppServerBinding(sessionFile))?.dynamicToolsFingerprint;
await startOrResumeThread({
client: { request } as never,
params,
cwd: workspaceDir,
dynamicTools: [],
appServer,
});
await startOrResumeThread({
client: { request } as never,
params,
cwd: workspaceDir,
dynamicTools: [createDeferredNamedDynamicTool("message")],
appServer,
});
const binding = await readCodexAppServerBinding(sessionFile);
expect(binding?.dynamicToolsFingerprint).toBe(fingerprint);
expect(binding?.dynamicToolsContainDeferred).toBe(true);
expect(binding?.threadId).toBe("thread-1");
expect(request.mock.calls.map(([method]) => method)).toEqual([
"thread/start",
"thread/start",
"thread/resume",
]);
});
it("keeps plugin app bindings across transient native-tool-disabled turns", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
const pluginAppPolicyContext = createPluginAppPolicyContext();
await writeCodexAppServerBinding(sessionFile, {
threadId: "thread-existing",
cwd: workspaceDir,
model: "gpt-5.4-codex",
modelProvider: "openai",
pluginAppsFingerprint: "plugin-apps-config-1",
pluginAppsInputFingerprint: "plugin-apps-input-1",
pluginAppPolicyContext,
});
const params = createParams(sessionFile, workspaceDir);
const appServer = createThreadLifecycleAppServerOptions();
const request = vi.fn(async (method: string) => {
if (method === "thread/start") {
return threadStartResult("thread-transient");
}
if (method === "thread/resume") {
return threadStartResult("thread-existing");
}
throw new Error(`unexpected method: ${method}`);
});
const buildDenyAllPluginThreadConfig = vi.fn(async () => ({
enabled: true,
configPatch: {
apps: {
_default: {
enabled: false,
destructive_enabled: false,
open_world_enabled: false,
},
},
},
fingerprint: "plugin-apps-deny-all",
inputFingerprint: "plugin-apps-input-deny-all",
policyContext: { fingerprint: "plugin-policy-deny-all", apps: {}, pluginAppIds: {} },
diagnostics: [],
}));
const buildEnabledPluginThreadConfig = vi.fn(async () => ({
enabled: true,
configPatch: createPluginAppConfigPatch(),
fingerprint: "plugin-apps-config-1",
inputFingerprint: "plugin-apps-input-1",
policyContext: pluginAppPolicyContext,
diagnostics: [],
}));
await startOrResumeThread({
client: { request } as never,
params,
cwd: workspaceDir,
dynamicTools: [],
appServer,
nativeCodeModeEnabled: false,
pluginThreadConfig: {
enabled: true,
inputFingerprint: "plugin-apps-input-deny-all",
enabledPluginConfigKeys: [],
build: buildDenyAllPluginThreadConfig,
},
});
const savedAfterDeny = await readCodexAppServerBinding(sessionFile);
expect(savedAfterDeny?.threadId).toBe("thread-existing");
expect(savedAfterDeny?.pluginAppsFingerprint).toBe("plugin-apps-config-1");
expect(savedAfterDeny?.pluginAppsInputFingerprint).toBe("plugin-apps-input-1");
await startOrResumeThread({
client: { request } as never,
params,
cwd: workspaceDir,
dynamicTools: [],
appServer,
pluginThreadConfig: {
enabled: true,
inputFingerprint: "plugin-apps-input-1",
enabledPluginConfigKeys: ["google-calendar"],
build: buildEnabledPluginThreadConfig,
},
});
expect(buildDenyAllPluginThreadConfig).toHaveBeenCalledTimes(1);
expect(buildEnabledPluginThreadConfig).toHaveBeenCalledTimes(1);
const requestCalls = request.mock.calls as unknown as Array<[string, { config?: unknown }]>;
expect(requestCalls.map(([method]) => method)).toEqual(["thread/start", "thread/resume"]);
expect(requestCalls[0]?.[1].config).toMatchObject({
apps: {
_default: {
enabled: false,
destructive_enabled: false,
open_world_enabled: false,
},
},
});
const savedAfterAllowed = await readCodexAppServerBinding(sessionFile);
expect(savedAfterAllowed?.threadId).toBe("thread-existing");
expect(savedAfterAllowed?.pluginAppsFingerprint).toBe("plugin-apps-config-1");
expect(savedAfterAllowed?.pluginAppsInputFingerprint).toBe("plugin-apps-input-1");
expect(savedAfterAllowed?.pluginAppPolicyContext).toEqual(pluginAppPolicyContext);
});
it("preserves the binding when the app-server closes during thread resume", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
await writeCodexAppServerBinding(sessionFile, {
threadId: "thread-existing",
cwd: workspaceDir,
model: "gpt-5.4-codex",
modelProvider: "openai",
dynamicToolsFingerprint: "[]",
});
const appServer = createThreadLifecycleAppServerOptions();
const request = vi.fn(async (method: string) => {
if (method === "thread/resume") {
throw new Error("codex app-server client is closed");
}
throw new Error(`unexpected method: ${method}`);
});
await expect(
startOrResumeThread({
client: { request } as never,
params: createParams(sessionFile, workspaceDir),
cwd: workspaceDir,
dynamicTools: [],
appServer,
}),
).rejects.toThrow("codex app-server client is closed");
expect(request.mock.calls.map(([method]) => method)).toEqual(["thread/resume"]);
const binding = await readCodexAppServerBinding(sessionFile);
expect(binding?.threadId).toBe("thread-existing");
});
it("starts a new thread when the network proxy config is not active on the binding", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
await writeCodexAppServerBinding(sessionFile, {
threadId: "thread-existing",
cwd: workspaceDir,
model: "gpt-5.4-codex",
modelProvider: "openai",
dynamicToolsFingerprint: "[]",
});
const appServer = createNetworkProxyThreadLifecycleAppServerOptions();
const request = vi.fn(async (method: string) => {
if (method === "thread/start") {
return threadStartResult("thread-network-proxy");
}
throw new Error(`unexpected method: ${method}`);
});
await startOrResumeThread({
client: { request } as never,
params: createParams(sessionFile, workspaceDir),
cwd: workspaceDir,
dynamicTools: [],
appServer,
});
const requestCalls = request.mock.calls as unknown as Array<[string, { config?: unknown }]>;
expect(requestCalls.map(([method]) => method)).toEqual(["thread/start"]);
expect(requestCalls[0]?.[1]).not.toHaveProperty("sandbox");
expect(requestCalls[0]?.[1].config).toMatchObject(appServer.networkProxy.configPatch);
const binding = await readCodexAppServerBinding(sessionFile);
expect(binding?.threadId).toBe("thread-network-proxy");
expect(binding?.networkProxyProfileName).toBe("openclaw-network");
expect(binding?.networkProxyConfigFingerprint).toBe(appServer.networkProxy.configFingerprint);
});
it("passes native hook relay config on thread start and resume", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
const params = createParams(sessionFile, workspaceDir);
const appServer = createThreadLifecycleAppServerOptions();
const request = vi.fn(async (method: string) => {
if (method === "thread/start") {
return threadStartResult("thread-existing");
}
if (method === "thread/resume") {
return threadStartResult("thread-existing");
}
throw new Error(`unexpected method: ${method}`);
});
const config = {
"features.hooks": true,
"hooks.PreToolUse": [],
};
const expectedConfig = {
...config,
...DEFAULT_CODEX_RUNTIME_THREAD_CONFIG,
};
await startOrResumeThread({
client: { request } as never,
params,
cwd: workspaceDir,
dynamicTools: [],
appServer,
config,
});
await startOrResumeThread({
client: { request } as never,
params,
cwd: workspaceDir,
dynamicTools: [],
appServer,
config,
});
const requestCalls = request.mock.calls as unknown as Array<[string, { config?: unknown }]>;
expect(requestCalls.map(([method]) => method)).toEqual(["thread/start", "thread/resume"]);
expect(requestCalls[0]?.[1].config).toEqual(expectedConfig);
expect(requestCalls[1]?.[1].config).toEqual(expectedConfig);
});
it("merges native hook relay config with plugin app config when starting a thread", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
const params = createParams(sessionFile, workspaceDir);
const appServer = createThreadLifecycleAppServerOptions();
const request = vi.fn(async (method: string) => {
if (method === "thread/start") {
return threadStartResult("thread-plugins");
}
throw new Error(`unexpected method: ${method}`);
});
const pluginAppPolicyContext = createPluginAppPolicyContext();
const buildPluginThreadConfig = vi.fn(async () => ({
enabled: true,
configPatch: createPluginAppConfigPatch(),
fingerprint: "plugin-apps-config-1",
inputFingerprint: "plugin-apps-input-1",
policyContext: pluginAppPolicyContext,
diagnostics: [],
}));
await startOrResumeThread({
client: { request } as never,
params,
cwd: workspaceDir,
dynamicTools: [],
appServer,
config: { "features.hooks": true, hooks: { PreToolUse: [] } },
pluginThreadConfig: {
enabled: true,
inputFingerprint: "plugin-apps-input-1",
enabledPluginConfigKeys: ["google-calendar"],
build: buildPluginThreadConfig,
},
});
expect(buildPluginThreadConfig).toHaveBeenCalledTimes(1);
const requestCalls = request.mock.calls as unknown as Array<[string, { config?: unknown }]>;
expect(requestCalls.map(([method]) => method)).toEqual(["thread/start"]);
expect(requestCalls[0]?.[1].config).toEqual({
"features.hooks": true,
...DEFAULT_CODEX_RUNTIME_THREAD_CONFIG,
hooks: { PreToolUse: [] },
...createPluginAppConfigPatch(),
});
const binding = await readCodexAppServerBinding(sessionFile);
expect(binding?.threadId).toBe("thread-plugins");
expect(binding?.pluginAppsFingerprint).toBe("plugin-apps-config-1");
expect(binding?.pluginAppsInputFingerprint).toBe("plugin-apps-input-1");
expect(binding?.pluginAppPolicyContext).toEqual(pluginAppPolicyContext);
});
it("keeps native hook relay config as the final thread config patch", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
const params = createParams(sessionFile, workspaceDir);
const appServer = createThreadLifecycleAppServerOptions();
const request = vi.fn(async (method: string) => {
if (method === "thread/start" || method === "thread/resume") {
return threadStartResult("thread-hooks");
}
throw new Error(`unexpected method: ${method}`);
});
const pluginAppPolicyContext = createPluginAppPolicyContext();
const finalConfigPatch = {
"features.hooks": true,
"hooks.PreToolUse": [
{
hooks: [{ type: "command", command: "openclaw-native-hook-relay", timeout: 5 }],
},
],
};
const buildPluginThreadConfig = vi.fn(async () => ({
enabled: true,
configPatch: {
"features.hooks": false,
"hooks.PreToolUse": [],
...createPluginAppConfigPatch(),
},
fingerprint: "plugin-apps-config-1",
inputFingerprint: "plugin-apps-input-1",
policyContext: pluginAppPolicyContext,
diagnostics: [],
}));
const pluginThreadConfig = {
enabled: true,
inputFingerprint: "plugin-apps-input-1",
build: buildPluginThreadConfig,
};
await startOrResumeThread({
client: { request } as never,
params,
cwd: workspaceDir,
dynamicTools: [],
appServer,
config: { "features.hooks": false },
finalConfigPatch,
pluginThreadConfig,
});
await startOrResumeThread({
client: { request } as never,
params,
cwd: workspaceDir,
dynamicTools: [],
appServer,
config: { "features.hooks": false },
finalConfigPatch,
pluginThreadConfig: {
...pluginThreadConfig,
enabledPluginConfigKeys: ["google-calendar"],
},
});
const requestCalls = request.mock.calls as unknown as Array<[string, { config?: unknown }]>;
expect(requestCalls.map(([method]) => method)).toEqual(["thread/start", "thread/resume"]);
expect(requestCalls[0]?.[1].config).toMatchObject({
"features.hooks": true,
...DEFAULT_CODEX_RUNTIME_THREAD_CONFIG,
"hooks.PreToolUse": finalConfigPatch["hooks.PreToolUse"],
...createPluginAppConfigPatch(),
});
expect(requestCalls[1]?.[1].config).toMatchObject({
"features.hooks": true,
...DEFAULT_CODEX_RUNTIME_THREAD_CONFIG,
"hooks.PreToolUse": finalConfigPatch["hooks.PreToolUse"],
});
});
it("replays compatible plugin app bindings on thread resume", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
const params = createParams(sessionFile, workspaceDir);
const appServer = {
...createThreadLifecycleAppServerOptions(),
approvalsReviewer: "auto_review" as const,
};
const request = vi.fn(async (method: string) => {
if (method === "thread/start" || method === "thread/resume") {
return threadStartResult("thread-plugins");
}
throw new Error(`unexpected method: ${method}`);
});
const basePolicyContext = createPluginAppPolicyContext();
const pluginAppPolicyContext = {
...basePolicyContext,
apps: {
...basePolicyContext.apps,
"google-calendar-app": {
...basePolicyContext.apps["google-calendar-app"],
destructiveApprovalMode: "ask" as const,
},
},
};
const askApprovalConfigPatch = createPluginAppConfigPatch({ approvalsReviewer: "user" });
const buildPluginThreadConfig = vi.fn(async () => ({
enabled: true,
configPatch: askApprovalConfigPatch,
fingerprint: "plugin-apps-config-1",
inputFingerprint: "plugin-apps-input-1",
policyContext: pluginAppPolicyContext,
diagnostics: [],
}));
await startOrResumeThread({
client: { request } as never,
params,
cwd: workspaceDir,
dynamicTools: [],
appServer,
config: { "features.hooks": true },
pluginThreadConfig: {
enabled: true,
inputFingerprint: "plugin-apps-input-1",
build: buildPluginThreadConfig,
},
});
const binding = await startOrResumeThread({
client: { request } as never,
params,
cwd: workspaceDir,
dynamicTools: [],
appServer,
config: { "features.hooks": true },
pluginThreadConfig: {
enabled: true,
inputFingerprint: "plugin-apps-input-1",
enabledPluginConfigKeys: ["google-calendar"],
build: buildPluginThreadConfig,
},
});
expect(binding.pluginAppPolicyContext).toEqual(pluginAppPolicyContext);
expect(buildPluginThreadConfig).toHaveBeenCalledTimes(2);
const requestCalls = request.mock.calls as unknown as Array<
[string, { approvalsReviewer?: string; config?: unknown }]
>;
expect(requestCalls.map(([method]) => method)).toEqual(["thread/start", "thread/resume"]);
expect(requestCalls.map(([, requestParams]) => requestParams.approvalsReviewer)).toEqual([
"auto_review",
"auto_review",
]);
expect(requestCalls[0]?.[1].config).toEqual({
"features.hooks": true,
...DEFAULT_CODEX_RUNTIME_THREAD_CONFIG,
...askApprovalConfigPatch,
});
expect(requestCalls[1]?.[1].config).toEqual({
"features.hooks": true,
...DEFAULT_CODEX_RUNTIME_THREAD_CONFIG,
...askApprovalConfigPatch,
});
});
it("starts a new plugin app thread when full binding revalidation removes an app", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
await writeCodexAppServerBinding(sessionFile, {
threadId: "thread-existing",
cwd: workspaceDir,
model: "gpt-5.4-codex",
modelProvider: "openai",
dynamicToolsFingerprint: "[]",
pluginAppsFingerprint: "plugin-apps-config-1",
pluginAppsInputFingerprint: "plugin-apps-input-1",
pluginAppPolicyContext: createPluginAppPolicyContext(),
});
const params = createParams(sessionFile, workspaceDir);
const appServer = createThreadLifecycleAppServerOptions();
const request = vi.fn(async (method: string) => {
if (method === "thread/start") {
return threadStartResult("thread-revalidated");
}
throw new Error(`unexpected method: ${method}`);
});
const emptyPolicyContext = { fingerprint: "plugin-policy-empty", apps: {}, pluginAppIds: {} };
const buildPluginThreadConfig = vi.fn(async () => ({
enabled: true,
configPatch: {
apps: {
_default: {
enabled: false,
destructive_enabled: false,
open_world_enabled: false,
},
},
},
fingerprint: "plugin-apps-empty",
inputFingerprint: "plugin-apps-input-1",
policyContext: emptyPolicyContext,
diagnostics: [],
}));
await startOrResumeThread({
client: { request } as never,
params,
cwd: workspaceDir,
dynamicTools: [],
appServer,
pluginThreadConfig: {
enabled: true,
inputFingerprint: "plugin-apps-input-1",
enabledPluginConfigKeys: ["google-calendar"],
build: buildPluginThreadConfig,
},
});
expect(buildPluginThreadConfig).toHaveBeenCalledTimes(1);
const requestCalls = request.mock.calls as unknown as Array<[string, { config?: unknown }]>;
expect(requestCalls.map(([method]) => method)).toEqual(["thread/start"]);
expect(requestCalls[0]?.[1].config).toEqual({
...DEFAULT_CODEX_RUNTIME_THREAD_CONFIG,
apps: {
_default: {
enabled: false,
destructive_enabled: false,
open_world_enabled: false,
},
},
});
const binding = await readCodexAppServerBinding(sessionFile);
expect(binding?.threadId).toBe("thread-revalidated");
expect(binding?.pluginAppsFingerprint).toBe("plugin-apps-empty");
expect(binding?.pluginAppPolicyContext).toEqual(emptyPolicyContext);
});
it("keeps the existing plugin app binding when revalidation fails", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
const pluginAppPolicyContext = createPluginAppPolicyContext();
await writeCodexAppServerBinding(sessionFile, {
threadId: "thread-existing",
cwd: workspaceDir,
model: "gpt-5.4-codex",
modelProvider: "openai",
dynamicToolsFingerprint: "[]",
pluginAppsFingerprint: "plugin-apps-config-1",
pluginAppsInputFingerprint: "plugin-apps-input-1",
pluginAppPolicyContext,
});
const params = createParams(sessionFile, workspaceDir);
const appServer = createThreadLifecycleAppServerOptions();
const request = vi.fn(async (method: string) => {
if (method === "thread/resume") {
return threadStartResult("thread-existing");
}
throw new Error(`unexpected method: ${method}`);
});
await startOrResumeThread({
client: { request } as never,
params,
cwd: workspaceDir,
dynamicTools: [],
appServer,
pluginThreadConfig: {
enabled: true,
inputFingerprint: "plugin-apps-input-1",
enabledPluginConfigKeys: ["google-calendar"],
build: async () => {
throw new Error("plugin inventory unavailable");
},
},
});
const requestCalls = request.mock.calls as unknown as Array<[string, { config?: unknown }]>;
expect(requestCalls.map(([method]) => method)).toEqual(["thread/resume"]);
expect(requestCalls[0]?.[1].config).toEqual({
...DEFAULT_CODEX_RUNTIME_THREAD_CONFIG,
...createPluginAppConfigPatch(),
});
const binding = await readCodexAppServerBinding(sessionFile);
expect(binding?.threadId).toBe("thread-existing");
expect(binding?.pluginAppsFingerprint).toBe("plugin-apps-config-1");
expect(binding?.pluginAppsInputFingerprint).toBe("plugin-apps-input-1");
expect(binding?.pluginAppPolicyContext).toEqual(pluginAppPolicyContext);
});
it("rebuilds an empty plugin app binding after app inventory recovers", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
await writeCodexAppServerBinding(sessionFile, {
threadId: "thread-existing",
cwd: workspaceDir,
model: "gpt-5.4-codex",
modelProvider: "openai",
dynamicToolsFingerprint: "[]",
pluginAppsFingerprint: "plugin-apps-empty",
pluginAppsInputFingerprint: "plugin-apps-input-1",
pluginAppPolicyContext: { fingerprint: "plugin-policy-empty", apps: {}, pluginAppIds: {} },
});
const params = createParams(sessionFile, workspaceDir);
const appServer = createThreadLifecycleAppServerOptions();
const request = vi.fn(async (method: string) => {
if (method === "thread/start") {
return threadStartResult("thread-recovered");
}
throw new Error(`unexpected method: ${method}`);
});
const pluginAppPolicyContext = createPluginAppPolicyContext();
const buildPluginThreadConfig = vi.fn(async () => ({
enabled: true,
configPatch: createPluginAppConfigPatch(),
fingerprint: "plugin-apps-config-1",
inputFingerprint: "plugin-apps-input-1",
policyContext: pluginAppPolicyContext,
diagnostics: [],
}));
await startOrResumeThread({
client: { request } as never,
params,
cwd: workspaceDir,
dynamicTools: [],
appServer,
pluginThreadConfig: {
enabled: true,
inputFingerprint: "plugin-apps-input-1",
build: buildPluginThreadConfig,
},
});
expect(buildPluginThreadConfig).toHaveBeenCalledTimes(1);
const requestCalls = request.mock.calls as unknown as Array<[string, { config?: unknown }]>;
expect(requestCalls.map(([method]) => method)).toEqual(["thread/start"]);
expect(requestCalls[0]?.[1].config).toEqual({
...createPluginAppConfigPatch(),
...DEFAULT_CODEX_RUNTIME_THREAD_CONFIG,
});
const binding = await readCodexAppServerBinding(sessionFile);
expect(binding?.threadId).toBe("thread-recovered");
expect(binding?.pluginAppsFingerprint).toBe("plugin-apps-config-1");
expect(binding?.pluginAppPolicyContext).toEqual(pluginAppPolicyContext);
});
it("keeps an empty plugin app binding when recovery still produces the same config", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
const emptyPolicyContext = { fingerprint: "plugin-policy-empty", apps: {}, pluginAppIds: {} };
await writeCodexAppServerBinding(sessionFile, {
threadId: "thread-existing",
cwd: workspaceDir,
model: "gpt-5.4-codex",
modelProvider: "openai",
dynamicToolsFingerprint: "[]",
pluginAppsFingerprint: "plugin-apps-empty",
pluginAppsInputFingerprint: "plugin-apps-input-1",
pluginAppPolicyContext: emptyPolicyContext,
});
const params = createParams(sessionFile, workspaceDir);
const appServer = createThreadLifecycleAppServerOptions();
const request = vi.fn(async (method: string) => {
if (method === "thread/resume") {
return threadStartResult("thread-existing");
}
throw new Error(`unexpected method: ${method}`);
});
const buildPluginThreadConfig = vi.fn(async () => ({
enabled: true,
configPatch: {
apps: {
_default: {
enabled: false,
destructive_enabled: false,
open_world_enabled: false,
},
},
},
fingerprint: "plugin-apps-empty",
inputFingerprint: "plugin-apps-input-1",
policyContext: emptyPolicyContext,
diagnostics: [],
}));
await startOrResumeThread({
client: { request } as never,
params,
cwd: workspaceDir,
dynamicTools: [],
appServer,
pluginThreadConfig: {
enabled: true,
inputFingerprint: "plugin-apps-input-1",
build: buildPluginThreadConfig,
},
});
expect(buildPluginThreadConfig).toHaveBeenCalledTimes(1);
const requestCalls = request.mock.calls as unknown as Array<[string, { config?: unknown }]>;
expect(requestCalls.map(([method]) => method)).toEqual(["thread/resume"]);
expect(requestCalls[0]?.[1].config).toEqual({
...DEFAULT_CODEX_RUNTIME_THREAD_CONFIG,
apps: {
_default: {
enabled: false,
destructive_enabled: false,
open_world_enabled: false,
},
},
});
});
it("rebuilds a partial plugin app binding after another plugin recovers", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
await writeCodexAppServerBinding(sessionFile, {
threadId: "thread-existing",
cwd: workspaceDir,
model: "gpt-5.4-codex",
modelProvider: "openai",
dynamicToolsFingerprint: "[]",
pluginAppsFingerprint: "plugin-apps-partial",
pluginAppsInputFingerprint: "plugin-apps-input-1",
pluginAppPolicyContext: createPluginAppPolicyContext(),
});
const params = createParams(sessionFile, workspaceDir);
const appServer = createThreadLifecycleAppServerOptions();
const request = vi.fn(async (method: string) => {
if (method === "thread/start") {
return threadStartResult("thread-recovered");
}
throw new Error(`unexpected method: ${method}`);
});
const recoveredPolicyContext = createTwoPluginAppPolicyContext();
const buildPluginThreadConfig = vi.fn(async () => ({
enabled: true,
configPatch: createTwoPluginAppConfigPatch(),
fingerprint: "plugin-apps-config-2",
inputFingerprint: "plugin-apps-input-1",
policyContext: recoveredPolicyContext,
diagnostics: [],
}));
await startOrResumeThread({
client: { request } as never,
params,
cwd: workspaceDir,
dynamicTools: [],
appServer,
pluginThreadConfig: {
enabled: true,
inputFingerprint: "plugin-apps-input-1",
enabledPluginConfigKeys: ["google-calendar", "gmail"],
build: buildPluginThreadConfig,
},
});
expect(buildPluginThreadConfig).toHaveBeenCalledTimes(1);
const requestCalls = request.mock.calls as unknown as Array<[string, { config?: unknown }]>;
expect(requestCalls.map(([method]) => method)).toEqual(["thread/start"]);
expect(requestCalls[0]?.[1].config).toEqual({
...createTwoPluginAppConfigPatch(),
...DEFAULT_CODEX_RUNTIME_THREAD_CONFIG,
});
const binding = await readCodexAppServerBinding(sessionFile);
expect(binding?.threadId).toBe("thread-recovered");
expect(binding?.pluginAppsFingerprint).toBe("plugin-apps-config-2");
expect(binding?.pluginAppPolicyContext).toEqual(recoveredPolicyContext);
});
it("rebuilds a partial plugin app binding after another app from the same plugin recovers", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
await writeCodexAppServerBinding(sessionFile, {
threadId: "thread-existing",
cwd: workspaceDir,
model: "gpt-5.4-codex",
modelProvider: "openai",
dynamicToolsFingerprint: "[]",
pluginAppsFingerprint: "plugin-apps-partial",
pluginAppsInputFingerprint: "plugin-apps-input-1",
pluginAppPolicyContext: {
...createPluginAppPolicyContext(),
pluginAppIds: {
"google-calendar": ["google-calendar-app", "google-calendar-secondary-app"],
},
},
});
const params = createParams(sessionFile, workspaceDir);
const appServer = createThreadLifecycleAppServerOptions();
const request = vi.fn(async (method: string) => {
if (method === "thread/start") {
return threadStartResult("thread-recovered");
}
throw new Error(`unexpected method: ${method}`);
});
const recoveredPolicyContext = createTwoCalendarAppPolicyContext();
const buildPluginThreadConfig = vi.fn(async () => ({
enabled: true,
configPatch: createTwoCalendarAppConfigPatch(),
fingerprint: "plugin-apps-config-calendar-2",
inputFingerprint: "plugin-apps-input-1",
policyContext: recoveredPolicyContext,
diagnostics: [],
}));
await startOrResumeThread({
client: { request } as never,
params,
cwd: workspaceDir,
dynamicTools: [],
appServer,
pluginThreadConfig: {
enabled: true,
inputFingerprint: "plugin-apps-input-1",
enabledPluginConfigKeys: ["google-calendar"],
build: buildPluginThreadConfig,
},
});
expect(buildPluginThreadConfig).toHaveBeenCalledTimes(1);
const requestCalls = request.mock.calls as unknown as Array<[string, { config?: unknown }]>;
expect(requestCalls.map(([method]) => method)).toEqual(["thread/start"]);
expect(requestCalls[0]?.[1].config).toEqual({
...createTwoCalendarAppConfigPatch(),
...DEFAULT_CODEX_RUNTIME_THREAD_CONFIG,
});
const binding = await readCodexAppServerBinding(sessionFile);
expect(binding?.threadId).toBe("thread-recovered");
expect(binding?.pluginAppsFingerprint).toBe("plugin-apps-config-calendar-2");
expect(binding?.pluginAppPolicyContext).toEqual(recoveredPolicyContext);
});
it("starts a new configured thread for legacy bindings missing plugin app metadata", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
await writeCodexAppServerBinding(sessionFile, {
threadId: "thread-existing",
cwd: workspaceDir,
model: "gpt-5.4-codex",
modelProvider: "openai",
dynamicToolsFingerprint: "[]",
});
const params = createParams(sessionFile, workspaceDir);
const appServer = createThreadLifecycleAppServerOptions();
const request = vi.fn(async (method: string) => {
if (method === "thread/start") {
return threadStartResult("thread-plugins");
}
throw new Error(`unexpected method: ${method}`);
});
const pluginAppPolicyContext = createPluginAppPolicyContext();
await startOrResumeThread({
client: { request } as never,
params,
cwd: workspaceDir,
dynamicTools: [],
appServer,
pluginThreadConfig: {
enabled: true,
inputFingerprint: "plugin-apps-input-1",
build: async () => ({
enabled: true,
configPatch: createPluginAppConfigPatch(),
fingerprint: "plugin-apps-config-1",
inputFingerprint: "plugin-apps-input-1",
policyContext: pluginAppPolicyContext,
diagnostics: [],
}),
},
});
const requestCalls = request.mock.calls as unknown as Array<[string, { config?: unknown }]>;
expect(requestCalls.map(([method]) => method)).toEqual(["thread/start"]);
expect(requestCalls[0]?.[1].config).toEqual({
...createPluginAppConfigPatch(),
...DEFAULT_CODEX_RUNTIME_THREAD_CONFIG,
});
const binding = await readCodexAppServerBinding(sessionFile);
expect(binding?.threadId).toBe("thread-plugins");
expect(binding?.pluginAppsFingerprint).toBe("plugin-apps-config-1");
expect(binding?.pluginAppPolicyContext).toEqual(pluginAppPolicyContext);
});
it("starts a new Codex thread when dynamic tool schemas change", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
const params = createParams(sessionFile, workspaceDir);
const appServer = createThreadLifecycleAppServerOptions();
let nextThread = 1;
const request = vi.fn(async (method: string) => {
if (method === "thread/start") {
return threadStartResult(`thread-${nextThread++}`);
}
throw new Error(`unexpected method: ${method}`);
});
await startOrResumeThread({
client: { request } as never,
params,
cwd: workspaceDir,
dynamicTools: [createMessageDynamicTool("Send and manage messages.", ["send"])],
appServer,
});
const binding = await startOrResumeThread({
client: { request } as never,
params,
cwd: workspaceDir,
dynamicTools: [createMessageDynamicTool("Send and manage messages.", ["send", "read"])],
appServer,
});
expect(binding.threadId).toBe("thread-2");
expect(request.mock.calls.map(([method]) => method)).toEqual(["thread/start", "thread/start"]);
});
it("preserves the bound auth profile when resume params omit authProfileId", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
await writeCodexAppServerBinding(sessionFile, {
threadId: "thread-existing",
cwd: workspaceDir,
model: "gpt-5.4-codex",
modelProvider: "openai",
authProfileId: "openai:bound",
});
const params = createParams(sessionFile, workspaceDir);
delete params.authProfileId;
params.agentDir = path.join(tempDir, "agent");
params.authProfileStore = {
version: 1,
profiles: {
"openai:bound": {
type: "oauth",
provider: "openai",
access: "scoped-access",
refresh: "scoped-refresh",
expires: Date.now() + 60_000,
},
},
};
const binding = await startOrResumeThread({
client: {
request: async (method: string) => {
if (method === "thread/resume") {
return threadStartResult("thread-existing");
}
throw new Error(`unexpected method: ${method}`);
},
} as never,
params,
cwd: workspaceDir,
dynamicTools: [],
appServer: {
start: {
transport: "stdio",
command: "codex",
args: ["app-server"],
headers: {},
},
codeModeOnly: false,
requestTimeoutMs: 60_000,
turnCompletionIdleTimeoutMs: 60_000,
approvalPolicy: "never",
approvalsReviewer: "user",
sandbox: "workspace-write",
connectionClass: "local-loopback",
remoteAppsSubstrate: "preconfigured",
},
});
expect(binding.authProfileId).toBe("openai:bound");
expect(binding.modelProvider).toBeUndefined();
});
});