Skip to content

Commit c35ed4f

Browse files
committed
Plugin: log Codex server startup details
1 parent 18d4035 commit c35ed4f

File tree

4 files changed

+165
-48
lines changed

4 files changed

+165
-48
lines changed

src/client.test.ts

Lines changed: 31 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
22
import { __testing } from "./client.js";
33

44
describe("buildTurnStartPayloads", () => {
5-
it("keeps legacy text and message input fallbacks for normal turns", () => {
5+
it("uses only text input variants for normal turns", () => {
66
expect(
77
__testing.buildTurnStartPayloads({
88
threadId: "thread-123",
@@ -20,28 +20,6 @@ describe("buildTurnStartPayloads", () => {
2020
input: [{ type: "text", text: "ship it" }],
2121
model: "gpt-5.4",
2222
},
23-
{
24-
threadId: "thread-123",
25-
input: [
26-
{
27-
type: "message",
28-
role: "user",
29-
content: [{ type: "input_text", text: "ship it" }],
30-
},
31-
],
32-
model: "gpt-5.4",
33-
},
34-
{
35-
thread_id: "thread-123",
36-
input: [
37-
{
38-
type: "message",
39-
role: "user",
40-
content: [{ type: "input_text", text: "ship it" }],
41-
},
42-
],
43-
model: "gpt-5.4",
44-
},
4523
]);
4624
});
4725

@@ -114,6 +92,36 @@ describe("buildTurnStartPayloads", () => {
11492
});
11593
});
11694

95+
describe("extractStartupProbeInfo", () => {
96+
it("extracts server info from initialize responses without losing CLI probe details", () => {
97+
expect(
98+
__testing.extractStartupProbeInfo(
99+
{
100+
serverInfo: {
101+
name: "Codex App Server",
102+
version: "2026.3.15",
103+
},
104+
},
105+
{
106+
transport: "stdio",
107+
command: "codex",
108+
args: ["--foo"],
109+
resolvedCommandPath: "/opt/homebrew/bin/codex",
110+
cliVersion: "2026.3.15",
111+
},
112+
),
113+
).toEqual({
114+
transport: "stdio",
115+
command: "codex",
116+
args: ["--foo"],
117+
resolvedCommandPath: "/opt/homebrew/bin/codex",
118+
cliVersion: "2026.3.15",
119+
serverName: "Codex App Server",
120+
serverVersion: "2026.3.15",
121+
});
122+
});
123+
});
124+
117125
describe("extractThreadTokenUsageSnapshot", () => {
118126
it("prefers current-context usage over cumulative totals when both are present", () => {
119127
expect(

src/client.ts

Lines changed: 127 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
1+
import { execFile, spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
22
import * as path from "node:path";
33
import readline from "node:readline";
4+
import { promisify } from "node:util";
45
import WebSocket from "ws";
56
import type { PluginLogger } from "openclaw/plugin-sdk";
67
import { createPendingInputState, parseCodexUserInput } from "./pending-input.js";
@@ -72,6 +73,17 @@ const DEFAULT_PROTOCOL_VERSION = "1.0";
7273
const TRAILING_NOTIFICATION_SETTLE_MS = 250;
7374
const TURN_STEER_METHODS = ["turn/steer"] as const;
7475
const TURN_INTERRUPT_METHODS = ["turn/interrupt"] as const;
76+
const execFileAsync = promisify(execFile);
77+
78+
type StartupProbeInfo = {
79+
transport: PluginSettings["transport"];
80+
command?: string;
81+
args?: string[];
82+
resolvedCommandPath?: string;
83+
cliVersion?: string;
84+
serverName?: string;
85+
serverVersion?: string;
86+
};
7587

7688
function isTransportClosedError(error: unknown): boolean {
7789
const text = error instanceof Error ? error.message : String(error);
@@ -741,8 +753,8 @@ async function initializeClient(params: {
741753
client: JsonRpcClient;
742754
settings: PluginSettings;
743755
sessionKey?: string;
744-
}): Promise<void> {
745-
await params.client.request("initialize", {
756+
}): Promise<unknown> {
757+
const initializeResult = await params.client.request("initialize", {
746758
protocolVersion: DEFAULT_PROTOCOL_VERSION,
747759
clientInfo: { name: "openclaw-codex-app-server", version: "0.0.0" },
748760
capabilities: { experimentalApi: true },
@@ -760,6 +772,82 @@ async function initializeClient(params: {
760772
}
761773
});
762774
}
775+
return initializeResult;
776+
}
777+
778+
function extractStartupProbeInfo(
779+
initializeResult: unknown,
780+
base: StartupProbeInfo,
781+
): StartupProbeInfo {
782+
const record = asRecord(initializeResult) ?? {};
783+
const serverInfo = asRecord(record.serverInfo) ?? asRecord(record.server_info) ?? record;
784+
return {
785+
...base,
786+
serverName:
787+
pickString(serverInfo, ["name", "serverName", "server_name"]) ?? base.serverName,
788+
serverVersion:
789+
pickString(serverInfo, ["version", "serverVersion", "server_version"]) ?? base.serverVersion,
790+
};
791+
}
792+
793+
async function resolveCommandPath(command: string): Promise<string | undefined> {
794+
const trimmed = command.trim();
795+
if (!trimmed) {
796+
return undefined;
797+
}
798+
if (trimmed.includes(path.sep)) {
799+
return path.resolve(trimmed);
800+
}
801+
const locator = process.platform === "win32" ? "where" : "which";
802+
try {
803+
const { stdout } = await execFileAsync(locator, [trimmed], { timeout: 5_000 });
804+
const first = stdout
805+
.split(/\r?\n/)
806+
.map((line) => line.trim())
807+
.find(Boolean);
808+
return first || undefined;
809+
} catch {
810+
return undefined;
811+
}
812+
}
813+
814+
async function probeStdioVersion(settings: PluginSettings): Promise<{
815+
resolvedCommandPath?: string;
816+
cliVersion?: string;
817+
}> {
818+
const resolvedCommandPath = await resolveCommandPath(settings.command);
819+
const commandPath = resolvedCommandPath ?? settings.command;
820+
try {
821+
const { stdout, stderr } = await execFileAsync(
822+
commandPath,
823+
[...settings.args, "--version"],
824+
{ timeout: Math.min(settings.requestTimeoutMs, 10_000) },
825+
);
826+
const combined = `${stdout}\n${stderr}`
827+
.split(/\r?\n/)
828+
.map((line) => line.trim())
829+
.find(Boolean);
830+
return {
831+
resolvedCommandPath,
832+
cliVersion: combined || undefined,
833+
};
834+
} catch {
835+
return { resolvedCommandPath };
836+
}
837+
}
838+
839+
function formatStartupProbeLog(info: StartupProbeInfo): string {
840+
return [
841+
`transport=${info.transport}`,
842+
info.command ? `command=${info.command}` : undefined,
843+
info.args ? `args=${JSON.stringify(info.args)}` : undefined,
844+
info.resolvedCommandPath ? `resolved=${info.resolvedCommandPath}` : undefined,
845+
info.cliVersion ? `cliVersion=${info.cliVersion}` : undefined,
846+
info.serverName ? `serverName=${info.serverName}` : undefined,
847+
info.serverVersion ? `serverVersion=${info.serverVersion}` : undefined,
848+
]
849+
.filter(Boolean)
850+
.join(" ");
763851
}
764852

765853
async function requestWithFallbacks(params: {
@@ -828,21 +916,8 @@ function buildThreadResumePayloads(params: {
828916
});
829917
}
830918

831-
function buildTurnInput(
832-
prompt: string,
833-
options?: { includeLegacyMessageVariant?: boolean },
834-
): unknown[] {
835-
const variants: unknown[] = [[{ type: "text", text: prompt }]];
836-
if (options?.includeLegacyMessageVariant !== false) {
837-
variants.push([
838-
{
839-
type: "message",
840-
role: "user",
841-
content: [{ type: "input_text", text: prompt }],
842-
},
843-
]);
844-
}
845-
return variants;
919+
function buildTurnInput(prompt: string): unknown[] {
920+
return [[{ type: "text", text: prompt }]];
846921
}
847922

848923
function buildCollaborationModeVariants(
@@ -912,9 +987,7 @@ function buildTurnStartPayloads(params: {
912987
model?: string;
913988
collaborationMode?: CollaborationMode;
914989
}): unknown[] {
915-
const payloads = buildTurnInput(params.prompt, {
916-
includeLegacyMessageVariant: !params.collaborationMode,
917-
}).flatMap((input) => {
990+
const payloads = buildTurnInput(params.prompt).flatMap((input) => {
918991
const camel: Record<string, unknown> = {
919992
threadId: params.threadId,
920993
input,
@@ -1908,17 +1981,21 @@ async function withInitializedClient<T>(
19081981
settings: PluginSettings;
19091982
sessionKey?: string;
19101983
},
1911-
callback: (args: { client: JsonRpcClient; settings: PluginSettings }) => Promise<T>,
1984+
callback: (args: {
1985+
client: JsonRpcClient;
1986+
settings: PluginSettings;
1987+
initializeResult: unknown;
1988+
}) => Promise<T>,
19121989
): Promise<T> {
19131990
const client = createJsonRpcClient(params.settings);
19141991
try {
19151992
await client.connect();
1916-
await initializeClient({
1993+
const initializeResult = await initializeClient({
19171994
client,
19181995
settings: params.settings,
19191996
sessionKey: params.sessionKey,
19201997
});
1921-
return await callback({ client, settings: params.settings });
1998+
return await callback({ client, settings: params.settings, initializeResult });
19221999
} finally {
19232000
await client.close().catch(() => undefined);
19242001
}
@@ -1941,6 +2018,31 @@ export class CodexAppServerClient {
19412018
private readonly logger: PluginLogger,
19422019
) {}
19432020

2021+
async logStartupProbe(params: { sessionKey?: string } = {}): Promise<void> {
2022+
const base: StartupProbeInfo = {
2023+
transport: this.settings.transport,
2024+
command: this.settings.transport === "stdio" ? this.settings.command : undefined,
2025+
args: this.settings.transport === "stdio" ? this.settings.args : undefined,
2026+
};
2027+
const stdioProbe =
2028+
this.settings.transport === "stdio" ? await probeStdioVersion(this.settings) : {};
2029+
await withInitializedClient(
2030+
{ settings: this.settings, sessionKey: params.sessionKey },
2031+
async ({ initializeResult }) => {
2032+
const info = extractStartupProbeInfo(initializeResult, {
2033+
...base,
2034+
...stdioProbe,
2035+
});
2036+
this.logger.info(`codex startup probe ${formatStartupProbeLog(info)}`);
2037+
},
2038+
).catch((error) => {
2039+
const message = error instanceof Error ? error.message : String(error);
2040+
this.logger.warn(
2041+
`codex startup probe failed transport=${this.settings.transport}${this.settings.transport === "stdio" ? ` command=${this.settings.command}` : ""}: ${message}`,
2042+
);
2043+
});
2044+
}
2045+
19442046
async listThreads(params: {
19452047
sessionKey?: string;
19462048
workspaceDir?: string;
@@ -2965,6 +3067,7 @@ export const __testing = {
29653067
buildTurnStartPayloads,
29663068
createPendingInputCoordinator,
29673069
extractFileChangePathsFromReadResult,
3070+
extractStartupProbeInfo,
29683071
extractThreadTokenUsageSnapshot,
29693072
extractRateLimitSummaries,
29703073
};

src/controller.test.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import fs from "node:fs";
22
import os from "node:os";
33
import path from "node:path";
4-
import { afterEach, describe, expect, it, vi } from "vitest";
4+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
55
import type { OpenClawPluginApi, PluginCommandContext } from "openclaw/plugin-sdk";
6+
import { CodexAppServerClient } from "./client.js";
67
import { CodexPluginController } from "./controller.js";
78

89
function makeStateDir(): string {
@@ -204,6 +205,10 @@ afterEach(() => {
204205
vi.restoreAllMocks();
205206
});
206207

208+
beforeEach(() => {
209+
vi.spyOn(CodexAppServerClient.prototype, "logStartupProbe").mockResolvedValue();
210+
});
211+
207212
describe("Discord controller flows", () => {
208213
it("starts cleanly without the legacy runtime.channel.bindings surface", async () => {
209214
const { controller } = await createControllerHarnessWithoutLegacyBindings();

src/controller.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -516,6 +516,7 @@ export class CodexPluginController {
516516
return;
517517
}
518518
await this.store.load();
519+
await this.client.logStartupProbe().catch(() => undefined);
519520
this.started = true;
520521
}
521522

0 commit comments

Comments
 (0)