Files
openclaw/extensions/codex/src/app-server/thread-lifecycle.binding.test.ts
Kevin Lin bc5081c587 feat(codex): support remote app-server plugins (#94203)
* feat(codex): support remote app-server plugins

* fix(codex): refresh remote plugin apps after install

* fix(codex): infer remote app-server topology

* docs(codex): document remote app-server plugin config

* fix(codex): remove remote mutation policy

* fix(codex): simplify remote workspace root

* docs(codex): fix remote app-server PR docs
2026-06-17 13:52:34 -07:00

2284 lines
79 KiB
TypeScript

// Codex tests cover thread lifecycle.binding plugin behavior.
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import type { CodexDynamicToolFunctionSpec } from "./protocol.js";
import {
createParams as createRunAttemptParams,
setupRunAttemptTestHooks,
tempDir,
threadStartResult,
} from "./run-attempt-test-harness.js";
import {
readCodexAppServerBinding,
writeCodexAppServerBinding as writeRawCodexAppServerBinding,
} from "./session-binding.js";
import { fingerprintCodexAppServerNetworkProxyConfigPatch } from "./config.js";
import {
shouldRotateCodexAppServerBindingForRuntime,
startOrResumeThread,
} from "./thread-lifecycle.js";
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, lookup] = args;
return writeRawCodexAppServerBinding(
sessionFile,
{
webSearchThreadConfigFingerprint: DEFAULT_CODEX_WEB_SEARCH_THREAD_CONFIG_FINGERPRINT,
...binding,
},
lookup,
);
}
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() {
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",
},
},
};
}
function createPluginAppPolicyContext() {
return {
fingerprint: "plugin-policy-1",
apps: {
"google-calendar-app": {
configKey: "google-calendar",
marketplaceName: "openai-curated" as const,
pluginName: "google-calendar",
allowDestructiveActions: false,
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("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("sends legacy flat 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-flat-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({
name: "message",
description: "Send a message.",
}),
expect.objectContaining({
name: "web_search",
namespace: "openclaw",
deferLoading: true,
}),
]);
expect(startParams?.dynamicTools?.[0]).not.toHaveProperty("type");
expect(startParams?.dynamicTools?.[1]).not.toHaveProperty("type");
});
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") {
throw new Error("stale thread");
}
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/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 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, _params?: unknown) => {
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,
});
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, _params?: unknown) => {
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: [],
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-existing");
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("revalidates compatible plugin app bindings without resending app config", 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-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 },
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, { config?: unknown }]>;
expect(requestCalls.map(([method]) => method)).toEqual(["thread/start", "thread/resume"]);
expect(requestCalls[0]?.[1].config).toEqual({
"features.hooks": true,
...DEFAULT_CODEX_RUNTIME_THREAD_CONFIG,
...createPluginAppConfigPatch(),
});
expect(requestCalls[1]?.[1].config).toEqual({
"features.hooks": true,
...DEFAULT_CODEX_RUNTIME_THREAD_CONFIG,
});
});
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,
});
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,
});
});
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");
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");
});
});