Files
openclaw/extensions/codex/src/app-server/side-question.test.ts
keshavbotagent fcf2852f0f fix(codex): prevent post-tool edit stream timeouts
Keep Codex post-tool assistant/commentary progress and patch snapshot updates on the post-tool completion guard so long generated edits do not fall back to terminal idle handling. Enable Codex patch streaming events for native code mode and refresh exact prompt/config expectations.

Verification:
- pnpm prompt:snapshots:check
- pnpm test extensions/codex/src/app-server/run-attempt.turn-watches.test.ts extensions/codex/src/app-server/thread-lifecycle.test.ts extensions/codex/src/app-server/thread-lifecycle.binding.test.ts extensions/codex/src/app-server/side-question.test.ts
- .agents/skills/autoreview/scripts/autoreview --mode branch --base origin/main
- git diff --check origin/main...HEAD
- exact-head CI run 26677938955
- Real behavior proof override run 26678097960

Thanks @keshavbotagent.

Co-authored-by: Kelaw - Keshav's Agent <keshavbotagent@gmail.com>
2026-05-30 09:30:09 +02:00

1301 lines
46 KiB
TypeScript

import { nativeHookRelayTesting } from "openclaw/plugin-sdk/agent-harness-runtime";
import {
onInternalDiagnosticEvent,
resetDiagnosticEventsForTest,
type DiagnosticEventPayload,
} from "openclaw/plugin-sdk/diagnostic-runtime";
import {
initializeGlobalHookRunner,
resetGlobalHookRunner,
} from "openclaw/plugin-sdk/hook-runtime";
import { createMockPluginRegistry } from "openclaw/plugin-sdk/plugin-test-runtime";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { CodexServerNotification, JsonObject, RpcRequest } from "./protocol.js";
const readCodexAppServerBindingMock = vi.fn();
const isCodexAppServerNativeAuthProfileMock = vi.fn();
const getSharedCodexAppServerClientMock = vi.fn();
const refreshCodexAppServerAuthTokensMock = vi.fn();
const createOpenClawCodingToolsMock = vi.fn();
const toolExecuteMock = vi.fn();
const handleCodexAppServerApprovalRequestMock = vi.fn();
vi.mock("./session-binding.js", () => ({
clearCodexAppServerBinding: vi.fn(),
isCodexAppServerNativeAuthProfile: (...args: unknown[]) =>
isCodexAppServerNativeAuthProfileMock(...args),
readCodexAppServerBinding: (...args: unknown[]) => readCodexAppServerBindingMock(...args),
writeCodexAppServerBinding: vi.fn(),
}));
vi.mock("./shared-client.js", () => ({
getSharedCodexAppServerClient: (...args: unknown[]) => getSharedCodexAppServerClientMock(...args),
getLeasedSharedCodexAppServerClient: (...args: unknown[]) =>
getSharedCodexAppServerClientMock(...args),
releaseLeasedSharedCodexAppServerClient: vi.fn(),
}));
vi.mock("./auth-bridge.js", () => ({
refreshCodexAppServerAuthTokens: (...args: unknown[]) =>
refreshCodexAppServerAuthTokensMock(...args),
}));
vi.mock("./approval-bridge.js", () => ({
handleCodexAppServerApprovalRequest: (...args: unknown[]) =>
handleCodexAppServerApprovalRequestMock(...args),
}));
vi.mock("openclaw/plugin-sdk/agent-harness", () => ({
createOpenClawCodingTools: (...args: unknown[]) => createOpenClawCodingToolsMock(...args),
}));
const { testing, runCodexAppServerSideQuestion } = await import("./side-question.js");
type ServerRequest = Required<Pick<RpcRequest, "id" | "method">> & {
params?: RpcRequest["params"];
};
type FakeClient = {
request: ReturnType<typeof vi.fn>;
addNotificationHandler: ReturnType<typeof vi.fn>;
addRequestHandler: ReturnType<typeof vi.fn>;
notifications: Array<(notification: CodexServerNotification) => void>;
requests: Array<(request: ServerRequest) => unknown>;
emit: (notification: CodexServerNotification) => void;
handleRequest: (request: ServerRequest) => Promise<unknown>;
};
function createFakeClient(): FakeClient {
const notifications: FakeClient["notifications"] = [];
const requests: FakeClient["requests"] = [];
const client: FakeClient = {
notifications,
requests,
request: vi.fn(),
addNotificationHandler: vi.fn((handler: (notification: CodexServerNotification) => void) => {
notifications.push(handler);
return () => {
const index = notifications.indexOf(handler);
if (index >= 0) {
notifications.splice(index, 1);
}
};
}),
addRequestHandler: vi.fn((handler: FakeClient["requests"][number]) => {
requests.push(handler);
return () => {
const index = requests.indexOf(handler);
if (index >= 0) {
requests.splice(index, 1);
}
};
}),
emit: (notification) => {
for (const handler of notifications) {
handler(notification);
}
},
handleRequest: async (request) => {
for (const handler of requests) {
const result = await handler(request);
if (result !== undefined) {
return result;
}
}
return undefined;
},
};
client.request.mockImplementation(async (method: string) => {
if (method === "thread/fork") {
return threadResult("side-thread");
}
if (method === "thread/inject_items") {
return {};
}
if (method === "turn/start") {
queueMicrotask(() => {
client.emit(agentDelta("side-thread", "turn-1", "Side answer."));
client.emit(turnCompleted("side-thread", "turn-1", "Side answer."));
});
return turnStartResult("turn-1");
}
if (method === "thread/unsubscribe" || method === "turn/interrupt") {
return {};
}
throw new Error(`unexpected request: ${method}`);
});
return client;
}
function mockCall(mock: ReturnType<typeof vi.fn>, index = 0): unknown[] {
const call = mock.mock.calls.at(index);
if (!call) {
throw new Error(`Expected mock call ${index}`);
}
return call;
}
function flushDiagnosticEvents() {
return new Promise<void>((resolve) => setImmediate(resolve));
}
function activeDiagnosticToolKeys(events: DiagnosticEventPayload[]): Set<string> {
const active = new Set<string>();
for (const event of events) {
if (event.type === "tool.execution.started") {
active.add(
`${event.runId ?? event.sessionId ?? event.sessionKey ?? "unknown"}:${event.toolCallId ?? event.toolName}`,
);
} else if (
event.type === "tool.execution.completed" ||
event.type === "tool.execution.error" ||
event.type === "tool.execution.blocked"
) {
active.delete(
`${event.runId ?? event.sessionId ?? event.sessionKey ?? "unknown"}:${event.toolCallId ?? event.toolName}`,
);
}
}
return active;
}
function extractRelayIdFromThreadConfig(config: unknown): string {
const record = config as Record<string, unknown> | undefined;
let command: string | undefined;
for (const key of [
"hooks.PreToolUse",
"hooks.PostToolUse",
"hooks.PermissionRequest",
"hooks.Stop",
]) {
const entries = record?.[key];
if (!Array.isArray(entries)) {
continue;
}
for (const entry of entries as Array<{ hooks?: Array<{ command?: string }> }>) {
command = entry.hooks?.find((hook) => typeof hook.command === "string")?.command;
if (command) {
break;
}
}
if (command) {
break;
}
}
const match = command?.match(/--relay-id ([^ ]+)/);
if (!match?.[1]) {
throw new Error(`relay id missing from command: ${command}`);
}
return match[1];
}
function codexHookCommand(config: unknown, key: string) {
const entries = (config as Record<string, unknown> | undefined)?.[key];
if (!Array.isArray(entries)) {
return undefined;
}
return (
entries as Array<{ hooks?: Array<{ command?: string; timeout?: number; type?: string }> }>
)
.at(0)
?.hooks?.at(0);
}
function codexHookStateForEvent(
hookState: Record<string, { enabled?: unknown; trusted_hash?: unknown }> | undefined,
event: string,
) {
return Object.entries(hookState ?? {}).find(([key]) => key.endsWith(`:${event}:0:0`))?.[1];
}
function threadResult(threadId: string) {
return {
thread: {
id: threadId,
sessionId: threadId,
forkedFromId: null,
preview: "",
ephemeral: true,
modelProvider: "openai",
createdAt: 1,
updatedAt: 1,
status: { type: "idle" },
path: null,
cwd: "/tmp/workspace",
cliVersion: "0.125.0",
source: "unknown",
agentNickname: null,
agentRole: null,
gitInfo: null,
name: null,
turns: [],
},
model: "gpt-5.5",
modelProvider: "openai",
cwd: "/tmp/workspace",
approvalPolicy: "on-request",
approvalsReviewer: "user",
sandbox: { type: "dangerFullAccess" },
};
}
function turnStartResult(turnId: string) {
return {
turn: {
id: turnId,
threadId: "side-thread",
status: "inProgress",
items: [],
error: null,
startedAt: null,
completedAt: null,
durationMs: null,
},
};
}
function agentDelta(threadId: string, turnId: string, delta: string): CodexServerNotification {
return {
method: "item/agentMessage/delta",
params: { threadId, turnId, itemId: "agent-1", delta },
};
}
function turnCompleted(threadId: string, turnId: string, text: string): CodexServerNotification {
return {
method: "turn/completed",
params: {
threadId,
turn: {
id: turnId,
threadId,
status: "completed",
items: [{ id: "agent-1", type: "agentMessage", text }],
error: null,
startedAt: null,
completedAt: null,
durationMs: null,
},
},
};
}
function turnCompletedWithNestedThread(
threadId: string,
turnId: string,
text: string,
): CodexServerNotification {
const notification = turnCompleted(threadId, turnId, text);
const turn = (notification.params as JsonObject).turn;
return { method: notification.method, params: { threadId: "parent-thread", turn } };
}
function sideParams(overrides: Partial<Parameters<typeof runCodexAppServerSideQuestion>[0]> = {}) {
return {
cfg: {} as never,
agentDir: "/tmp/agent",
provider: "openai",
model: "gpt-5.5",
question: "What changed?",
sessionEntry: {
sessionId: "session-1",
sessionFile: "/tmp/session-1.jsonl",
updatedAt: 1,
},
resolvedReasoningLevel: "off",
opts: {},
isNewSession: false,
sessionId: "session-1",
sessionFile: "/tmp/session-1.jsonl",
workspaceDir: "/tmp/workspace",
authProfileId: "openai-codex:work",
authProfileIdSource: "user",
...overrides,
} satisfies Parameters<typeof runCodexAppServerSideQuestion>[0];
}
describe("runCodexAppServerSideQuestion", () => {
beforeEach(() => {
nativeHookRelayTesting.clearNativeHookRelaysForTests();
readCodexAppServerBindingMock.mockReset();
isCodexAppServerNativeAuthProfileMock.mockReset();
getSharedCodexAppServerClientMock.mockReset();
refreshCodexAppServerAuthTokensMock.mockReset();
createOpenClawCodingToolsMock.mockReset();
toolExecuteMock.mockReset();
handleCodexAppServerApprovalRequestMock.mockReset();
toolExecuteMock.mockResolvedValue({
content: [{ type: "text", text: "tool output" }],
});
createOpenClawCodingToolsMock.mockReturnValue([
{
name: "wiki_status",
description: "Check wiki status",
parameters: { type: "object", properties: {} },
execute: toolExecuteMock,
},
]);
readCodexAppServerBindingMock.mockResolvedValue({
schemaVersion: 1,
threadId: "parent-thread",
sessionFile: "/tmp/session-1.jsonl",
cwd: "/tmp/workspace",
authProfileId: "openai-codex:work",
model: "gpt-5.5",
approvalPolicy: "on-request",
sandbox: "workspace-write",
createdAt: new Date(0).toISOString(),
updatedAt: new Date(0).toISOString(),
});
isCodexAppServerNativeAuthProfileMock.mockReturnValue(true);
getSharedCodexAppServerClientMock.mockResolvedValue(createFakeClient());
refreshCodexAppServerAuthTokensMock.mockResolvedValue({
accessToken: "access-token",
chatgptAccountId: "account-1",
chatgptPlanType: "plus",
});
});
afterEach(() => {
nativeHookRelayTesting.clearNativeHookRelaysForTests();
resetDiagnosticEventsForTest();
resetGlobalHookRunner();
});
it("forks an ephemeral side thread and returns the completed assistant text", async () => {
const client = createFakeClient();
getSharedCodexAppServerClientMock.mockResolvedValue(client);
const result = await runCodexAppServerSideQuestion(
sideParams({
messageChannel: "discord",
messageProvider: "discord-voice",
currentChannelId: "voice-room",
}),
);
expect(result).toEqual({ text: "Side answer." });
const forkCall = mockCall(client.request);
expect(forkCall?.[0]).toBe("thread/fork");
const forkParams = forkCall?.[1] as Record<string, unknown> | undefined;
expect(Object.keys(forkParams ?? {}).toSorted()).toEqual([
"approvalPolicy",
"approvalsReviewer",
"config",
"cwd",
"developerInstructions",
"ephemeral",
"model",
"personality",
"sandbox",
"threadId",
"threadSource",
]);
expect(forkParams?.threadId).toBe("parent-thread");
expect(forkParams?.model).toBe("gpt-5.5");
expect(forkParams?.personality).toBe("none");
expect(forkParams?.approvalPolicy).toBe("on-request");
expect(forkParams?.sandbox).toBe("workspace-write");
expect(forkParams?.ephemeral).toBe(true);
expect(forkParams?.threadSource).toBe("user");
expect(forkParams?.approvalsReviewer).toBe("user");
expect(forkParams?.cwd).toBe("/tmp/workspace");
expect(forkParams?.config).toEqual({
"features.code_mode": true,
"features.code_mode_only": false,
"features.apply_patch_streaming_events": true,
});
expect(forkParams?.developerInstructions).toContain("You are in a side conversation");
expect(forkParams?.developerInstructions).toContain(
"Only instructions submitted after the side-conversation boundary are active.",
);
expect(forkCall?.[2]).toEqual({ timeoutMs: 60_000, signal: undefined });
const injectCall = mockCall(client.request, 1);
expect(injectCall?.[0]).toBe("thread/inject_items");
const injectParams = injectCall?.[1] as
| { threadId?: string; items?: Array<{ type?: string; role?: string; content?: unknown }> }
| undefined;
expect(injectParams?.threadId).toBe("side-thread");
expect(injectParams?.items).toHaveLength(1);
expect(injectParams?.items?.[0]?.type).toBe("message");
expect(injectParams?.items?.[0]?.role).toBe("user");
expect(injectCall?.[2]).toEqual({ timeoutMs: 60_000, signal: undefined });
const injectedItem = injectParams?.items?.[0] as
| { content?: Array<{ text?: string }> }
| undefined;
const injectedText = injectedItem?.content?.[0]?.text;
expect(injectedText).toContain(
"External tools may be available according to this thread's current permissions",
);
expect(injectedText).toContain(
"unless the user explicitly asks for that mutation after this boundary",
);
const turnStartCall = client.request.mock.calls.find(([method]) => method === "turn/start");
expect(turnStartCall).toEqual([
"turn/start",
{
threadId: "side-thread",
input: [{ type: "text", text: "What changed?", text_elements: [] }],
cwd: "/tmp/workspace",
model: "gpt-5.5",
personality: "none",
effort: null,
collaborationMode: {
mode: "default",
settings: {
model: "gpt-5.5",
reasoning_effort: null,
developer_instructions: null,
},
},
},
{ timeoutMs: 60_000, signal: undefined },
]);
const turnStartParams = turnStartCall?.[1] as Record<string, unknown> | undefined;
expect(turnStartParams).not.toHaveProperty("approvalPolicy");
expect(turnStartParams).not.toHaveProperty("sandboxPolicy");
expect(client.request.mock.calls.at(-1)).toEqual([
"thread/unsubscribe",
{ threadId: "side-thread" },
{ timeoutMs: 60_000 },
]);
expect(client.request.mock.calls.some(([method]) => method === "turn/interrupt")).toBe(false);
const [toolOptions] = mockCall(createOpenClawCodingToolsMock);
expect(toolOptions).toHaveProperty("agentDir", "/tmp/agent");
expect(toolOptions).toHaveProperty("workspaceDir", "/tmp/workspace");
expect(toolOptions).toHaveProperty("sessionId", "session-1");
expect(toolOptions).toHaveProperty("modelProvider", "openai");
expect(toolOptions).toHaveProperty("modelId", "gpt-5.5");
expect(toolOptions).toHaveProperty("messageProvider", "discord-voice");
expect(toolOptions).toHaveProperty("currentChannelId", "voice-room");
expect(toolOptions).toHaveProperty("requireExplicitMessageTarget", true);
});
it("returns side-thread completions scoped by nested turn thread id", async () => {
const client = createFakeClient();
client.request.mockImplementation(async (method: string) => {
if (method === "thread/fork") {
return threadResult("side-thread");
}
if (method === "thread/inject_items") {
return {};
}
if (method === "turn/start") {
queueMicrotask(() =>
client.emit(turnCompletedWithNestedThread("side-thread", "turn-1", "Nested answer.")),
);
return turnStartResult("turn-1");
}
if (method === "thread/unsubscribe" || method === "turn/interrupt") {
return {};
}
throw new Error(`unexpected request: ${method}`);
});
getSharedCodexAppServerClientMock.mockResolvedValue(client);
const result = await runCodexAppServerSideQuestion(sideParams());
expect(result).toEqual({ text: "Nested answer." });
});
it("rejects /btw before forking when the current OpenClaw session is sandboxed", async () => {
await expect(
runCodexAppServerSideQuestion(
sideParams({
cfg: { agents: { defaults: { sandbox: { mode: "all" } } } } as never,
sessionKey: "sandboxed-session",
}),
),
).rejects.toThrow(
"Codex-native /btw side-question mode is unavailable because OpenClaw sandboxing is active for this session.",
);
expect(getSharedCodexAppServerClientMock).not.toHaveBeenCalled();
});
it("rejects /btw before forking when exec host=node is active", async () => {
await expect(
runCodexAppServerSideQuestion(
sideParams({
cfg: { tools: { exec: { host: "node", node: "worker-1" } } } as never,
sessionKey: "node-session",
}),
),
).rejects.toThrow(
"Codex-native /btw side-question mode is unavailable because OpenClaw exec host=node is active for this session.",
);
expect(getSharedCodexAppServerClientMock).not.toHaveBeenCalled();
});
it("installs native hook relay config for opted-in side threads", async () => {
const client = createFakeClient();
let relayIdDuringFork: string | undefined;
client.request.mockImplementation(async (method: string, requestParams: unknown) => {
if (method === "thread/fork") {
const config = (requestParams as { config?: Record<string, unknown> }).config;
relayIdDuringFork = extractRelayIdFromThreadConfig(config);
expect(
nativeHookRelayTesting.getNativeHookRelayRegistrationForTests(relayIdDuringFork),
).toMatchObject({
agentId: "main",
sessionId: "session-1",
sessionKey: "agent:main:session-1",
runId: "run-side-1",
channelId: "voice-room",
allowedEvents: ["pre_tool_use", "post_tool_use", "before_agent_finalize"],
});
return threadResult("side-thread");
}
if (method === "thread/inject_items") {
return {};
}
if (method === "turn/start") {
queueMicrotask(() => {
client.emit(agentDelta("side-thread", "turn-1", "Side answer."));
client.emit(turnCompleted("side-thread", "turn-1", "Side answer."));
});
return turnStartResult("turn-1");
}
if (method === "thread/unsubscribe" || method === "turn/interrupt") {
return {};
}
throw new Error(`unexpected request: ${method}`);
});
getSharedCodexAppServerClientMock.mockResolvedValue(client);
await expect(
runCodexAppServerSideQuestion(
sideParams({
sessionKey: "agent:main:session-1",
messageChannel: "discord",
messageProvider: "discord-voice",
currentChannelId: "discord:voice-room",
opts: { runId: "run-side-1" },
}),
{ nativeHookRelay: { enabled: true, hookTimeoutSec: 9 } },
),
).resolves.toEqual({ text: "Side answer." });
const forkParams = mockCall(client.request)[1] as Record<string, unknown> | undefined;
const config = forkParams?.config as Record<string, unknown> | undefined;
expect(config?.["features.hooks"]).toBe(true);
expect(config?.["features.code_mode"]).toBe(true);
expect(config?.["features.code_mode_only"]).toBe(false);
expect(config?.["hooks.PermissionRequest"]).toEqual([]);
const preToolUseHooks = config?.["hooks.PreToolUse"] as
| Array<{ hooks?: Array<{ command?: string; timeout?: number; type?: string }> }>
| undefined;
const preToolUseCommand = preToolUseHooks?.[0]?.hooks?.[0];
expect(preToolUseCommand?.type).toBe("command");
expect(preToolUseCommand?.timeout).toBe(9);
expect(preToolUseCommand?.command).toContain("--event pre_tool_use");
const hookState = config?.["hooks.state"] as
| Record<string, { enabled?: unknown; trusted_hash?: unknown }>
| undefined;
const preToolUseState = codexHookStateForEvent(hookState, "pre_tool_use");
expect(preToolUseState?.enabled).toBe(true);
expect(preToolUseState?.trusted_hash).toMatch(/^sha256:[a-f0-9]{64}$/);
const permissionRequestState = codexHookStateForEvent(hookState, "permission_request");
expect(permissionRequestState).toEqual({ enabled: false });
const turnStartCall = client.request.mock.calls.find(([method]) => method === "turn/start");
expect(turnStartCall?.[1]).not.toHaveProperty("config");
expect(relayIdDuringFork).toBeDefined();
expect(
nativeHookRelayTesting.getNativeHookRelayRegistrationForTests(relayIdDuringFork!),
).toBeUndefined();
});
it("forwards side-thread command approvals through the active native hook relay", async () => {
const client = createFakeClient();
let relayIdDuringFork: string | undefined;
let approvalResponse: unknown;
handleCodexAppServerApprovalRequestMock.mockResolvedValueOnce({ decision: "decline" });
client.request.mockImplementation(async (method: string, requestParams: unknown) => {
if (method === "thread/fork") {
const config = (requestParams as { config?: Record<string, unknown> }).config;
relayIdDuringFork = extractRelayIdFromThreadConfig(config);
return threadResult("side-thread");
}
if (method === "thread/inject_items") {
return {};
}
if (method === "turn/start") {
setTimeout(async () => {
approvalResponse = await client.handleRequest({
id: 42,
method: "item/commandExecution/requestApproval",
params: {
threadId: "side-thread",
turnId: "turn-1",
itemId: "cmd-side",
command: "/bin/bash -lc 'node -v'",
cwd: "/tmp/workspace",
},
});
client.emit(turnCompleted("side-thread", "turn-1", "Side answer."));
}, 0);
return turnStartResult("turn-1");
}
if (method === "thread/unsubscribe" || method === "turn/interrupt") {
return {};
}
throw new Error(`unexpected request: ${method}`);
});
getSharedCodexAppServerClientMock.mockResolvedValue(client);
await expect(
runCodexAppServerSideQuestion(
sideParams({
sessionKey: "agent:main:session-1",
messageChannel: "discord",
messageProvider: "discord-voice",
opts: { runId: "run-side-approval" },
}),
{ nativeHookRelay: { enabled: true } },
),
).resolves.toEqual({ text: "Side answer." });
expect(approvalResponse).toEqual({ decision: "decline" });
expect(handleCodexAppServerApprovalRequestMock).toHaveBeenCalledTimes(1);
const approvalArgs = handleCodexAppServerApprovalRequestMock.mock.calls[0]?.[0] as
| {
method?: string;
requestParams?: Record<string, unknown>;
threadId?: string;
turnId?: string;
paramsForRun?: { messageChannel?: string; messageProvider?: string };
nativeHookRelay?: { relayId?: string; allowedEvents?: readonly string[] };
}
| undefined;
expect(approvalArgs).toMatchObject({
method: "item/commandExecution/requestApproval",
requestParams: {
threadId: "side-thread",
turnId: "turn-1",
itemId: "cmd-side",
command: "/bin/bash -lc 'node -v'",
cwd: "/tmp/workspace",
},
threadId: "side-thread",
turnId: "turn-1",
autoApprove: false,
paramsForRun: {
messageChannel: "discord",
messageProvider: "discord-voice",
},
});
expect(approvalArgs?.nativeHookRelay).toMatchObject({
relayId: relayIdDuringFork,
allowedEvents: expect.arrayContaining(["pre_tool_use"]),
});
expect(
nativeHookRelayTesting.getNativeHookRelayRegistrationForTests(relayIdDuringFork!),
).toBeUndefined();
});
it("unregisters the native hook relay when side thread fork fails", async () => {
const client = createFakeClient();
let relayIdDuringFork: string | undefined;
client.request.mockImplementation(async (method: string, requestParams: unknown) => {
if (method === "thread/fork") {
relayIdDuringFork = extractRelayIdFromThreadConfig(
(requestParams as { config?: Record<string, unknown> }).config,
);
expect(
nativeHookRelayTesting.getNativeHookRelayRegistrationForTests(relayIdDuringFork),
).toBeDefined();
throw new Error("fork failed");
}
throw new Error(`unexpected request: ${method}`);
});
getSharedCodexAppServerClientMock.mockResolvedValue(client);
await expect(
runCodexAppServerSideQuestion(
sideParams({
cfg: { tools: { loopDetection: { enabled: true } } } as never,
sessionKey: "agent:main:session-1",
}),
{ nativeHookRelay: { enabled: true } },
),
).rejects.toThrow("fork failed");
expect(relayIdDuringFork).toBeDefined();
expect(
nativeHookRelayTesting.getNativeHookRelayRegistrationForTests(relayIdDuringFork!),
).toBeUndefined();
});
it("includes permission request native hooks for side threads with yolo approval policy", async () => {
readCodexAppServerBindingMock.mockResolvedValue({
schemaVersion: 1,
threadId: "parent-thread",
sessionFile: "/tmp/session-1.jsonl",
cwd: "/tmp/workspace",
authProfileId: "openai-codex:work",
model: "gpt-5.5",
approvalPolicy: "never",
sandbox: "workspace-write",
createdAt: new Date(0).toISOString(),
updatedAt: new Date(0).toISOString(),
});
const client = createFakeClient();
getSharedCodexAppServerClientMock.mockResolvedValue(client);
await expect(
runCodexAppServerSideQuestion(
sideParams({
cfg: { tools: { loopDetection: { enabled: true } } } as never,
sessionKey: "agent:main:session-1",
}),
{ nativeHookRelay: { enabled: true } },
),
).resolves.toEqual({ text: "Side answer." });
const forkParams = mockCall(client.request)[1] as Record<string, unknown> | undefined;
const config = forkParams?.config as Record<string, unknown> | undefined;
expect(forkParams?.approvalPolicy).toBe("never");
expect(codexHookCommand(config, "hooks.PermissionRequest")?.command).toContain(
"--event permission_request",
);
expect(codexHookCommand(config, "hooks.PreToolUse")?.command).toContain("--event pre_tool_use");
});
it("preserves explicitly configured side-thread native hook events", async () => {
const client = createFakeClient();
getSharedCodexAppServerClientMock.mockResolvedValue(client);
await expect(
runCodexAppServerSideQuestion(sideParams(), {
nativeHookRelay: { enabled: true, events: ["permission_request"] },
}),
).resolves.toEqual({ text: "Side answer." });
const forkParams = mockCall(client.request)[1] as Record<string, unknown> | undefined;
const config = forkParams?.config as Record<string, unknown> | undefined;
expect(codexHookCommand(config, "hooks.PermissionRequest")?.command).toContain(
"--event permission_request",
);
expect(config?.["hooks.PreToolUse"]).toEqual([]);
expect(config?.["hooks.PostToolUse"]).toEqual([]);
expect(config?.["hooks.Stop"]).toEqual([]);
const hookState = config?.["hooks.state"] as
| Record<string, { enabled?: unknown; trusted_hash?: unknown }>
| undefined;
expect(codexHookStateForEvent(hookState, "permission_request")?.enabled).toBe(true);
expect(codexHookStateForEvent(hookState, "pre_tool_use")).toEqual({ enabled: false });
expect(codexHookStateForEvent(hookState, "post_tool_use")).toEqual({ enabled: false });
expect(codexHookStateForEvent(hookState, "stop")).toEqual({ enabled: false });
});
it("sends clearing native hook config when side-thread relay is disabled", async () => {
const client = createFakeClient();
getSharedCodexAppServerClientMock.mockResolvedValue(client);
await expect(
runCodexAppServerSideQuestion(sideParams(), { nativeHookRelay: { enabled: false } }),
).resolves.toEqual({ text: "Side answer." });
const forkParams = mockCall(client.request)[1] as Record<string, unknown> | undefined;
const config = forkParams?.config as Record<string, unknown> | undefined;
expect(config).toMatchObject({
"features.hooks": false,
"features.code_mode": true,
"features.code_mode_only": false,
"features.apply_patch_streaming_events": true,
"hooks.PreToolUse": [],
"hooks.PostToolUse": [],
"hooks.PermissionRequest": [],
"hooks.Stop": [],
});
expect(config).not.toHaveProperty("hooks.state");
});
it("passes Codex code-mode-only opt-in to side-thread forks", async () => {
const client = createFakeClient();
getSharedCodexAppServerClientMock.mockResolvedValue(client);
await expect(
runCodexAppServerSideQuestion(sideParams(), {
pluginConfig: { appServer: { codeModeOnly: true } },
}),
).resolves.toEqual({ text: "Side answer." });
const forkParams = mockCall(client.request)[1] as Record<string, unknown> | undefined;
const config = forkParams?.config as Record<string, unknown> | undefined;
expect(config?.["features.code_mode"]).toBe(true);
expect(config?.["features.code_mode_only"]).toBe(true);
});
it("keeps native hook relays alive across side-thread startup and completion timeouts", async () => {
const client = createFakeClient();
const requestTimeoutMs = 400_000;
const completionTimeoutMs = 700_000;
const expectedRelayTtlMs = requestTimeoutMs * 3 + completionTimeoutMs + 5 * 60_000;
let relayIdDuringFork: string | undefined;
let startedAtMs = 0;
client.request.mockImplementation(async (method: string, requestParams: unknown) => {
if (method === "thread/fork") {
relayIdDuringFork = extractRelayIdFromThreadConfig(
(requestParams as { config?: Record<string, unknown> }).config,
);
const registration =
nativeHookRelayTesting.getNativeHookRelayRegistrationForTests(relayIdDuringFork);
if (!registration) {
throw new Error("Expected native hook relay registration");
}
expect(registration.expiresAtMs - startedAtMs).toBeGreaterThanOrEqual(expectedRelayTtlMs);
expect(registration.expiresAtMs - startedAtMs).toBeLessThan(expectedRelayTtlMs + 10_000);
return threadResult("side-thread");
}
if (method === "thread/inject_items") {
return {};
}
if (method === "turn/start") {
queueMicrotask(() => {
client.emit(agentDelta("side-thread", "turn-1", "Side answer."));
client.emit(turnCompleted("side-thread", "turn-1", "Side answer."));
});
return turnStartResult("turn-1");
}
if (method === "thread/unsubscribe" || method === "turn/interrupt") {
return {};
}
throw new Error(`unexpected request: ${method}`);
});
getSharedCodexAppServerClientMock.mockResolvedValue(client);
startedAtMs = Date.now();
await expect(
runCodexAppServerSideQuestion(
sideParams({
cfg: { tools: { loopDetection: { enabled: true } } } as never,
sessionKey: "agent:main:session-1",
}),
{
pluginConfig: {
appServer: {
requestTimeoutMs,
turnCompletionIdleTimeoutMs: completionTimeoutMs,
},
},
nativeHookRelay: { enabled: true },
},
),
).resolves.toEqual({ text: "Side answer." });
expect(relayIdDuringFork).toBeDefined();
const registration = nativeHookRelayTesting.getNativeHookRelayRegistrationForTests(
relayIdDuringFork!,
);
expect(registration).toBeUndefined();
const forkCall = mockCall(client.request);
const forkOptions = forkCall[2] as { timeoutMs?: number } | undefined;
expect(forkOptions?.timeoutMs).toBe(requestTimeoutMs);
const config = (forkCall[1] as { config?: Record<string, unknown> }).config;
const relayId = extractRelayIdFromThreadConfig(config);
expect(relayId).toBe(relayIdDuringFork);
});
it("bridges side-thread dynamic tool requests to OpenClaw tools", async () => {
const client = createFakeClient();
let toolResponse: unknown;
client.request.mockImplementation(async (method: string) => {
if (method === "thread/fork") {
return threadResult("side-thread");
}
if (method === "thread/inject_items") {
return {};
}
if (method === "turn/start") {
setTimeout(async () => {
toolResponse = await client.handleRequest({
id: 42,
method: "item/tool/call",
params: {
threadId: "side-thread",
turnId: "turn-1",
callId: "tool-1",
tool: "wiki_status",
arguments: { topic: "AGENTS.md" },
},
});
client.emit(agentDelta("side-thread", "turn-1", "Tool answer."));
client.emit(turnCompleted("side-thread", "turn-1", "Tool answer."));
}, 0);
return turnStartResult("turn-1");
}
if (method === "thread/unsubscribe" || method === "turn/interrupt") {
return {};
}
throw new Error(`unexpected request: ${method}`);
});
getSharedCodexAppServerClientMock.mockResolvedValue(client);
const result = await runCodexAppServerSideQuestion(sideParams());
expect(result).toEqual({ text: "Tool answer." });
const [toolCallId, toolArguments, toolSignal, toolOptions] = mockCall(toolExecuteMock);
expect(toolExecuteMock).toHaveBeenCalledTimes(1);
expect(toolCallId).toBe("tool-1");
expect(toolArguments).toEqual({ topic: "AGENTS.md" });
expect(toolSignal).toBeInstanceOf(AbortSignal);
expect(toolOptions).toBeUndefined();
expect(toolResponse).toEqual({
success: true,
contentItems: [{ type: "inputText", text: "tool output" }],
});
});
it("clears side-thread dynamic tool diagnostics at the app-server request boundary", async () => {
const client = createFakeClient();
const diagnosticEvents: DiagnosticEventPayload[] = [];
const unsubscribeDiagnostics = onInternalDiagnosticEvent((event) =>
diagnosticEvents.push(event),
);
client.request.mockImplementation(async (method: string) => {
if (method === "thread/fork") {
return threadResult("side-thread");
}
if (method === "thread/inject_items") {
return {};
}
if (method === "turn/start") {
setTimeout(async () => {
await client.handleRequest({
id: 42,
method: "item/tool/call",
params: {
threadId: "side-thread",
turnId: "turn-1",
callId: "tool-1",
tool: "wiki_status",
arguments: { topic: "AGENTS.md" },
},
});
client.emit(agentDelta("side-thread", "turn-1", "Tool answer."));
client.emit(turnCompleted("side-thread", "turn-1", "Tool answer."));
}, 0);
return turnStartResult("turn-1");
}
if (method === "thread/unsubscribe" || method === "turn/interrupt") {
return {};
}
throw new Error(`unexpected request: ${method}`);
});
getSharedCodexAppServerClientMock.mockResolvedValue(client);
await runCodexAppServerSideQuestion(
sideParams({
opts: { runId: "run-side-diagnostics" },
}),
);
await flushDiagnosticEvents();
unsubscribeDiagnostics();
const toolDiagnosticEvents = diagnosticEvents.filter(
(
event,
): event is Extract<
DiagnosticEventPayload,
{ type: "tool.execution.started" | "tool.execution.completed" | "tool.execution.error" }
> => event.type.startsWith("tool.execution."),
);
expect(
toolDiagnosticEvents.map((event) => ({
type: event.type,
toolName: event.toolName,
toolCallId: event.toolCallId,
})),
).toEqual([
{
type: "tool.execution.started",
toolName: "wiki_status",
toolCallId: "tool-1",
},
{
type: "tool.execution.completed",
toolName: "wiki_status",
toolCallId: "tool-1",
},
]);
expect(activeDiagnosticToolKeys(diagnosticEvents)).toEqual(new Set());
});
it("normalizes hook channel ids for side-thread dynamic tool requests", async () => {
const beforeToolCall = vi.fn((...args: unknown[]) => {
const context = args[1] as { channelId?: string };
expect(context.channelId).toBe("voice-room");
return undefined;
});
initializeGlobalHookRunner(
createMockPluginRegistry([{ hookName: "before_tool_call", handler: beforeToolCall }]),
);
const client = createFakeClient();
client.request.mockImplementation(async (method: string) => {
if (method === "thread/fork") {
return threadResult("side-thread");
}
if (method === "thread/inject_items") {
return {};
}
if (method === "turn/start") {
setTimeout(async () => {
await client.handleRequest({
id: 42,
method: "item/tool/call",
params: {
threadId: "side-thread",
turnId: "turn-1",
callId: "tool-1",
tool: "wiki_status",
arguments: { topic: "AGENTS.md" },
},
});
client.emit(agentDelta("side-thread", "turn-1", "Tool answer."));
client.emit(turnCompleted("side-thread", "turn-1", "Tool answer."));
}, 0);
return turnStartResult("turn-1");
}
if (method === "thread/unsubscribe" || method === "turn/interrupt") {
return {};
}
throw new Error(`unexpected request: ${method}`);
});
getSharedCodexAppServerClientMock.mockResolvedValue(client);
await expect(
runCodexAppServerSideQuestion(
sideParams({
messageChannel: "discord",
messageProvider: "discord-voice",
currentChannelId: "discord:voice-room",
}),
),
).resolves.toEqual({ text: "Tool answer." });
expect(beforeToolCall).toHaveBeenCalledTimes(1);
expect(createOpenClawCodingToolsMock).toHaveBeenCalledWith(
expect.objectContaining({ hookChannelId: "voice-room" }),
);
expect(toolExecuteMock).toHaveBeenCalledTimes(1);
});
it("returns an empty response for side-thread user input requests", async () => {
const client = createFakeClient();
let unrelatedUserInputResponse: unknown;
let userInputResponse: unknown;
client.request.mockImplementation(async (method: string) => {
if (method === "thread/fork") {
return threadResult("side-thread");
}
if (method === "thread/inject_items") {
return {};
}
if (method === "turn/start") {
setTimeout(async () => {
unrelatedUserInputResponse = await client.handleRequest({
id: 42,
method: "item/tool/requestUserInput",
params: {
threadId: "parent-thread",
turnId: "parent-turn",
itemId: "input-parent",
questions: [],
},
});
userInputResponse = await client.handleRequest({
id: 43,
method: "item/tool/requestUserInput",
params: {
threadId: "side-thread",
turnId: "turn-1",
itemId: "input-1",
questions: [
{
id: "choice",
header: "Choice",
question: "Pick one",
options: [{ label: "A", description: "" }],
},
],
},
});
client.emit(turnCompleted("side-thread", "turn-1", "No input needed."));
}, 0);
return turnStartResult("turn-1");
}
if (method === "thread/unsubscribe" || method === "turn/interrupt") {
return {};
}
throw new Error(`unexpected request: ${method}`);
});
getSharedCodexAppServerClientMock.mockResolvedValue(client);
const result = await runCodexAppServerSideQuestion(sideParams());
expect(result).toEqual({ text: "No input needed." });
expect(unrelatedUserInputResponse).toBeUndefined();
expect(userInputResponse).toEqual({ answers: {} });
});
it("uses configured image generation timeout for side-thread image_generate calls", () => {
const timeoutMs = testing.resolveSideDynamicToolCallTimeoutMs({
call: {
threadId: "side-thread",
turnId: "turn-1",
callId: "tool-1",
tool: "image_generate",
},
config: {
agents: {
defaults: {
imageGenerationModel: {
timeoutMs: 123_456,
},
},
},
} as never,
});
expect(timeoutMs).toBe(123_456);
});
it("uses a 120 second default for side-thread image_generate calls", () => {
const timeoutMs = testing.resolveSideDynamicToolCallTimeoutMs({
call: {
threadId: "side-thread",
turnId: "turn-1",
callId: "tool-1",
tool: "image_generate",
},
config: {} as never,
});
expect(timeoutMs).toBe(120_000);
});
it("uses a 90 second default for generic side-thread dynamic tool calls", () => {
const timeoutMs = testing.resolveSideDynamicToolCallTimeoutMs({
call: {
threadId: "side-thread",
turnId: "turn-1",
callId: "tool-1",
tool: "session_status",
arguments: { sessionKey: "current" },
},
config: {} as never,
});
expect(timeoutMs).toBe(90_000);
});
it("cleans up notification handlers when side tool setup fails", async () => {
const client = createFakeClient();
createOpenClawCodingToolsMock.mockImplementation(() => {
throw new Error("tool setup failed");
});
getSharedCodexAppServerClientMock.mockResolvedValue(client);
await expect(runCodexAppServerSideQuestion(sideParams())).rejects.toThrow("tool setup failed");
expect(client.notifications).toHaveLength(0);
expect(client.requests).toHaveLength(0);
});
it("uses the app-server auth refresh request handler while the side thread is active", async () => {
const client = createFakeClient();
client.request.mockImplementation(async (method: string) => {
if (method === "thread/fork") {
await client.requests[0]?.({
id: 1,
method: "account/chatgptAuthTokens/refresh",
});
return threadResult("side-thread");
}
if (method === "thread/inject_items") {
return {};
}
if (method === "turn/start") {
queueMicrotask(() => client.emit(turnCompleted("side-thread", "turn-1", "Done.")));
return turnStartResult("turn-1");
}
return {};
});
getSharedCodexAppServerClientMock.mockResolvedValue(client);
await runCodexAppServerSideQuestion(sideParams());
expect(refreshCodexAppServerAuthTokensMock).toHaveBeenCalledWith({
agentDir: "/tmp/agent",
authProfileId: "openai-codex:work",
config: {},
});
});
it("returns a clear setup error when there is no Codex parent thread", async () => {
readCodexAppServerBindingMock.mockResolvedValue(undefined);
await expect(runCodexAppServerSideQuestion(sideParams())).rejects.toThrow(
"Codex /btw needs an active Codex thread. Send a normal message first, then try /btw again.",
);
expect(getSharedCodexAppServerClientMock).not.toHaveBeenCalled();
});
it("returns the same setup error when the persisted parent binding is stale", async () => {
const client = createFakeClient();
client.request.mockImplementation(async (method: string) => {
if (method === "thread/fork") {
throw new Error("thread/fork failed: no rollout found for thread id parent-thread");
}
return {};
});
getSharedCodexAppServerClientMock.mockResolvedValue(client);
await expect(runCodexAppServerSideQuestion(sideParams())).rejects.toThrow(
"Codex /btw needs an active Codex thread. Send a normal message first, then try /btw again.",
);
});
it("interrupts and unsubscribes the ephemeral thread on abort", async () => {
const controller = new AbortController();
const client = createFakeClient();
client.request.mockImplementation(async (method: string) => {
if (method === "thread/fork") {
return threadResult("side-thread");
}
if (method === "thread/inject_items") {
return {};
}
if (method === "turn/start") {
queueMicrotask(() => controller.abort());
return turnStartResult("turn-1");
}
if (method === "turn/interrupt" || method === "thread/unsubscribe") {
return {};
}
throw new Error(`unexpected request: ${method}`);
});
getSharedCodexAppServerClientMock.mockResolvedValue(client);
await expect(
runCodexAppServerSideQuestion(
sideParams({
opts: { abortSignal: controller.signal },
}),
),
).rejects.toThrow("Codex /btw was aborted.");
expect(client.request.mock.calls.filter(([method]) => method === "turn/interrupt")).toEqual([
["turn/interrupt", { threadId: "side-thread", turnId: "turn-1" }, { timeoutMs: 60_000 }],
]);
expect(client.request.mock.calls.filter(([method]) => method === "thread/unsubscribe")).toEqual(
[["thread/unsubscribe", { threadId: "side-thread" }, { timeoutMs: 60_000 }]],
);
});
});