Skip to content

Commit 3b65e23

Browse files
committed
refactor(codex): split app-server lifecycle seams
1 parent 979ae0b commit 3b65e23

9 files changed

Lines changed: 318 additions & 239 deletions

File tree

extensions/codex/openclaw.plugin.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,11 @@
124124
"sensitive": true,
125125
"advanced": true
126126
},
127+
"appServer.headers": {
128+
"label": "Headers",
129+
"help": "Additional headers sent to the WebSocket app-server.",
130+
"advanced": true
131+
},
127132
"appServer.requestTimeoutMs": {
128133
"label": "Request Timeout",
129134
"help": "Maximum time to wait for Codex app-server control-plane requests.",

extensions/codex/src/app-server/capabilities.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { CodexAppServerRpcError } from "./client.js";
2+
13
export const CODEX_CONTROL_METHODS = {
24
account: "account/read",
35
compact: "thread/compact/start",
@@ -12,10 +14,13 @@ export const CODEX_CONTROL_METHODS = {
1214
export type CodexControlName = keyof typeof CODEX_CONTROL_METHODS;
1315
export type CodexControlMethod = (typeof CODEX_CONTROL_METHODS)[CodexControlName];
1416

15-
export function describeControlFailure(error: string): string {
16-
return isUnsupportedControlError(error) ? "unsupported by this Codex app-server" : error;
17+
export function describeControlFailure(error: unknown): string {
18+
if (isUnsupportedControlError(error)) {
19+
return "unsupported by this Codex app-server";
20+
}
21+
return error instanceof Error ? error.message : String(error);
1722
}
1823

19-
function isUnsupportedControlError(error: string): boolean {
20-
return /method not found|unknown method|unsupported method|-32601/i.test(error);
24+
function isUnsupportedControlError(error: unknown): error is CodexAppServerRpcError {
25+
return error instanceof CodexAppServerRpcError && error.code === -32601;
2126
}

extensions/codex/src/app-server/client.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,18 @@ type PendingRequest = {
2222
reject: (error: Error) => void;
2323
};
2424

25+
export class CodexAppServerRpcError extends Error {
26+
readonly code?: number;
27+
readonly data?: JsonValue;
28+
29+
constructor(error: { code?: number; message: string; data?: JsonValue }, method: string) {
30+
super(error.message || `${method} failed`);
31+
this.name = "CodexAppServerRpcError";
32+
this.code = error.code;
33+
this.data = error.data;
34+
}
35+
}
36+
2537
export type CodexServerRequestHandler = (
2638
request: Required<Pick<RpcRequest, "id" | "method">> & { params?: JsonValue },
2739
) => Promise<JsonValue | undefined> | JsonValue | undefined;
@@ -192,7 +204,7 @@ export class CodexAppServerClient {
192204
}
193205
this.pending.delete(response.id);
194206
if (response.error) {
195-
pending.reject(new Error(response.error.message || `${pending.method} failed`));
207+
pending.reject(new CodexAppServerRpcError(response.error, pending.method));
196208
return;
197209
}
198210
pending.resolve(response.result);

extensions/codex/src/app-server/config.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,20 @@ export type CodexPluginConfig = {
4343
};
4444
};
4545

46+
export const CODEX_APP_SERVER_CONFIG_KEYS = [
47+
"transport",
48+
"command",
49+
"args",
50+
"url",
51+
"authToken",
52+
"headers",
53+
"requestTimeoutMs",
54+
"approvalPolicy",
55+
"sandbox",
56+
"approvalsReviewer",
57+
"serviceTier",
58+
] as const;
59+
4660
const codexAppServerTransportSchema = z.enum(["stdio", "websocket"]);
4761
const codexAppServerApprovalPolicySchema = z.enum([
4862
"never",
@@ -101,6 +115,11 @@ export function resolveCodexAppServerRuntimeOptions(
101115
const headers = normalizeHeaders(config.headers);
102116
const authToken = readNonEmptyString(config.authToken);
103117
const url = readNonEmptyString(config.url);
118+
if (transport === "websocket" && !url) {
119+
throw new Error(
120+
"plugins.entries.codex.config.appServer.url is required when appServer.transport is websocket",
121+
);
122+
}
104123

105124
return {
106125
start: {

extensions/codex/src/app-server/run-attempt.ts

Lines changed: 3 additions & 209 deletions
Original file line numberDiff line numberDiff line change
@@ -19,33 +19,20 @@ import {
1919
} from "openclaw/plugin-sdk/agent-harness";
2020
import { handleCodexAppServerApprovalRequest } from "./approval-bridge.js";
2121
import { isCodexAppServerApprovalRequest, type CodexAppServerClient } from "./client.js";
22-
import {
23-
resolveCodexAppServerRuntimeOptions,
24-
type CodexAppServerRuntimeOptions,
25-
type CodexAppServerStartOptions,
26-
} from "./config.js";
22+
import { resolveCodexAppServerRuntimeOptions, type CodexAppServerStartOptions } from "./config.js";
2723
import { createCodexDynamicToolBridge } from "./dynamic-tools.js";
2824
import { CodexAppServerEventProjector } from "./event-projector.js";
2925
import {
3026
isJsonObject,
3127
type CodexServerNotification,
3228
type CodexDynamicToolCallParams,
33-
type CodexThreadResumeParams,
34-
type CodexThreadResumeResponse,
35-
type CodexThreadStartResponse,
36-
type CodexTurnStartParams,
3729
type CodexTurnStartResponse,
38-
type CodexUserInput,
3930
type JsonObject,
4031
type JsonValue,
4132
} from "./protocol.js";
42-
import {
43-
clearCodexAppServerBinding,
44-
readCodexAppServerBinding,
45-
writeCodexAppServerBinding,
46-
type CodexAppServerThreadBinding,
47-
} from "./session-binding.js";
33+
import type { CodexAppServerThreadBinding } from "./session-binding.js";
4834
import { clearSharedCodexAppServerClient, getSharedCodexAppServerClient } from "./shared-client.js";
35+
import { buildTurnStartParams, startOrResumeThread } from "./thread-lifecycle.js";
4936
import { mirrorCodexAppServerTranscript } from "./transcript-mirror.js";
5037

5138
type CodexAppServerClientFactory = (
@@ -397,199 +384,6 @@ async function withCodexStartupTimeout<T>(params: {
397384
}
398385
}
399386

400-
async function startOrResumeThread(params: {
401-
client: CodexAppServerClient;
402-
params: EmbeddedRunAttemptParams;
403-
cwd: string;
404-
dynamicTools: JsonValue[];
405-
appServer: CodexAppServerRuntimeOptions;
406-
}): Promise<CodexAppServerThreadBinding> {
407-
const dynamicToolsFingerprint = fingerprintDynamicTools(params.dynamicTools);
408-
const binding = await readCodexAppServerBinding(params.params.sessionFile);
409-
if (binding?.threadId) {
410-
// `/codex resume <thread>` writes a binding before the next turn can know
411-
// the dynamic tool catalog, so only invalidate fingerprints we actually have.
412-
if (
413-
binding.dynamicToolsFingerprint &&
414-
binding.dynamicToolsFingerprint !== dynamicToolsFingerprint
415-
) {
416-
embeddedAgentLog.debug(
417-
"codex app-server dynamic tool catalog changed; starting a new thread",
418-
{
419-
threadId: binding.threadId,
420-
},
421-
);
422-
await clearCodexAppServerBinding(params.params.sessionFile);
423-
} else {
424-
try {
425-
const response = await params.client.request<CodexThreadResumeResponse>(
426-
"thread/resume",
427-
buildThreadResumeParams(params.params, {
428-
threadId: binding.threadId,
429-
appServer: params.appServer,
430-
}),
431-
);
432-
await writeCodexAppServerBinding(params.params.sessionFile, {
433-
threadId: response.thread.id,
434-
cwd: params.cwd,
435-
model: params.params.modelId,
436-
modelProvider: response.modelProvider ?? normalizeModelProvider(params.params.provider),
437-
dynamicToolsFingerprint,
438-
createdAt: binding.createdAt,
439-
});
440-
return {
441-
...binding,
442-
threadId: response.thread.id,
443-
cwd: params.cwd,
444-
model: params.params.modelId,
445-
modelProvider: response.modelProvider ?? normalizeModelProvider(params.params.provider),
446-
dynamicToolsFingerprint,
447-
};
448-
} catch (error) {
449-
embeddedAgentLog.warn("codex app-server thread resume failed; starting a new thread", {
450-
error,
451-
});
452-
await clearCodexAppServerBinding(params.params.sessionFile);
453-
}
454-
}
455-
}
456-
457-
const response = await params.client.request<CodexThreadStartResponse>("thread/start", {
458-
model: params.params.modelId,
459-
modelProvider: normalizeModelProvider(params.params.provider),
460-
cwd: params.cwd,
461-
approvalPolicy: params.appServer.approvalPolicy,
462-
approvalsReviewer: params.appServer.approvalsReviewer,
463-
sandbox: params.appServer.sandbox,
464-
...(params.appServer.serviceTier ? { serviceTier: params.appServer.serviceTier } : {}),
465-
serviceName: "OpenClaw",
466-
developerInstructions: buildDeveloperInstructions(params.params),
467-
dynamicTools: params.dynamicTools,
468-
experimentalRawEvents: true,
469-
persistExtendedHistory: true,
470-
});
471-
const createdAt = new Date().toISOString();
472-
await writeCodexAppServerBinding(params.params.sessionFile, {
473-
threadId: response.thread.id,
474-
cwd: params.cwd,
475-
model: response.model ?? params.params.modelId,
476-
modelProvider: response.modelProvider ?? normalizeModelProvider(params.params.provider),
477-
dynamicToolsFingerprint,
478-
createdAt,
479-
});
480-
return {
481-
schemaVersion: 1,
482-
threadId: response.thread.id,
483-
sessionFile: params.params.sessionFile,
484-
cwd: params.cwd,
485-
model: response.model ?? params.params.modelId,
486-
modelProvider: response.modelProvider ?? normalizeModelProvider(params.params.provider),
487-
dynamicToolsFingerprint,
488-
createdAt,
489-
updatedAt: createdAt,
490-
};
491-
}
492-
493-
export function buildThreadResumeParams(
494-
params: EmbeddedRunAttemptParams,
495-
options: {
496-
threadId: string;
497-
appServer: CodexAppServerRuntimeOptions;
498-
},
499-
): CodexThreadResumeParams {
500-
return {
501-
threadId: options.threadId,
502-
model: params.modelId,
503-
modelProvider: normalizeModelProvider(params.provider),
504-
approvalPolicy: options.appServer.approvalPolicy,
505-
approvalsReviewer: options.appServer.approvalsReviewer,
506-
sandbox: options.appServer.sandbox,
507-
...(options.appServer.serviceTier ? { serviceTier: options.appServer.serviceTier } : {}),
508-
persistExtendedHistory: true,
509-
};
510-
}
511-
512-
export function buildTurnStartParams(
513-
params: EmbeddedRunAttemptParams,
514-
options: {
515-
threadId: string;
516-
cwd: string;
517-
appServer: CodexAppServerRuntimeOptions;
518-
},
519-
): CodexTurnStartParams {
520-
return {
521-
threadId: options.threadId,
522-
input: buildUserInput(params),
523-
cwd: options.cwd,
524-
approvalPolicy: options.appServer.approvalPolicy,
525-
approvalsReviewer: options.appServer.approvalsReviewer,
526-
model: params.modelId,
527-
...(options.appServer.serviceTier ? { serviceTier: options.appServer.serviceTier } : {}),
528-
effort: resolveReasoningEffort(params.thinkLevel),
529-
};
530-
}
531-
532-
function fingerprintDynamicTools(dynamicTools: JsonValue[]): string {
533-
return JSON.stringify(dynamicTools.map(stabilizeJsonValue));
534-
}
535-
536-
function stabilizeJsonValue(value: JsonValue): JsonValue {
537-
if (Array.isArray(value)) {
538-
return value.map(stabilizeJsonValue);
539-
}
540-
if (!isJsonObject(value)) {
541-
return value;
542-
}
543-
const stable: JsonObject = {};
544-
for (const [key, child] of Object.entries(value).toSorted(([left], [right]) =>
545-
left.localeCompare(right),
546-
)) {
547-
stable[key] = stabilizeJsonValue(child);
548-
}
549-
return stable;
550-
}
551-
552-
function buildDeveloperInstructions(params: EmbeddedRunAttemptParams): string {
553-
const sections = [
554-
"You are running inside OpenClaw. Use OpenClaw dynamic tools for messaging, cron, sessions, and host actions when available.",
555-
"Preserve the user's existing channel/session context. If sending a channel reply, use the OpenClaw messaging tool instead of describing that you would reply.",
556-
params.extraSystemPrompt,
557-
params.skillsSnapshot?.prompt,
558-
];
559-
return sections.filter((section) => typeof section === "string" && section.trim()).join("\n\n");
560-
}
561-
562-
function buildUserInput(params: EmbeddedRunAttemptParams): CodexUserInput[] {
563-
return [
564-
{ type: "text", text: params.prompt },
565-
...(params.images ?? []).map(
566-
(image): CodexUserInput => ({
567-
type: "image",
568-
url: `data:${image.mimeType};base64,${image.data}`,
569-
}),
570-
),
571-
];
572-
}
573-
574-
function normalizeModelProvider(provider: string): string {
575-
return provider === "codex" || provider === "openai-codex" ? "openai" : provider;
576-
}
577-
578-
function resolveReasoningEffort(
579-
thinkLevel: EmbeddedRunAttemptParams["thinkLevel"],
580-
): "minimal" | "low" | "medium" | "high" | "xhigh" | null {
581-
if (
582-
thinkLevel === "minimal" ||
583-
thinkLevel === "low" ||
584-
thinkLevel === "medium" ||
585-
thinkLevel === "high" ||
586-
thinkLevel === "xhigh"
587-
) {
588-
return thinkLevel;
589-
}
590-
return null;
591-
}
592-
593387
function readDynamicToolCallParams(
594388
value: JsonValue | undefined,
595389
): CodexDynamicToolCallParams | undefined {

0 commit comments

Comments
 (0)