fix: complete codex app-server turns in docker

This commit is contained in:
Peter Steinberger
2026-04-23 05:34:56 +01:00
parent 20b05f220e
commit d88d6a3c8b
23 changed files with 283 additions and 134 deletions

View File

@@ -1,19 +1,13 @@
import type { AgentHarness } from "openclaw/plugin-sdk/agent-harness";
import { maybeCompactCodexAppServerSession } from "./src/app-server/compact.js";
import { listCodexAppServerModels } from "./src/app-server/models.js";
import type { AgentHarness } from "openclaw/plugin-sdk/agent-harness-runtime";
import type {
CodexAppServerListModelsOptions,
CodexAppServerModel,
CodexAppServerModelListResult,
} from "./src/app-server/models.js";
import { runCodexAppServerAttempt } from "./src/app-server/run-attempt.js";
import { clearCodexAppServerBinding } from "./src/app-server/session-binding.js";
import { clearSharedCodexAppServerClient } from "./src/app-server/shared-client.js";
const DEFAULT_CODEX_HARNESS_PROVIDER_IDS = new Set(["codex"]);
export type { CodexAppServerListModelsOptions, CodexAppServerModel, CodexAppServerModelListResult };
export { listCodexAppServerModels };
export function createCodexAppServerAgentHarness(options?: {
id?: string;
@@ -39,16 +33,22 @@ export function createCodexAppServerAgentHarness(options?: {
reason: `provider is not one of: ${[...providerIds].toSorted().join(", ")}`,
};
},
runAttempt: (params) =>
runCodexAppServerAttempt(params, { pluginConfig: options?.pluginConfig }),
compact: (params) =>
maybeCompactCodexAppServerSession(params, { pluginConfig: options?.pluginConfig }),
runAttempt: async (params) => {
const { runCodexAppServerAttempt } = await import("./src/app-server/run-attempt.js");
return runCodexAppServerAttempt(params, { pluginConfig: options?.pluginConfig });
},
compact: async (params) => {
const { maybeCompactCodexAppServerSession } = await import("./src/app-server/compact.js");
return maybeCompactCodexAppServerSession(params, { pluginConfig: options?.pluginConfig });
},
reset: async (params) => {
if (params.sessionFile) {
const { clearCodexAppServerBinding } = await import("./src/app-server/session-binding.js");
await clearCodexAppServerBinding(params.sessionFile);
}
},
dispose: () => {
dispose: async () => {
const { clearSharedCodexAppServerClient } = await import("./src/app-server/shared-client.js");
clearSharedCodexAppServerClient();
},
};

View File

@@ -1,7 +1,7 @@
import {
type AgentApprovalEventData,
type EmbeddedRunAttemptParams,
} from "openclaw/plugin-sdk/agent-harness";
} from "openclaw/plugin-sdk/agent-harness-runtime";
import {
mapExecDecisionToOutcome,
requestPluginApproval,

View File

@@ -1,5 +1,5 @@
import { createInterface, type Interface as ReadlineInterface } from "node:readline";
import { embeddedAgentLog, OPENCLAW_VERSION } from "openclaw/plugin-sdk/agent-harness";
import { embeddedAgentLog, OPENCLAW_VERSION } from "openclaw/plugin-sdk/agent-harness-runtime";
import { resolveCodexAppServerRuntimeOptions, type CodexAppServerStartOptions } from "./config.js";
import {
type CodexInitializeResponse,

View File

@@ -2,7 +2,7 @@ import {
embeddedAgentLog,
type CompactEmbeddedPiSessionParams,
type EmbeddedPiCompactResult,
} from "openclaw/plugin-sdk/agent-harness";
} from "openclaw/plugin-sdk/agent-harness-runtime";
import {
createCodexAppServerClientFactoryTestHooks,
defaultCodexAppServerClientFactory,

View File

@@ -9,7 +9,7 @@ import {
runAgentHarnessAfterToolCallHook,
type AnyAgentTool,
type MessagingToolSend,
} from "openclaw/plugin-sdk/agent-harness";
} from "openclaw/plugin-sdk/agent-harness-runtime";
import {
type CodexDynamicToolCallOutputContentItem,
type CodexDynamicToolCallParams,

View File

@@ -1,4 +1,7 @@
import { embeddedAgentLog, type EmbeddedRunAttemptParams } from "openclaw/plugin-sdk/agent-harness";
import {
embeddedAgentLog,
type EmbeddedRunAttemptParams,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import {
mapExecDecisionToOutcome,
requestPluginApproval,

View File

@@ -124,9 +124,13 @@ function agentMessageDelta(delta: string, itemId = "msg-1"): ProjectorNotificati
}
function turnCompleted(items: unknown[] = []): ProjectorNotification {
return forCurrentTurn("turn/completed", {
turn: { id: TURN_ID, status: "completed", items },
});
return {
method: "turn/completed",
params: {
threadId: THREAD_ID,
turn: { id: TURN_ID, status: "completed", items },
},
} as ProjectorNotification;
}
describe("CodexAppServerEventProjector", () => {

View File

@@ -9,7 +9,7 @@ import {
type EmbeddedRunAttemptParams,
type EmbeddedRunAttemptResult,
type MessagingToolSend,
} from "openclaw/plugin-sdk/agent-harness";
} from "openclaw/plugin-sdk/agent-harness-runtime";
import {
isJsonObject,
type CodexServerNotification,
@@ -576,11 +576,20 @@ export class CodexAppServerEventProjector {
private isNotificationForTurn(params: JsonObject): boolean {
const threadId = readString(params, "threadId");
const turnId = readString(params, "turnId");
const turnId = readNotificationTurnId(params);
return threadId === this.threadId && turnId === this.turnId;
}
}
function readNotificationTurnId(record: JsonObject): string | undefined {
return readString(record, "turnId") ?? readNestedTurnId(record);
}
function readNestedTurnId(record: JsonObject): string | undefined {
const turn = record.turn;
return isJsonObject(turn) ? readString(turn, "id") : undefined;
}
function readString(record: JsonObject, key: string): string | undefined {
const value = record[key];
return typeof value === "string" ? value : undefined;

View File

@@ -1,4 +1,7 @@
import { callGatewayTool, type EmbeddedRunAttemptParams } from "openclaw/plugin-sdk/agent-harness";
import {
callGatewayTool,
type EmbeddedRunAttemptParams,
} from "openclaw/plugin-sdk/agent-harness-runtime";
export const DEFAULT_CODEX_APPROVAL_TIMEOUT_MS = 120_000;
const MAX_PLUGIN_APPROVAL_TITLE_LENGTH = 80;

View File

@@ -703,6 +703,35 @@ describe("runCodexAppServerAttempt", () => {
});
});
it("completes when turn/start returns a terminal turn without a follow-up notification", async () => {
const harness = createAppServerHarness(async (method) => {
if (method === "thread/start") {
return threadStartResult();
}
if (method === "turn/start") {
return {
turn: {
id: "turn-1",
status: "completed",
items: [{ type: "agentMessage", id: "msg-1", text: "done from response" }],
},
};
}
return {};
});
const result = await runCodexAppServerAttempt(
createParams(path.join(tempDir, "session.jsonl"), path.join(tempDir, "workspace")),
);
expect(harness.requests.map((entry) => entry.method)).toContain("turn/start");
expect(result).toMatchObject({
assistantTexts: ["done from response"],
aborted: false,
timedOut: false,
});
});
it("does not complete on unscoped turn/completed notifications", async () => {
const harness = createStartedThreadHarness();
const run = runCodexAppServerAttempt(
@@ -731,7 +760,6 @@ describe("runCodexAppServerAttempt", () => {
method: "turn/completed",
params: {
threadId: "thread-1",
turnId: "turn-1",
turn: {
id: "turn-1",
status: "completed",
@@ -788,7 +816,6 @@ describe("runCodexAppServerAttempt", () => {
method: "turn/completed",
params: {
threadId: "thread-1",
turnId: "turn-1",
turn: {
id: "turn-1",
status: "completed",

View File

@@ -3,7 +3,6 @@ import { SessionManager } from "@mariozechner/pi-coding-agent";
import {
buildEmbeddedAttemptToolRunContext,
clearActiveEmbeddedRun,
createOpenClawCodingTools,
embeddedAgentLog,
formatErrorMessage,
isSubagentSessionKey,
@@ -22,7 +21,7 @@ import {
supportsModelTools,
type EmbeddedRunAttemptParams,
type EmbeddedRunAttemptResult,
} from "openclaw/plugin-sdk/agent-harness";
} from "openclaw/plugin-sdk/agent-harness-runtime";
import { handleCodexAppServerApprovalRequest } from "./approval-bridge.js";
import {
createCodexAppServerClientFactoryTestHooks,
@@ -295,11 +294,21 @@ export async function runCodexAppServerAttempt(
}
turnId = turn.turn.id;
projector = new CodexAppServerEventProjector(params, thread.threadId, turnId);
const activeTurnId = turnId;
const activeProjector = projector;
for (const notification of pendingNotifications.splice(0)) {
await enqueueNotification(notification);
}
const activeTurnId = turnId;
const activeProjector = projector;
if (!completed && isTerminalTurnStatus(turn.turn.status)) {
await enqueueNotification({
method: "turn/completed",
params: {
threadId: thread.threadId,
turnId: activeTurnId,
turn: turn.turn as unknown as JsonObject,
},
});
}
const handle = {
kind: "embedded" as const,
@@ -422,6 +431,7 @@ async function buildDynamicTools(input: DynamicToolBuildParams) {
}
const modelHasVision = params.model.input?.includes("image") ?? false;
const agentDir = params.agentDir ?? resolveOpenClawAgentDir();
const { createOpenClawCodingTools } = await import("openclaw/plugin-sdk/agent-harness");
const allTools = createOpenClawCodingTools({
agentId: input.sessionAgentId,
...buildEmbeddedAttemptToolRunContext(params),
@@ -581,7 +591,20 @@ function isTurnNotification(
if (!isJsonObject(value)) {
return false;
}
return readString(value, "threadId") === threadId && readString(value, "turnId") === turnId;
return readString(value, "threadId") === threadId && readNotificationTurnId(value) === turnId;
}
function isTerminalTurnStatus(status: string | undefined): boolean {
return status === "completed" || status === "interrupted" || status === "failed";
}
function readNotificationTurnId(record: JsonObject): string | undefined {
return readString(record, "turnId") ?? readNestedTurnId(record);
}
function readNestedTurnId(record: JsonObject): string | undefined {
const turn = record.turn;
return isJsonObject(turn) ? readString(turn, "id") : undefined;
}
function readString(record: JsonObject, key: string): string | undefined {

View File

@@ -1,5 +1,5 @@
import fs from "node:fs/promises";
import { embeddedAgentLog } from "openclaw/plugin-sdk/agent-harness";
import { embeddedAgentLog } from "openclaw/plugin-sdk/agent-harness-runtime";
export type CodexAppServerThreadBinding = {
schemaVersion: 1;

View File

@@ -18,6 +18,7 @@ vi.mock("openclaw/plugin-sdk/provider-auth", () => ({
let listCodexAppServerModels: typeof import("./models.js").listCodexAppServerModels;
let clearSharedCodexAppServerClient: typeof import("./shared-client.js").clearSharedCodexAppServerClient;
let createIsolatedCodexAppServerClient: typeof import("./shared-client.js").createIsolatedCodexAppServerClient;
let resetSharedCodexAppServerClientForTests: typeof import("./shared-client.js").resetSharedCodexAppServerClientForTests;
async function sendInitializeResult(
@@ -38,8 +39,11 @@ async function sendEmptyModelList(harness: ReturnType<typeof createClientHarness
describe("shared Codex app-server client", () => {
beforeAll(async () => {
({ listCodexAppServerModels } = await import("./models.js"));
({ clearSharedCodexAppServerClient, resetSharedCodexAppServerClientForTests } =
await import("./shared-client.js"));
({
clearSharedCodexAppServerClient,
createIsolatedCodexAppServerClient,
resetSharedCodexAppServerClientForTests,
} = await import("./shared-client.js"));
});
afterEach(() => {
@@ -87,6 +91,16 @@ describe("shared Codex app-server client", () => {
expect(startSpy).toHaveBeenCalledTimes(2);
});
it("does not wait for isolated initialize after a timeout closes the client", async () => {
const harness = createClientHarness();
vi.spyOn(CodexAppServerClient, "start").mockReturnValue(harness.client);
await expect(createIsolatedCodexAppServerClient({ timeoutMs: 5 })).rejects.toThrow(
"codex app-server initialize timed out",
);
expect(harness.process.kill).toHaveBeenCalledTimes(1);
});
it("passes the selected auth profile through the bridge helper", async () => {
const harness = createClientHarness();
vi.spyOn(CodexAppServerClient, "start").mockReturnValue(harness.client);

View File

@@ -87,7 +87,7 @@ export async function createIsolatedCodexAppServerClient(options?: {
return client;
} catch (error) {
client.close();
await initialize.catch(() => undefined);
void initialize.catch(() => undefined);
throw error;
}
}

View File

@@ -1,4 +1,7 @@
import { embeddedAgentLog, type EmbeddedRunAttemptParams } from "openclaw/plugin-sdk/agent-harness";
import {
embeddedAgentLog,
type EmbeddedRunAttemptParams,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import { renderCodexPromptOverlay } from "../../prompt-overlay.js";
import type { CodexAppServerClient } from "./client.js";
import type { CodexAppServerRuntimeOptions } from "./config.js";

View File

@@ -6,7 +6,7 @@ import {
acquireSessionWriteLock,
emitSessionTranscriptUpdate,
runAgentHarnessBeforeMessageWriteHook,
} from "openclaw/plugin-sdk/agent-harness";
} from "openclaw/plugin-sdk/agent-harness-runtime";
export async function mirrorCodexAppServerTranscript(params: {
sessionFile: string;

View File

@@ -2,7 +2,7 @@ import type {
OpenClawPluginCommandDefinition,
PluginCommandContext,
} from "openclaw/plugin-sdk/plugin-entry";
import { handleCodexSubcommand, type CodexCommandDeps } from "./command-handlers.js";
import type { CodexCommandDeps } from "./command-handlers.js";
export function createCodexCommand(options: {
pluginConfig?: unknown;
@@ -21,5 +21,6 @@ export async function handleCodexCommand(
ctx: PluginCommandContext,
options: { pluginConfig?: unknown; deps?: Partial<CodexCommandDeps> } = {},
): Promise<{ text: string }> {
const { handleCodexSubcommand } = await import("./command-handlers.js");
return await handleCodexSubcommand(ctx, options);
}