[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
This commit is contained in:
350
electron/bridges/aiBridge/codexAppServer/connection.cjs
Normal file
350
electron/bridges/aiBridge/codexAppServer/connection.cjs
Normal file
@@ -0,0 +1,350 @@
|
||||
"use strict";
|
||||
|
||||
const path = require("node:path");
|
||||
const { spawn } = require("node:child_process");
|
||||
const { createHash } = require("node:crypto");
|
||||
const { StringDecoder } = require("node:string_decoder");
|
||||
|
||||
const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
|
||||
const INITIALIZE_TIMEOUT_MS = 10_000;
|
||||
const MAX_STDERR_CHARS = 32_000;
|
||||
const MAX_JSONL_LINE_BYTES = 16 * 1024 * 1024;
|
||||
const CLOSE_KILL_GRACE_MS = 750;
|
||||
|
||||
function createBoundedLineReader(stream, onLine, onError, maxLineBytes) {
|
||||
const decoder = new StringDecoder("utf8");
|
||||
let buffer = "";
|
||||
let bufferedBytes = 0;
|
||||
let closed = false;
|
||||
|
||||
const fail = () => {
|
||||
buffer = "";
|
||||
bufferedBytes = 0;
|
||||
onError(new Error(`Codex App Server message exceeded ${maxLineBytes} bytes`));
|
||||
};
|
||||
const onData = (chunk) => {
|
||||
if (closed) return;
|
||||
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk || ""));
|
||||
bufferedBytes += bytes.length;
|
||||
buffer += decoder.write(bytes);
|
||||
let index;
|
||||
let consumedLine = false;
|
||||
while ((index = buffer.indexOf("\n")) >= 0) {
|
||||
const line = buffer.slice(0, index).trim();
|
||||
buffer = buffer.slice(index + 1);
|
||||
consumedLine = true;
|
||||
if (line) onLine(line);
|
||||
if (closed) return;
|
||||
}
|
||||
if (consumedLine) bufferedBytes = Buffer.byteLength(buffer, "utf8") + decoder.lastNeed;
|
||||
if (bufferedBytes > maxLineBytes) fail();
|
||||
};
|
||||
const onEnd = () => {
|
||||
if (closed) return;
|
||||
buffer += decoder.end();
|
||||
const line = buffer.trim();
|
||||
buffer = "";
|
||||
bufferedBytes = 0;
|
||||
if (line) onLine(line);
|
||||
};
|
||||
|
||||
stream?.on?.("data", onData);
|
||||
stream?.once?.("end", onEnd);
|
||||
return {
|
||||
close() {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
buffer = "";
|
||||
bufferedBytes = 0;
|
||||
stream?.removeListener?.("data", onData);
|
||||
stream?.removeListener?.("end", onEnd);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildCodexAppServerLaunch(binPath, args = ["app-server", "--stdio"], {
|
||||
nodePath = process.execPath,
|
||||
} = {}) {
|
||||
const executable = String(binPath || "").trim();
|
||||
if (!executable) {
|
||||
throw new Error("Codex binary not found. Configure Codex in Settings -> AI.");
|
||||
}
|
||||
const extension = path.extname(executable).toLowerCase();
|
||||
if (extension === ".js" || extension === ".cjs" || extension === ".mjs") {
|
||||
return {
|
||||
command: nodePath,
|
||||
args: [executable, ...args],
|
||||
env: { ELECTRON_RUN_AS_NODE: "1" },
|
||||
};
|
||||
}
|
||||
if (extension === ".cmd" || extension === ".bat" || extension === ".ps1") {
|
||||
throw new Error(
|
||||
`Codex App Server cannot launch the shell shim ${executable}. ` +
|
||||
"Configure the native Codex executable or reinstall the Codex CLI.",
|
||||
);
|
||||
}
|
||||
return { command: executable, args };
|
||||
}
|
||||
|
||||
function buildCodexAppServerKey(binPath, env) {
|
||||
const fingerprint = createHash("sha256")
|
||||
.update(JSON.stringify(
|
||||
Object.entries(env || {})
|
||||
.map(([key, value]) => [key, String(value)])
|
||||
.sort(([left], [right]) => left.localeCompare(right)),
|
||||
))
|
||||
.digest("hex");
|
||||
return `${String(binPath || "")}\u0000${fingerprint}`;
|
||||
}
|
||||
|
||||
class CodexAppServerConnection {
|
||||
constructor({
|
||||
binPath,
|
||||
env,
|
||||
appVersion = "0.0.0",
|
||||
spawnImpl = spawn,
|
||||
onNotification,
|
||||
onServerRequest,
|
||||
onFatal,
|
||||
closeKillGraceMs = CLOSE_KILL_GRACE_MS,
|
||||
maxJsonlLineBytes = MAX_JSONL_LINE_BYTES,
|
||||
}) {
|
||||
this.binPath = binPath;
|
||||
this.env = env || {};
|
||||
this.appVersion = appVersion;
|
||||
this.spawnImpl = spawnImpl;
|
||||
this.onNotification = onNotification;
|
||||
this.onServerRequest = onServerRequest;
|
||||
this.onFatal = onFatal;
|
||||
this.closeKillGraceMs = closeKillGraceMs;
|
||||
this.maxJsonlLineBytes = maxJsonlLineBytes;
|
||||
this.process = null;
|
||||
this.closingProcesses = new Map();
|
||||
this.readline = null;
|
||||
this.nextRequestId = 1;
|
||||
this.pending = new Map();
|
||||
this.startPromise = null;
|
||||
this.initialized = false;
|
||||
this.closing = false;
|
||||
this.stderr = "";
|
||||
}
|
||||
|
||||
async start() {
|
||||
if (this.initialized && this.process && !this.process.killed) return this;
|
||||
if (this.startPromise) return this.startPromise;
|
||||
this.startPromise = this.#startInternal().finally(() => {
|
||||
this.startPromise = null;
|
||||
});
|
||||
return this.startPromise;
|
||||
}
|
||||
|
||||
async #startInternal() {
|
||||
this.closing = false;
|
||||
this.stderr = "";
|
||||
const launch = buildCodexAppServerLaunch(this.binPath);
|
||||
const child = this.spawnImpl(launch.command, launch.args, {
|
||||
cwd: process.cwd(),
|
||||
env: { ...this.env, ...(launch.env || {}) },
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
windowsHide: true,
|
||||
shell: false,
|
||||
});
|
||||
this.process = child;
|
||||
|
||||
child.stderr?.setEncoding?.("utf8");
|
||||
child.stderr?.on?.("data", (chunk) => {
|
||||
this.stderr = `${this.stderr}${String(chunk || "")}`.slice(-MAX_STDERR_CHARS);
|
||||
});
|
||||
|
||||
this.readline = createBoundedLineReader(
|
||||
child.stdout,
|
||||
(line) => this.#handleLine(line),
|
||||
(error) => this.#handleFatal(error),
|
||||
this.maxJsonlLineBytes,
|
||||
);
|
||||
|
||||
child.once("error", (error) => {
|
||||
if (this.closingProcesses.has(child) || this.process !== child) return;
|
||||
this.#handleFatal(error);
|
||||
});
|
||||
child.once("exit", (code, signal) => {
|
||||
const wasClosing = this.closingProcesses.has(child);
|
||||
if (wasClosing) this.#releaseClosingProcess(child);
|
||||
if (wasClosing || this.process !== child) return;
|
||||
const detail = this.stderr.trim();
|
||||
const suffix = detail ? `\n${detail}` : "";
|
||||
this.#handleFatal(new Error(
|
||||
`Codex App Server exited unexpectedly (code ${code ?? "null"}, signal ${signal ?? "none"}).${suffix}`,
|
||||
));
|
||||
});
|
||||
|
||||
try {
|
||||
await this.request("initialize", {
|
||||
clientInfo: {
|
||||
name: "netcatty",
|
||||
title: "Netcatty",
|
||||
version: this.appVersion,
|
||||
},
|
||||
capabilities: {
|
||||
experimentalApi: true,
|
||||
requestAttestation: false,
|
||||
mcpServerOpenaiFormElicitation: false,
|
||||
},
|
||||
}, INITIALIZE_TIMEOUT_MS, { skipStart: true });
|
||||
this.notify("initialized", {});
|
||||
this.initialized = true;
|
||||
return this;
|
||||
} catch (error) {
|
||||
this.close();
|
||||
const detail = this.stderr.trim();
|
||||
if (detail && !String(error?.message || error).includes(detail)) {
|
||||
throw new Error(`${error?.message || error}\n${detail}`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async request(method, params = {}, timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS, options = {}) {
|
||||
if (!options.skipStart) await this.start();
|
||||
const id = this.nextRequestId++;
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
this.pending.delete(id);
|
||||
reject(new Error(`Codex App Server request timed out: ${method}`));
|
||||
}, timeoutMs);
|
||||
this.pending.set(id, { method, resolve, reject, timer });
|
||||
try {
|
||||
this.#write({ id, method, params });
|
||||
} catch (error) {
|
||||
clearTimeout(timer);
|
||||
this.pending.delete(id);
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
notify(method, params = {}) {
|
||||
this.#write({ method, params });
|
||||
}
|
||||
|
||||
respond(id, result) {
|
||||
this.#write({ id, result });
|
||||
}
|
||||
|
||||
respondError(id, code, message, data) {
|
||||
const error = { code, message };
|
||||
if (data !== undefined) error.data = data;
|
||||
this.#write({ id, error });
|
||||
}
|
||||
|
||||
#write(message) {
|
||||
const stdin = this.process?.stdin;
|
||||
if (!stdin || stdin.destroyed || !stdin.writable) {
|
||||
throw new Error("Codex App Server stdin is unavailable");
|
||||
}
|
||||
stdin.write(`${JSON.stringify(message)}\n`);
|
||||
}
|
||||
|
||||
#handleLine(rawLine) {
|
||||
const line = String(rawLine || "").trim();
|
||||
if (!line) return;
|
||||
let message;
|
||||
try {
|
||||
message = JSON.parse(line);
|
||||
} catch {
|
||||
this.#handleFatal(new Error(`Codex App Server emitted invalid JSON: ${line.slice(0, 500)}`));
|
||||
return;
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(message, "id") && !message.method) {
|
||||
const entry = this.pending.get(message.id);
|
||||
if (!entry) return;
|
||||
this.pending.delete(message.id);
|
||||
clearTimeout(entry.timer);
|
||||
if (message.error) {
|
||||
const error = new Error(message.error.message || `Codex App Server ${entry.method} failed`);
|
||||
error.code = message.error.code;
|
||||
error.data = message.error.data;
|
||||
entry.reject(error);
|
||||
} else {
|
||||
entry.resolve(message.result);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.method && Object.prototype.hasOwnProperty.call(message, "id")) {
|
||||
Promise.resolve(this.onServerRequest?.(message, this)).catch((error) => {
|
||||
try {
|
||||
this.respondError(message.id, -32603, error?.message || String(error));
|
||||
} catch {}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.method) {
|
||||
try {
|
||||
this.onNotification?.(message, this);
|
||||
} catch (error) {
|
||||
this.#handleFatal(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#handleFatal(error) {
|
||||
if (this.closing) return;
|
||||
this.initialized = false;
|
||||
const fatal = error instanceof Error ? error : new Error(String(error));
|
||||
for (const [, entry] of this.pending) {
|
||||
clearTimeout(entry.timer);
|
||||
entry.reject(fatal);
|
||||
}
|
||||
this.pending.clear();
|
||||
try { this.onFatal?.(fatal, this); } catch {}
|
||||
this.close();
|
||||
}
|
||||
|
||||
#releaseClosingProcess(child) {
|
||||
if (!this.closingProcesses.has(child)) return;
|
||||
clearTimeout(this.closingProcesses.get(child));
|
||||
this.closingProcesses.delete(child);
|
||||
}
|
||||
|
||||
getClosingProcessCountForTests() {
|
||||
return this.closingProcesses.size;
|
||||
}
|
||||
|
||||
close() {
|
||||
this.closing = true;
|
||||
this.initialized = false;
|
||||
try { this.readline?.close?.(); } catch {}
|
||||
this.readline = null;
|
||||
for (const [, entry] of this.pending) {
|
||||
clearTimeout(entry.timer);
|
||||
entry.reject(new Error("Codex App Server connection closed"));
|
||||
}
|
||||
this.pending.clear();
|
||||
const child = this.process;
|
||||
this.process = null;
|
||||
if (!child) return;
|
||||
try { child.stdin?.end?.(); } catch {}
|
||||
this.closingProcesses.set(child, null);
|
||||
const killTimer = setTimeout(() => {
|
||||
if (!this.closingProcesses.has(child)) return;
|
||||
try { child.kill?.("SIGKILL"); } catch {}
|
||||
this.#releaseClosingProcess(child);
|
||||
}, this.closeKillGraceMs);
|
||||
killTimer.unref?.();
|
||||
this.closingProcesses.set(child, killTimer);
|
||||
try { child.kill?.("SIGTERM"); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
CodexAppServerConnection,
|
||||
buildCodexAppServerKey,
|
||||
buildCodexAppServerLaunch,
|
||||
DEFAULT_REQUEST_TIMEOUT_MS,
|
||||
INITIALIZE_TIMEOUT_MS,
|
||||
CLOSE_KILL_GRACE_MS,
|
||||
MAX_JSONL_LINE_BYTES,
|
||||
};
|
||||
200
electron/bridges/aiBridge/codexAppServer/connection.test.cjs
Normal file
200
electron/bridges/aiBridge/codexAppServer/connection.test.cjs
Normal file
@@ -0,0 +1,200 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { EventEmitter, once } = require("node:events");
|
||||
const { PassThrough } = require("node:stream");
|
||||
const {
|
||||
CodexAppServerConnection,
|
||||
buildCodexAppServerKey,
|
||||
buildCodexAppServerLaunch,
|
||||
} = require("./connection.cjs");
|
||||
|
||||
function createFakeChild() {
|
||||
const child = new EventEmitter();
|
||||
child.stdin = new PassThrough();
|
||||
child.stdout = new PassThrough();
|
||||
child.stderr = new PassThrough();
|
||||
child.killed = false;
|
||||
child.kill = () => { child.killed = true; };
|
||||
return child;
|
||||
}
|
||||
|
||||
async function readJsonLine(stream) {
|
||||
const [chunk] = await once(stream, "data");
|
||||
return JSON.parse(String(chunk).trim());
|
||||
}
|
||||
|
||||
test("buildCodexAppServerLaunch runs JS entries through Node without a shell", () => {
|
||||
assert.deepEqual(
|
||||
buildCodexAppServerLaunch("/opt/codex/bin/codex.js", ["app-server", "--help"], { nodePath: "/usr/bin/node" }),
|
||||
{
|
||||
command: "/usr/bin/node",
|
||||
args: ["/opt/codex/bin/codex.js", "app-server", "--help"],
|
||||
env: { ELECTRON_RUN_AS_NODE: "1" },
|
||||
},
|
||||
);
|
||||
assert.deepEqual(
|
||||
buildCodexAppServerLaunch("/usr/local/bin/codex"),
|
||||
{ command: "/usr/local/bin/codex", args: ["app-server", "--stdio"] },
|
||||
);
|
||||
assert.throws(() => buildCodexAppServerLaunch("C:\\npm\\codex.cmd"), /shell shim/);
|
||||
});
|
||||
|
||||
test("App Server connection initializes once and correlates JSONL requests", async () => {
|
||||
const child = createFakeChild();
|
||||
const notifications = [];
|
||||
const connection = new CodexAppServerConnection({
|
||||
binPath: "/usr/bin/codex",
|
||||
env: { HOME: "/tmp/home" },
|
||||
appVersion: "1.2.3",
|
||||
spawnImpl: () => child,
|
||||
onNotification: (message) => notifications.push(message),
|
||||
});
|
||||
|
||||
const startPromise = connection.start();
|
||||
const initialize = await readJsonLine(child.stdin);
|
||||
assert.equal(initialize.method, "initialize");
|
||||
assert.equal(initialize.params.clientInfo.name, "netcatty");
|
||||
assert.equal(initialize.params.capabilities.experimentalApi, true);
|
||||
child.stdout.write(`${JSON.stringify({ id: initialize.id, result: { userAgent: "codex" } })}\n`);
|
||||
await startPromise;
|
||||
const initialized = await readJsonLine(child.stdin);
|
||||
assert.equal(initialized.method, "initialized");
|
||||
|
||||
const requestPromise = connection.request("model/list", { limit: 100 });
|
||||
const request = await readJsonLine(child.stdin);
|
||||
assert.equal(request.method, "model/list");
|
||||
child.stdout.write(`${JSON.stringify({ id: request.id, result: { data: [], nextCursor: null } })}\n`);
|
||||
assert.deepEqual(await requestPromise, { data: [], nextCursor: null });
|
||||
|
||||
child.stdout.write(`${JSON.stringify({ method: "warning", params: { message: "heads up" } })}\n`);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(notifications[0].method, "warning");
|
||||
connection.close();
|
||||
});
|
||||
|
||||
test("App Server connection preserves a Chinese response split across UTF-8 chunks", async () => {
|
||||
const child = createFakeChild();
|
||||
const connection = new CodexAppServerConnection({
|
||||
binPath: "/usr/bin/codex",
|
||||
env: {},
|
||||
spawnImpl: () => child,
|
||||
});
|
||||
|
||||
const startPromise = connection.start();
|
||||
const initialize = await readJsonLine(child.stdin);
|
||||
const response = Buffer.from(`${JSON.stringify({
|
||||
id: initialize.id,
|
||||
result: { message: "中文" },
|
||||
})}\n`, "utf8");
|
||||
const split = response.indexOf(Buffer.from("中", "utf8")) + 1;
|
||||
child.stdout.write(response.subarray(0, split));
|
||||
child.stdout.write(response.subarray(split));
|
||||
await startPromise;
|
||||
await readJsonLine(child.stdin);
|
||||
connection.close();
|
||||
});
|
||||
|
||||
test("App Server connection rejects an unterminated oversized JSONL message", async () => {
|
||||
const child = createFakeChild();
|
||||
let fatal;
|
||||
const connection = new CodexAppServerConnection({
|
||||
binPath: "/usr/bin/codex",
|
||||
env: {},
|
||||
maxJsonlLineBytes: 8,
|
||||
spawnImpl: () => child,
|
||||
onFatal: (error) => { fatal = error; },
|
||||
});
|
||||
|
||||
const startPromise = connection.start();
|
||||
await readJsonLine(child.stdin);
|
||||
child.stdout.write("123456789");
|
||||
|
||||
await assert.rejects(startPromise, /message exceeded 8 bytes/);
|
||||
assert.match(fatal.message, /message exceeded 8 bytes/);
|
||||
assert.equal(child.killed, true);
|
||||
});
|
||||
|
||||
test("App Server connection rejects pending RPCs when the process exits", async () => {
|
||||
const child = createFakeChild();
|
||||
let fatal;
|
||||
const connection = new CodexAppServerConnection({
|
||||
binPath: "/usr/bin/codex",
|
||||
env: {},
|
||||
spawnImpl: () => child,
|
||||
onFatal: (error) => { fatal = error; },
|
||||
});
|
||||
const startPromise = connection.start();
|
||||
const initialize = await readJsonLine(child.stdin);
|
||||
child.stdout.write(`${JSON.stringify({ id: initialize.id, result: {} })}\n`);
|
||||
await startPromise;
|
||||
await readJsonLine(child.stdin); // initialized notification
|
||||
|
||||
const request = connection.request("thread/start", {});
|
||||
await readJsonLine(child.stdin);
|
||||
child.emit("exit", 1, null);
|
||||
await assert.rejects(request, /exited unexpectedly/);
|
||||
assert.match(fatal.message, /code 1/);
|
||||
});
|
||||
|
||||
test("App Server close force-kills a child that ignores SIGTERM", async () => {
|
||||
const child = createFakeChild();
|
||||
const signals = [];
|
||||
child.kill = (signal) => {
|
||||
signals.push(signal);
|
||||
return true;
|
||||
};
|
||||
const connection = new CodexAppServerConnection({
|
||||
binPath: "/usr/bin/codex",
|
||||
env: {},
|
||||
closeKillGraceMs: 5,
|
||||
spawnImpl: () => child,
|
||||
});
|
||||
const startPromise = connection.start();
|
||||
const initialize = await readJsonLine(child.stdin);
|
||||
child.stdout.write(`${JSON.stringify({ id: initialize.id, result: {} })}\n`);
|
||||
await startPromise;
|
||||
await readJsonLine(child.stdin);
|
||||
|
||||
connection.close();
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
assert.deepEqual(signals, ["SIGTERM", "SIGKILL"]);
|
||||
});
|
||||
|
||||
test("App Server close does not retain or re-kill a child that exits synchronously on SIGTERM", async () => {
|
||||
const child = createFakeChild();
|
||||
const signals = [];
|
||||
child.kill = (signal) => {
|
||||
signals.push(signal);
|
||||
if (signal === "SIGTERM") child.emit("exit", 0, "SIGTERM");
|
||||
return true;
|
||||
};
|
||||
const connection = new CodexAppServerConnection({
|
||||
binPath: "/usr/bin/codex",
|
||||
env: {},
|
||||
closeKillGraceMs: 5,
|
||||
spawnImpl: () => child,
|
||||
});
|
||||
const startPromise = connection.start();
|
||||
const initialize = await readJsonLine(child.stdin);
|
||||
child.stdout.write(`${JSON.stringify({ id: initialize.id, result: {} })}\n`);
|
||||
await startPromise;
|
||||
await readJsonLine(child.stdin);
|
||||
|
||||
connection.close();
|
||||
assert.equal(connection.getClosingProcessCountForTests(), 0);
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
assert.deepEqual(signals, ["SIGTERM"]);
|
||||
});
|
||||
|
||||
test("App Server process keys include executable and environment identity", () => {
|
||||
assert.notEqual(
|
||||
buildCodexAppServerKey("/a/codex", { HOME: "/a" }),
|
||||
buildCodexAppServerKey("/b/codex", { HOME: "/a" }),
|
||||
);
|
||||
assert.notEqual(
|
||||
buildCodexAppServerKey("/a/codex", { HOME: "/a" }),
|
||||
buildCodexAppServerKey("/a/codex", { HOME: "/b" }),
|
||||
);
|
||||
});
|
||||
44
electron/bridges/aiBridge/codexAppServer/probe.cjs
Normal file
44
electron/bridges/aiBridge/codexAppServer/probe.cjs
Normal file
@@ -0,0 +1,44 @@
|
||||
"use strict";
|
||||
|
||||
const { execFile } = require("node:child_process");
|
||||
const { buildCodexAppServerLaunch } = require("./connection.cjs");
|
||||
|
||||
function execFileText(command, args, options = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
execFile(command, args, options, (error, stdout, stderr) => {
|
||||
if (error) {
|
||||
error.stdout = stdout;
|
||||
error.stderr = stderr;
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve({ stdout: String(stdout || ""), stderr: String(stderr || "") });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function probeCodexAppServer({ binPath, env, execFileImpl = execFileText }) {
|
||||
try {
|
||||
const launch = buildCodexAppServerLaunch(binPath, ["app-server", "--help"]);
|
||||
const result = await execFileImpl(launch.command, launch.args, {
|
||||
env: { ...(env || {}), ...(launch.env || {}) },
|
||||
encoding: "utf8",
|
||||
timeout: 5_000,
|
||||
windowsHide: true,
|
||||
maxBuffer: 1024 * 1024,
|
||||
});
|
||||
const output = `${result.stdout || ""}\n${result.stderr || ""}`;
|
||||
const available = /Run the app server|--listen|--stdio/i.test(output);
|
||||
return available
|
||||
? { available: true }
|
||||
: { available: false, error: "This Codex CLI does not advertise App Server support." };
|
||||
} catch (error) {
|
||||
const detail = String(error?.stderr || error?.message || error || "").trim();
|
||||
return {
|
||||
available: false,
|
||||
error: detail || "Failed to probe Codex App Server support.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { execFileText, probeCodexAppServer };
|
||||
23981
electron/bridges/aiBridge/codexAppServer/protocol.schema.json
Normal file
23981
electron/bridges/aiBridge/codexAppServer/protocol.schema.json
Normal file
File diff suppressed because it is too large
Load Diff
988
electron/bridges/aiBridge/codexAppServer/runtime.cjs
Normal file
988
electron/bridges/aiBridge/codexAppServer/runtime.cjs
Normal file
@@ -0,0 +1,988 @@
|
||||
"use strict";
|
||||
|
||||
const {
|
||||
CodexAppServerConnection,
|
||||
buildCodexAppServerKey,
|
||||
} = require("./connection.cjs");
|
||||
const {
|
||||
parseCodexModelSelection,
|
||||
toCodexMcpConfig,
|
||||
} = require("../sdk/codexDriver.cjs");
|
||||
|
||||
const INTERACTION_TIMEOUT_MS = 5 * 60 * 1000;
|
||||
const INTERRUPT_REQUEST_TIMEOUT_MS = 5_000;
|
||||
const INTERRUPT_GRACE_MS = 2_000;
|
||||
const MAX_STREAMED_PREFIX_CHARS = 256 * 1024;
|
||||
const MAX_TOOL_OUTPUT_CHARS = 1024 * 1024;
|
||||
|
||||
function appendStreamState(map, itemId, delta, maxPrefixChars = MAX_STREAMED_PREFIX_CHARS) {
|
||||
const text = String(delta || "");
|
||||
const previous = map.get(itemId) || { prefix: "", length: 0, truncated: false };
|
||||
const remaining = Math.max(0, maxPrefixChars - previous.prefix.length);
|
||||
const next = {
|
||||
prefix: remaining > 0 ? previous.prefix + text.slice(0, remaining) : previous.prefix,
|
||||
length: previous.length + text.length,
|
||||
truncated: previous.truncated || text.length > remaining,
|
||||
};
|
||||
map.set(itemId, next);
|
||||
return next;
|
||||
}
|
||||
|
||||
function appendToolOutputState(map, itemId, delta) {
|
||||
const text = String(delta || "");
|
||||
const previous = map.get(itemId) || { text: "", totalLength: 0, truncated: false };
|
||||
const remaining = Math.max(0, MAX_TOOL_OUTPUT_CHARS - previous.text.length);
|
||||
const next = {
|
||||
text: remaining > 0 ? previous.text + text.slice(0, remaining) : previous.text,
|
||||
totalLength: previous.totalLength + text.length,
|
||||
truncated: previous.truncated || text.length > remaining,
|
||||
};
|
||||
map.set(itemId, next);
|
||||
return next;
|
||||
}
|
||||
|
||||
function formatBoundedToolOutput(value, totalLength = String(value || "").length) {
|
||||
const text = String(value || "");
|
||||
if (text.length <= MAX_TOOL_OUTPUT_CHARS && totalLength <= MAX_TOOL_OUTPUT_CHARS) return text;
|
||||
const kept = text.slice(0, MAX_TOOL_OUTPUT_CHARS);
|
||||
return `${kept}\n[output truncated: ${Math.max(totalLength, text.length)} characters total]`;
|
||||
}
|
||||
|
||||
function resolveCodexPermissionConfig(permissionMode) {
|
||||
if (permissionMode === "observer") {
|
||||
return {
|
||||
approvalPolicy: "never",
|
||||
approvalsReviewer: "user",
|
||||
sandbox: "read-only",
|
||||
sandboxPolicy: { type: "readOnly", networkAccess: false },
|
||||
};
|
||||
}
|
||||
if (permissionMode === "auto") {
|
||||
return {
|
||||
approvalPolicy: "never",
|
||||
approvalsReviewer: "user",
|
||||
sandbox: "danger-full-access",
|
||||
sandboxPolicy: { type: "dangerFullAccess" },
|
||||
};
|
||||
}
|
||||
return {
|
||||
approvalPolicy: "on-request",
|
||||
approvalsReviewer: "user",
|
||||
sandbox: "read-only",
|
||||
sandboxPolicy: { type: "readOnly", networkAccess: false },
|
||||
};
|
||||
}
|
||||
|
||||
function buildThreadConfig(injectedMcpServers) {
|
||||
return {
|
||||
// Netcatty already applies its Observer/Confirm/Auto policy inside the MCP
|
||||
// bridge. Tell Codex not to add a second MCP approval prompt: App Server
|
||||
// otherwise routes the stable MCP elicitation request back to this client,
|
||||
// and rejecting/omitting that duplicate prompt surfaces as
|
||||
// "user rejected MCP tool call" before Netcatty's own gate can run.
|
||||
mcp_servers: toCodexMcpConfig(injectedMcpServers, {
|
||||
defaultToolsApprovalMode: "approve",
|
||||
}),
|
||||
model_reasoning_summary: "concise",
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeFileChanges(changes) {
|
||||
if (!Array.isArray(changes)) return [];
|
||||
return changes
|
||||
.filter((change) => change && typeof change.path === "string")
|
||||
.map((change) => ({
|
||||
path: change.path,
|
||||
kind: change.kind?.type === "add"
|
||||
? "add"
|
||||
: change.kind?.type === "delete"
|
||||
? "delete"
|
||||
: "update",
|
||||
}));
|
||||
}
|
||||
|
||||
function normalizeGrantedPermissions(requested) {
|
||||
const granted = {};
|
||||
if (requested?.network != null) granted.network = requested.network;
|
||||
if (requested?.fileSystem != null) granted.fileSystem = requested.fileSystem;
|
||||
return granted;
|
||||
}
|
||||
|
||||
function stringifyMcpContent(result) {
|
||||
if (!result) return "";
|
||||
const content = Array.isArray(result.content) ? result.content : [];
|
||||
let text = "";
|
||||
let totalLength = 0;
|
||||
for (const item of content) {
|
||||
const rawPart = item && typeof item === "object" && typeof item.text === "string"
|
||||
? item.text
|
||||
: typeof item === "string" ? item : JSON.stringify(item);
|
||||
const part = typeof rawPart === "string" ? rawPart : "";
|
||||
totalLength += part.length;
|
||||
if (text.length < MAX_TOOL_OUTPUT_CHARS) {
|
||||
text += part.slice(0, MAX_TOOL_OUTPUT_CHARS - text.length);
|
||||
}
|
||||
}
|
||||
if (text || totalLength > 0) return formatBoundedToolOutput(text, totalLength);
|
||||
if (result.structuredContent == null) return "";
|
||||
return formatBoundedToolOutput(JSON.stringify(result.structuredContent));
|
||||
}
|
||||
|
||||
function buildTurnInput(prompt, attachments) {
|
||||
const input = [{ type: "text", text: String(prompt || ""), text_elements: [] }];
|
||||
for (const attachment of attachments || []) {
|
||||
if (!attachment?.filePath) continue;
|
||||
if (!String(attachment.mediaType || "").toLowerCase().startsWith("image/")) continue;
|
||||
input.push({ type: "localImage", path: attachment.filePath });
|
||||
}
|
||||
return input;
|
||||
}
|
||||
|
||||
function getActiveTurnNotSteerableKind(error) {
|
||||
const turnKind = error?.data?.activeTurnNotSteerable?.turnKind
|
||||
?? error?.data?.codexErrorInfo?.activeTurnNotSteerable?.turnKind;
|
||||
return turnKind === "review" || turnKind === "compact" ? turnKind : null;
|
||||
}
|
||||
|
||||
function mapAppServerModels(rawModels) {
|
||||
return (Array.isArray(rawModels) ? rawModels : [])
|
||||
.filter((model) => model && model.id && !model.hidden)
|
||||
.map((model) => ({
|
||||
id: model.id,
|
||||
name: model.displayName || model.id,
|
||||
description: model.description || undefined,
|
||||
thinkingLevels: Array.isArray(model.supportedReasoningEfforts)
|
||||
? model.supportedReasoningEfforts
|
||||
.map((option) => option?.reasoningEffort)
|
||||
.filter(Boolean)
|
||||
: [],
|
||||
defaultThinkingLevel: model.defaultReasoningEffort || undefined,
|
||||
isDefault: model.isDefault === true,
|
||||
}));
|
||||
}
|
||||
|
||||
function resolveAppServerModelSelection(model) {
|
||||
if (!model) return null;
|
||||
const defaultThinkingLevel = model.defaultThinkingLevel;
|
||||
if (
|
||||
defaultThinkingLevel
|
||||
&& Array.isArray(model.thinkingLevels)
|
||||
&& model.thinkingLevels.includes(defaultThinkingLevel)
|
||||
) {
|
||||
return `${model.id}/${defaultThinkingLevel}`;
|
||||
}
|
||||
return model.id;
|
||||
}
|
||||
|
||||
class CodexAppServerRuntime {
|
||||
constructor({
|
||||
appVersion = "0.0.0",
|
||||
connectionFactory,
|
||||
sendInteractionRequest,
|
||||
sendInteractionCleared,
|
||||
interruptRequestTimeoutMs = INTERRUPT_REQUEST_TIMEOUT_MS,
|
||||
interruptGraceMs = INTERRUPT_GRACE_MS,
|
||||
} = {}) {
|
||||
this.appVersion = appVersion;
|
||||
this.connectionFactory = connectionFactory;
|
||||
this.sendInteractionRequest = sendInteractionRequest;
|
||||
this.sendInteractionCleared = sendInteractionCleared;
|
||||
this.interruptRequestTimeoutMs = interruptRequestTimeoutMs;
|
||||
this.interruptGraceMs = interruptGraceMs;
|
||||
this.connections = new Map();
|
||||
this.preferredConnectionKey = null;
|
||||
this.activeByRequest = new Map();
|
||||
this.activeByThread = new Map();
|
||||
this.activeByTurn = new Map();
|
||||
this.pendingInteractions = new Map();
|
||||
this.interactionCounter = 0;
|
||||
this.eventCounter = 0;
|
||||
}
|
||||
|
||||
#scopedKey(connectionKey, id) {
|
||||
return `${connectionKey}\u0000${String(id || "")}`;
|
||||
}
|
||||
|
||||
#getConnection(binPath, env) {
|
||||
const connectionKey = buildCodexAppServerKey(binPath, env);
|
||||
this.preferredConnectionKey = connectionKey;
|
||||
const existing = this.connections.get(connectionKey);
|
||||
if (existing) {
|
||||
this.#closeIdleConnections(connectionKey);
|
||||
return { connection: existing, connectionKey };
|
||||
}
|
||||
this.#closeIdleConnections(connectionKey);
|
||||
const factory = this.connectionFactory || ((options) => new CodexAppServerConnection(options));
|
||||
const connection = factory({
|
||||
binPath,
|
||||
env,
|
||||
appVersion: this.appVersion,
|
||||
onNotification: (message) => this.#handleNotification(connectionKey, message),
|
||||
onServerRequest: (message, source) => this.#handleServerRequest(connectionKey, source, message),
|
||||
onFatal: (error) => this.#handleConnectionFatal(connectionKey, error),
|
||||
});
|
||||
this.connections.set(connectionKey, connection);
|
||||
return { connection, connectionKey };
|
||||
}
|
||||
|
||||
#closeIdleConnections(keepKey = this.preferredConnectionKey) {
|
||||
const activeConnectionKeys = new Set(
|
||||
Array.from(this.activeByRequest.values(), (context) => context.connectionKey),
|
||||
);
|
||||
for (const [connectionKey, connection] of this.connections) {
|
||||
if (connectionKey === keepKey || activeConnectionKeys.has(connectionKey)) continue;
|
||||
this.connections.delete(connectionKey);
|
||||
try { connection.close(); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
#refreshPreferredConnectionKey() {
|
||||
if (this.preferredConnectionKey && this.connections.has(this.preferredConnectionKey)) return;
|
||||
const connectionKeys = Array.from(this.connections.keys());
|
||||
this.preferredConnectionKey = connectionKeys.at(-1) || null;
|
||||
}
|
||||
|
||||
async runTurn({
|
||||
requestId,
|
||||
chatSessionId,
|
||||
prompt,
|
||||
attachments,
|
||||
cwd,
|
||||
model,
|
||||
permissionMode,
|
||||
env,
|
||||
binPath,
|
||||
injectedMcpServers,
|
||||
resumeThreadId,
|
||||
emitter,
|
||||
signal,
|
||||
sender,
|
||||
}) {
|
||||
const throwIfAborted = () => {
|
||||
if (!signal?.aborted) return;
|
||||
const error = new Error("Codex App Server turn was interrupted before it started");
|
||||
error.name = "AbortError";
|
||||
throw error;
|
||||
};
|
||||
throwIfAborted();
|
||||
const { connection, connectionKey } = this.#getConnection(binPath, env);
|
||||
await connection.start();
|
||||
throwIfAborted();
|
||||
const permission = resolveCodexPermissionConfig(permissionMode);
|
||||
const selection = parseCodexModelSelection(model);
|
||||
const threadParams = {
|
||||
model: selection.model || null,
|
||||
cwd: cwd || process.cwd(),
|
||||
approvalPolicy: permission.approvalPolicy,
|
||||
approvalsReviewer: permission.approvalsReviewer,
|
||||
sandbox: permission.sandbox,
|
||||
config: buildThreadConfig(injectedMcpServers),
|
||||
};
|
||||
|
||||
const threadResult = resumeThreadId
|
||||
? await connection.request("thread/resume", { threadId: resumeThreadId, ...threadParams })
|
||||
: await connection.request("thread/start", threadParams);
|
||||
throwIfAborted();
|
||||
const threadId = threadResult?.thread?.id || resumeThreadId;
|
||||
if (!threadId) throw new Error("Codex App Server did not return a thread id");
|
||||
emitter.sessionId(threadId);
|
||||
|
||||
const context = {
|
||||
requestId,
|
||||
chatSessionId,
|
||||
connection,
|
||||
connectionKey,
|
||||
threadId,
|
||||
turnId: null,
|
||||
emitter,
|
||||
signal,
|
||||
sender,
|
||||
lastError: null,
|
||||
settled: false,
|
||||
cancelRequested: false,
|
||||
interruptPromise: null,
|
||||
steerPromise: null,
|
||||
reasoningOpen: false,
|
||||
streamedTextByItem: new Map(),
|
||||
streamedReasoningByItem: new Map(),
|
||||
commandOutputByItem: new Map(),
|
||||
emittedToolCalls: new Set(),
|
||||
emittedToolResults: new Set(),
|
||||
forceCancelTimer: null,
|
||||
abortListener: null,
|
||||
};
|
||||
this.activeByRequest.set(requestId, context);
|
||||
this.activeByThread.set(this.#scopedKey(connectionKey, threadId), context);
|
||||
|
||||
const completion = new Promise((resolve, reject) => {
|
||||
context.resolve = resolve;
|
||||
context.reject = reject;
|
||||
});
|
||||
if (signal) {
|
||||
context.abortListener = () => { void this.cancelTurn(requestId); };
|
||||
signal.addEventListener("abort", context.abortListener, { once: true });
|
||||
if (signal.aborted) context.abortListener();
|
||||
}
|
||||
|
||||
try {
|
||||
const turnResult = await connection.request("turn/start", {
|
||||
threadId,
|
||||
input: buildTurnInput(prompt, attachments),
|
||||
cwd: cwd || process.cwd(),
|
||||
approvalPolicy: permission.approvalPolicy,
|
||||
approvalsReviewer: permission.approvalsReviewer,
|
||||
sandboxPolicy: permission.sandboxPolicy,
|
||||
model: selection.model || null,
|
||||
effort: selection.effort || null,
|
||||
summary: "concise",
|
||||
});
|
||||
const turnId = turnResult?.turn?.id;
|
||||
if (turnId) this.#assignTurnId(context, turnId);
|
||||
await completion;
|
||||
return { threadId, turnId: context.turnId };
|
||||
} finally {
|
||||
this.#removeContext(context);
|
||||
}
|
||||
}
|
||||
|
||||
async listModels({ binPath, env }) {
|
||||
const { connection } = this.#getConnection(binPath, env);
|
||||
await connection.start();
|
||||
const all = [];
|
||||
let cursor = null;
|
||||
do {
|
||||
const response = await connection.request("model/list", {
|
||||
cursor,
|
||||
limit: 100,
|
||||
}, 10_000);
|
||||
all.push(...(response?.data || []));
|
||||
cursor = response?.nextCursor || null;
|
||||
} while (cursor);
|
||||
const models = mapAppServerModels(all);
|
||||
const defaultModel = models.find((model) => model.isDefault);
|
||||
return {
|
||||
currentModelId: resolveAppServerModelSelection(defaultModel),
|
||||
models,
|
||||
};
|
||||
}
|
||||
|
||||
async steerTurn(requestId, {
|
||||
chatSessionId,
|
||||
prompt,
|
||||
attachments,
|
||||
clientUserMessageId,
|
||||
} = {}) {
|
||||
const context = this.activeByRequest.get(requestId);
|
||||
if (!context || context.settled || context.chatSessionId !== chatSessionId) {
|
||||
return { status: "inactive" };
|
||||
}
|
||||
if (context.cancelRequested || context.signal?.aborted) {
|
||||
return { status: "cancelled" };
|
||||
}
|
||||
if (!context.turnId) {
|
||||
return { status: "busy", message: "Codex turn is still starting" };
|
||||
}
|
||||
if (context.steerPromise) {
|
||||
return { status: "busy", message: "A Codex instruction is already being sent" };
|
||||
}
|
||||
|
||||
const steerPromise = (async () => {
|
||||
try {
|
||||
const response = await context.connection.request("turn/steer", {
|
||||
threadId: context.threadId,
|
||||
expectedTurnId: context.turnId,
|
||||
input: buildTurnInput(prompt, attachments),
|
||||
clientUserMessageId: clientUserMessageId || null,
|
||||
});
|
||||
if (context.cancelRequested || context.signal?.aborted || context.settled) {
|
||||
return { status: "cancelled" };
|
||||
}
|
||||
if (response?.turnId && response.turnId !== context.turnId) {
|
||||
return {
|
||||
status: "failed",
|
||||
message: "Codex App Server returned a different turn id while steering",
|
||||
};
|
||||
}
|
||||
return { status: "accepted" };
|
||||
} catch (error) {
|
||||
const turnKind = getActiveTurnNotSteerableKind(error);
|
||||
if (turnKind) {
|
||||
return {
|
||||
status: "not-steerable",
|
||||
turnKind,
|
||||
message: error?.message || "The active Codex turn cannot be steered",
|
||||
};
|
||||
}
|
||||
if (context.cancelRequested || context.signal?.aborted || context.settled) {
|
||||
return { status: "cancelled" };
|
||||
}
|
||||
return {
|
||||
status: "failed",
|
||||
message: error?.message || String(error),
|
||||
};
|
||||
}
|
||||
})();
|
||||
|
||||
context.steerPromise = steerPromise;
|
||||
try {
|
||||
return await steerPromise;
|
||||
} finally {
|
||||
if (context.steerPromise === steerPromise) context.steerPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
#assignTurnId(context, turnId) {
|
||||
if (!turnId || context.turnId === turnId) return;
|
||||
if (context.turnId) {
|
||||
this.activeByTurn.delete(this.#scopedKey(context.connectionKey, context.turnId));
|
||||
}
|
||||
context.turnId = turnId;
|
||||
this.activeByTurn.set(this.#scopedKey(context.connectionKey, turnId), context);
|
||||
if (context.cancelRequested) void this.#interruptAndSchedule(context);
|
||||
}
|
||||
|
||||
#interruptContext(context) {
|
||||
if (!context.turnId) return Promise.resolve(false);
|
||||
if (context.interruptPromise) return context.interruptPromise;
|
||||
let timeout;
|
||||
const request = Promise.resolve().then(() => context.connection.request("turn/interrupt", {
|
||||
threadId: context.threadId,
|
||||
turnId: context.turnId,
|
||||
}, this.interruptRequestTimeoutMs)).then(() => true).catch(() => false);
|
||||
const deadline = new Promise((resolve) => {
|
||||
timeout = setTimeout(() => resolve(false), this.interruptRequestTimeoutMs);
|
||||
timeout.unref?.();
|
||||
});
|
||||
context.interruptPromise = Promise.race([request, deadline])
|
||||
.finally(() => clearTimeout(timeout));
|
||||
return context.interruptPromise;
|
||||
}
|
||||
|
||||
#scheduleForcedCancellation(context, delayMs) {
|
||||
if (context.settled) return;
|
||||
clearTimeout(context.forceCancelTimer);
|
||||
context.forceCancelTimer = setTimeout(() => {
|
||||
this.#forceCancelContext(context, "Codex App Server did not complete the interrupted turn");
|
||||
}, Math.max(0, delayMs));
|
||||
context.forceCancelTimer.unref?.();
|
||||
}
|
||||
|
||||
async #interruptAndSchedule(context) {
|
||||
if (context.settled) return;
|
||||
if (!context.turnId) {
|
||||
this.#scheduleForcedCancellation(
|
||||
context,
|
||||
this.interruptRequestTimeoutMs + this.interruptGraceMs,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const interrupted = await this.#interruptContext(context);
|
||||
if (context.settled) return;
|
||||
if (!interrupted) {
|
||||
this.#forceCancelContext(context, "Codex App Server could not interrupt the turn");
|
||||
return;
|
||||
}
|
||||
this.#scheduleForcedCancellation(context, this.interruptGraceMs);
|
||||
}
|
||||
|
||||
#forceCancelContext(context, reason) {
|
||||
if (context.settled) return;
|
||||
context.settled = true;
|
||||
clearTimeout(context.forceCancelTimer);
|
||||
context.forceCancelTimer = null;
|
||||
this.#closeReasoning(context);
|
||||
this.#clearInteractionsForContext(context, "cancel");
|
||||
context.emitter.emitDone();
|
||||
context.resolve();
|
||||
|
||||
const connection = this.connections.get(context.connectionKey);
|
||||
if (connection === context.connection) {
|
||||
this.connections.delete(context.connectionKey);
|
||||
try { connection.close(); } catch {}
|
||||
this.#refreshPreferredConnectionKey();
|
||||
}
|
||||
const error = new Error(reason);
|
||||
for (const candidate of this.activeByRequest.values()) {
|
||||
if (candidate === context || candidate.connectionKey !== context.connectionKey || candidate.settled) continue;
|
||||
candidate.settled = true;
|
||||
clearTimeout(candidate.forceCancelTimer);
|
||||
candidate.forceCancelTimer = null;
|
||||
this.#clearInteractionsForContext(candidate, "cancel");
|
||||
candidate.reject(error);
|
||||
}
|
||||
}
|
||||
|
||||
#findContext(connectionKey, params) {
|
||||
if (params?.turnId) {
|
||||
const byTurn = this.activeByTurn.get(this.#scopedKey(connectionKey, params.turnId));
|
||||
if (byTurn) return byTurn;
|
||||
}
|
||||
if (params?.threadId) {
|
||||
return this.activeByThread.get(this.#scopedKey(connectionKey, params.threadId)) || null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
#handleNotification(connectionKey, message) {
|
||||
const params = message.params || {};
|
||||
const context = this.#findContext(connectionKey, params);
|
||||
if (!context) {
|
||||
if (message.method === "warning") {
|
||||
const contexts = Array.from(this.activeByRequest.values())
|
||||
.filter((candidate) => candidate.connectionKey === connectionKey);
|
||||
for (const candidate of contexts) {
|
||||
candidate.emitter.warning(
|
||||
`codex-warning:connection:${++this.eventCounter}`,
|
||||
params.message || "Codex warning",
|
||||
);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
const emitter = context.emitter;
|
||||
|
||||
switch (message.method) {
|
||||
case "turn/started":
|
||||
this.#assignTurnId(context, params.turn?.id);
|
||||
return;
|
||||
case "item/agentMessage/delta": {
|
||||
appendStreamState(context.streamedTextByItem, params.itemId, params.delta);
|
||||
emitter.text(params.delta || "");
|
||||
return;
|
||||
}
|
||||
case "item/reasoning/summaryTextDelta": {
|
||||
appendStreamState(context.streamedReasoningByItem, params.itemId, params.delta);
|
||||
emitter.reasoning(params.delta || "");
|
||||
context.reasoningOpen = true;
|
||||
return;
|
||||
}
|
||||
case "item/commandExecution/outputDelta": {
|
||||
appendToolOutputState(context.commandOutputByItem, params.itemId, params.delta);
|
||||
return;
|
||||
}
|
||||
case "item/started":
|
||||
this.#handleItem(context, params.item, false);
|
||||
return;
|
||||
case "item/completed":
|
||||
this.#handleItem(context, params.item, true);
|
||||
return;
|
||||
case "turn/plan/updated":
|
||||
emitter.planUpdate(
|
||||
`codex-plan:${params.turnId}`,
|
||||
(params.plan || []).map((item) => ({
|
||||
text: item.step || "",
|
||||
completed: item.status === "completed",
|
||||
})),
|
||||
(params.plan || []).every((item) => item.status === "completed") ? "completed" : "running",
|
||||
);
|
||||
return;
|
||||
case "thread/tokenUsage/updated": {
|
||||
const usage = params.tokenUsage?.last;
|
||||
if (usage) {
|
||||
emitter.usage({
|
||||
inputTokens: Number(usage.inputTokens) || 0,
|
||||
cachedInputTokens: Number(usage.cachedInputTokens) || 0,
|
||||
outputTokens: Number(usage.outputTokens) || 0,
|
||||
reasoningTokens: Number(usage.reasoningOutputTokens) || 0,
|
||||
totalTokens: Number(usage.totalTokens) || 0,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
case "warning":
|
||||
emitter.warning(
|
||||
`codex-warning:${params.turnId || context.turnId}:${++this.eventCounter}`,
|
||||
params.message || "Codex warning",
|
||||
);
|
||||
return;
|
||||
case "error":
|
||||
context.lastError = params.error?.message || "Codex App Server error";
|
||||
emitter.warning(
|
||||
`codex-error:${params.turnId || context.turnId}:${++this.eventCounter}`,
|
||||
params.willRetry ? `${context.lastError} (retrying)` : context.lastError,
|
||||
);
|
||||
return;
|
||||
case "turn/completed":
|
||||
this.#completeTurn(context, params.turn);
|
||||
return;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
#closeReasoning(context) {
|
||||
if (!context.reasoningOpen) return;
|
||||
context.emitter.reasoningEnd();
|
||||
context.reasoningOpen = false;
|
||||
}
|
||||
|
||||
#emitToolCallOnce(context, item, name, args) {
|
||||
if (!item?.id || context.emittedToolCalls.has(item.id)) return;
|
||||
context.emittedToolCalls.add(item.id);
|
||||
this.#closeReasoning(context);
|
||||
context.emitter.toolCall(name, args || {}, item.id);
|
||||
}
|
||||
|
||||
#emitToolResultOnce(context, item, output, name) {
|
||||
if (!item?.id || context.emittedToolResults.has(item.id)) return;
|
||||
context.emittedToolResults.add(item.id);
|
||||
context.emitter.toolResult(item.id, output || "", name);
|
||||
}
|
||||
|
||||
#handleItem(context, item, completed) {
|
||||
if (!item || typeof item !== "object") return;
|
||||
const emitter = context.emitter;
|
||||
switch (item.type) {
|
||||
case "agentMessage": {
|
||||
if (!completed) return;
|
||||
this.#closeReasoning(context);
|
||||
const streamed = context.streamedTextByItem.get(item.id);
|
||||
context.streamedTextByItem.delete(item.id);
|
||||
if (item.text && streamed && item.text.startsWith(streamed.prefix)) {
|
||||
if (item.text.length > streamed.length) emitter.text(item.text.slice(streamed.length));
|
||||
} else if (item.text && !streamed) emitter.text(item.text);
|
||||
return;
|
||||
}
|
||||
case "reasoning": {
|
||||
if (!completed) return;
|
||||
const finalText = Array.isArray(item.summary) ? item.summary.join("\n") : "";
|
||||
const streamed = context.streamedReasoningByItem.get(item.id);
|
||||
context.streamedReasoningByItem.delete(item.id);
|
||||
if (finalText && streamed && finalText.startsWith(streamed.prefix)) {
|
||||
if (finalText.length > streamed.length) emitter.reasoning(finalText.slice(streamed.length));
|
||||
} else if (finalText && !streamed) emitter.reasoning(finalText);
|
||||
context.reasoningOpen = true;
|
||||
this.#closeReasoning(context);
|
||||
return;
|
||||
}
|
||||
case "commandExecution": {
|
||||
const toolName = "codex.command";
|
||||
this.#emitToolCallOnce(context, item, toolName, { command: item.command, cwd: item.cwd });
|
||||
if (completed) {
|
||||
const streamedOutput = context.commandOutputByItem.get(item.id);
|
||||
context.commandOutputByItem.delete(item.id);
|
||||
const output = item.aggregatedOutput == null
|
||||
? formatBoundedToolOutput(
|
||||
streamedOutput?.text || "",
|
||||
streamedOutput?.totalLength || 0,
|
||||
)
|
||||
: formatBoundedToolOutput(item.aggregatedOutput);
|
||||
const suffix = item.exitCode == null ? "" : `\n[exit code: ${item.exitCode}]`;
|
||||
this.#emitToolResultOnce(context, item, `${output}${suffix}`, toolName);
|
||||
}
|
||||
return;
|
||||
}
|
||||
case "mcpToolCall": {
|
||||
const toolName = `${item.server || "mcp"}.${item.tool || "tool"}`;
|
||||
this.#emitToolCallOnce(context, item, toolName, item.arguments || {});
|
||||
if (completed) {
|
||||
const output = item.error?.message || stringifyMcpContent(item.result);
|
||||
this.#emitToolResultOnce(context, item, output, toolName);
|
||||
}
|
||||
return;
|
||||
}
|
||||
case "fileChange":
|
||||
if (completed) {
|
||||
emitter.fileChange(
|
||||
item.id,
|
||||
normalizeFileChanges(item.changes),
|
||||
item.status === "completed" ? "completed" : "failed",
|
||||
);
|
||||
}
|
||||
return;
|
||||
case "webSearch":
|
||||
emitter.webSearch(item.id, item.query || "", completed ? "completed" : "running");
|
||||
return;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
#completeTurn(context, turn) {
|
||||
if (context.settled) return;
|
||||
context.settled = true;
|
||||
clearTimeout(context.forceCancelTimer);
|
||||
context.forceCancelTimer = null;
|
||||
this.#closeReasoning(context);
|
||||
this.#clearInteractionsForContext(context, "cancel");
|
||||
if (turn?.status === "failed") {
|
||||
context.reject(new Error(turn.error?.message || context.lastError || "Codex turn failed"));
|
||||
return;
|
||||
}
|
||||
context.emitter.emitDone();
|
||||
context.resolve();
|
||||
}
|
||||
|
||||
async #handleServerRequest(connectionKey, connection, message) {
|
||||
const params = message.params || {};
|
||||
const context = this.#findContext(connectionKey, params);
|
||||
const supported = new Map([
|
||||
["item/commandExecution/requestApproval", "command"],
|
||||
["item/fileChange/requestApproval", "file-change"],
|
||||
["item/permissions/requestApproval", "permissions"],
|
||||
["item/tool/requestUserInput", "user-input"],
|
||||
]);
|
||||
const kind = supported.get(message.method);
|
||||
if (!kind) {
|
||||
connection.respondError(message.id, -32601, `Unsupported Codex App Server request: ${message.method}`);
|
||||
context?.emitter.warning(
|
||||
`codex-unsupported-request:${++this.eventCounter}`,
|
||||
`Unsupported Codex request: ${message.method}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!context) {
|
||||
connection.respond(message.id, this.#safeInteractionResponse(kind, params, "reject"));
|
||||
return;
|
||||
}
|
||||
|
||||
const interactionId = `codex_interaction_${++this.interactionCounter}_${Date.now()}`;
|
||||
const timeoutMs = kind === "user-input" && Number(params.autoResolutionMs) > 0
|
||||
? Number(params.autoResolutionMs)
|
||||
: INTERACTION_TIMEOUT_MS;
|
||||
// Hard ceiling from creation — review can cancel the idle timer but must
|
||||
// re-arm the absolute remainder (Catty/MCP pattern; never unbounded).
|
||||
const absoluteExpiresAt = Date.now() + timeoutMs;
|
||||
const armTimer = (ms) => {
|
||||
const pending = this.pendingInteractions.get(interactionId);
|
||||
if (!pending) return;
|
||||
if (pending.timer) {
|
||||
clearTimeout(pending.timer);
|
||||
pending.timer = null;
|
||||
}
|
||||
if (ms <= 0) {
|
||||
this.#resolveInteraction(
|
||||
interactionId,
|
||||
kind === "user-input" ? { answers: {} } : { decision: "reject" },
|
||||
);
|
||||
return;
|
||||
}
|
||||
pending.timer = setTimeout(() => {
|
||||
this.#resolveInteraction(
|
||||
interactionId,
|
||||
kind === "user-input" ? { answers: {} } : { decision: "reject" },
|
||||
);
|
||||
}, ms);
|
||||
};
|
||||
this.pendingInteractions.set(interactionId, {
|
||||
interactionId,
|
||||
connection,
|
||||
rpcId: message.id,
|
||||
kind,
|
||||
params,
|
||||
context,
|
||||
timer: null,
|
||||
absoluteExpiresAt,
|
||||
idleCancelled: false,
|
||||
});
|
||||
armTimer(timeoutMs);
|
||||
|
||||
const payload = {
|
||||
interactionId,
|
||||
source: "codex-app-server",
|
||||
kind,
|
||||
requestId: context.requestId,
|
||||
chatSessionId: context.chatSessionId,
|
||||
itemId: params.itemId,
|
||||
toolName: kind === "command"
|
||||
? "codex.command"
|
||||
: kind === "file-change"
|
||||
? "codex.file_change"
|
||||
: kind === "permissions"
|
||||
? "codex.permissions"
|
||||
: undefined,
|
||||
args: kind === "command"
|
||||
? {
|
||||
command: params.command,
|
||||
cwd: params.cwd,
|
||||
reason: params.reason,
|
||||
commandActions: params.commandActions,
|
||||
}
|
||||
: kind === "file-change"
|
||||
? { reason: params.reason, grantRoot: params.grantRoot, itemId: params.itemId }
|
||||
: kind === "permissions"
|
||||
? { cwd: params.cwd, reason: params.reason, permissions: params.permissions }
|
||||
: undefined,
|
||||
availableDecisions: kind === "command" && Array.isArray(params.availableDecisions)
|
||||
? params.availableDecisions
|
||||
: undefined,
|
||||
questions: kind === "user-input" ? params.questions || [] : undefined,
|
||||
autoResolutionMs: kind === "user-input" ? params.autoResolutionMs : undefined,
|
||||
};
|
||||
let delivered = false;
|
||||
try {
|
||||
delivered = typeof this.sendInteractionRequest === "function"
|
||||
&& this.sendInteractionRequest(payload, context) !== false;
|
||||
} catch {
|
||||
delivered = false;
|
||||
}
|
||||
if (!delivered) {
|
||||
this.#resolveInteraction(interactionId, kind === "user-input" ? { answers: {} } : { decision: "reject" });
|
||||
}
|
||||
}
|
||||
|
||||
#safeInteractionResponse(kind, params, decision) {
|
||||
if (kind === "user-input") return { answers: {} };
|
||||
if (kind === "permissions") {
|
||||
const granted = decision === "once" || decision === "session"
|
||||
? normalizeGrantedPermissions(params.permissions)
|
||||
: {};
|
||||
return { permissions: granted, scope: decision === "session" ? "session" : "turn" };
|
||||
}
|
||||
const mapped = decision === "once"
|
||||
? "accept"
|
||||
: decision === "session"
|
||||
? "acceptForSession"
|
||||
: decision === "cancel"
|
||||
? "cancel"
|
||||
: "decline";
|
||||
return { decision: mapped };
|
||||
}
|
||||
|
||||
#resolveInteraction(interactionId, response) {
|
||||
const pending = this.pendingInteractions.get(interactionId);
|
||||
if (!pending) return false;
|
||||
this.pendingInteractions.delete(interactionId);
|
||||
clearTimeout(pending.timer);
|
||||
try {
|
||||
const result = pending.kind === "user-input"
|
||||
? { answers: response?.answers || {} }
|
||||
: this.#safeInteractionResponse(pending.kind, pending.params, response?.decision || "reject");
|
||||
try { pending.connection.respond(pending.rpcId, result); } catch {}
|
||||
} finally {
|
||||
this.sendInteractionCleared?.({
|
||||
interactionIds: [interactionId],
|
||||
chatSessionId: pending.context.chatSessionId,
|
||||
}, pending.context);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
respondInteraction(interactionId, response, sender) {
|
||||
const pending = this.pendingInteractions.get(interactionId);
|
||||
if (sender && pending?.context?.sender && pending.context.sender !== sender) return false;
|
||||
return this.#resolveInteraction(interactionId, response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the idle auto-reject timer after the user starts reviewing an approval card.
|
||||
* Re-arms the absolute creation deadline so a late approve cannot outlive the
|
||||
* original timeout window (matches Catty/MCP approval cancel semantics).
|
||||
*/
|
||||
cancelInteractionTimeout(interactionId, sender) {
|
||||
const pending = this.pendingInteractions.get(interactionId);
|
||||
if (!pending || pending.idleCancelled) return false;
|
||||
if (sender && pending.context?.sender && pending.context.sender !== sender) return false;
|
||||
pending.idleCancelled = true;
|
||||
if (pending.timer) {
|
||||
clearTimeout(pending.timer);
|
||||
pending.timer = null;
|
||||
}
|
||||
const remainingMs = Math.max(0, (pending.absoluteExpiresAt ?? 0) - Date.now());
|
||||
if (remainingMs <= 0) {
|
||||
this.#resolveInteraction(
|
||||
interactionId,
|
||||
pending.kind === "user-input" ? { answers: {} } : { decision: "reject" },
|
||||
);
|
||||
return true;
|
||||
}
|
||||
pending.timer = setTimeout(() => {
|
||||
this.#resolveInteraction(
|
||||
interactionId,
|
||||
pending.kind === "user-input" ? { answers: {} } : { decision: "reject" },
|
||||
);
|
||||
}, remainingMs);
|
||||
return true;
|
||||
}
|
||||
|
||||
#clearInteractionsForContext(context, decision) {
|
||||
for (const [interactionId, pending] of Array.from(this.pendingInteractions)) {
|
||||
if (pending.context === context) {
|
||||
this.#resolveInteraction(
|
||||
interactionId,
|
||||
pending.kind === "user-input" ? { answers: {} } : { decision },
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async cancelTurn(requestId) {
|
||||
const context = this.activeByRequest.get(requestId);
|
||||
if (!context) return false;
|
||||
context.cancelRequested = true;
|
||||
this.#clearInteractionsForContext(context, "cancel");
|
||||
await this.#interruptAndSchedule(context);
|
||||
return true;
|
||||
}
|
||||
|
||||
async cleanupChatSession(chatSessionId) {
|
||||
const contexts = Array.from(this.activeByRequest.values())
|
||||
.filter((context) => context.chatSessionId === chatSessionId);
|
||||
await Promise.all(contexts.map((context) => this.cancelTurn(context.requestId)));
|
||||
}
|
||||
|
||||
#handleConnectionFatal(connectionKey, error) {
|
||||
const connection = this.connections.get(connectionKey);
|
||||
if (connection) {
|
||||
this.connections.delete(connectionKey);
|
||||
this.#refreshPreferredConnectionKey();
|
||||
}
|
||||
const contexts = Array.from(this.activeByRequest.values())
|
||||
.filter((context) => context.connectionKey === connectionKey);
|
||||
for (const context of contexts) {
|
||||
if (context.settled) continue;
|
||||
context.settled = true;
|
||||
clearTimeout(context.forceCancelTimer);
|
||||
context.forceCancelTimer = null;
|
||||
this.#clearInteractionsForContext(context, "cancel");
|
||||
context.reject(error);
|
||||
}
|
||||
}
|
||||
|
||||
#removeContext(context) {
|
||||
clearTimeout(context.forceCancelTimer);
|
||||
context.forceCancelTimer = null;
|
||||
if (context.abortListener && context.signal) {
|
||||
context.signal.removeEventListener("abort", context.abortListener);
|
||||
context.abortListener = null;
|
||||
}
|
||||
this.activeByRequest.delete(context.requestId);
|
||||
this.activeByThread.delete(this.#scopedKey(context.connectionKey, context.threadId));
|
||||
if (context.turnId) this.activeByTurn.delete(this.#scopedKey(context.connectionKey, context.turnId));
|
||||
this.#closeIdleConnections();
|
||||
}
|
||||
|
||||
close() {
|
||||
for (const interactionId of Array.from(this.pendingInteractions.keys())) {
|
||||
this.#resolveInteraction(interactionId, { decision: "cancel", answers: {} });
|
||||
}
|
||||
for (const [, connection] of this.connections) connection.close();
|
||||
this.connections.clear();
|
||||
this.preferredConnectionKey = null;
|
||||
for (const context of this.activeByRequest.values()) {
|
||||
if (!context.settled) {
|
||||
context.settled = true;
|
||||
clearTimeout(context.forceCancelTimer);
|
||||
context.forceCancelTimer = null;
|
||||
context.reject(new Error("Codex App Server shut down"));
|
||||
}
|
||||
}
|
||||
this.activeByRequest.clear();
|
||||
this.activeByThread.clear();
|
||||
this.activeByTurn.clear();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
CodexAppServerRuntime,
|
||||
INTERACTION_TIMEOUT_MS,
|
||||
buildThreadConfig,
|
||||
buildTurnInput,
|
||||
getActiveTurnNotSteerableKind,
|
||||
mapAppServerModels,
|
||||
normalizeFileChanges,
|
||||
normalizeGrantedPermissions,
|
||||
resolveAppServerModelSelection,
|
||||
resolveCodexPermissionConfig,
|
||||
stringifyMcpContent,
|
||||
};
|
||||
850
electron/bridges/aiBridge/codexAppServer/runtime.test.cjs
Normal file
850
electron/bridges/aiBridge/codexAppServer/runtime.test.cjs
Normal file
@@ -0,0 +1,850 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const {
|
||||
CodexAppServerRuntime,
|
||||
buildTurnInput,
|
||||
mapAppServerModels,
|
||||
normalizeFileChanges,
|
||||
resolveCodexPermissionConfig,
|
||||
} = require("./runtime.cjs");
|
||||
|
||||
class FakeConnection {
|
||||
constructor(options) {
|
||||
this.options = options;
|
||||
this.requests = [];
|
||||
this.responses = [];
|
||||
this.threadId = "thread-1";
|
||||
this.turnId = "turn-1";
|
||||
this.closed = false;
|
||||
}
|
||||
async start() { return this; }
|
||||
async request(method, params) {
|
||||
this.requests.push({ method, params });
|
||||
if (method === "thread/start" || method === "thread/resume") {
|
||||
return { thread: { id: this.threadId } };
|
||||
}
|
||||
if (method === "turn/start") {
|
||||
if (this.turnStartGate) await this.turnStartGate;
|
||||
return { turn: { id: this.turnId } };
|
||||
}
|
||||
if (method === "turn/steer") {
|
||||
if (this.turnSteerGate) await this.turnSteerGate;
|
||||
if (this.turnSteerError) throw this.turnSteerError;
|
||||
return { turnId: this.turnId };
|
||||
}
|
||||
if (method === "turn/interrupt") {
|
||||
if (this.turnInterruptGate) await this.turnInterruptGate;
|
||||
if (this.turnInterruptError) throw this.turnInterruptError;
|
||||
return {};
|
||||
}
|
||||
if (method === "model/list") {
|
||||
return {
|
||||
data: [
|
||||
{
|
||||
id: "gpt-first",
|
||||
displayName: "GPT First",
|
||||
description: "First model in the catalog",
|
||||
hidden: false,
|
||||
supportedReasoningEfforts: [{ reasoningEffort: "low" }],
|
||||
defaultReasoningEffort: "low",
|
||||
isDefault: false,
|
||||
},
|
||||
{
|
||||
id: "gpt-test",
|
||||
displayName: "GPT Test",
|
||||
description: "Server default model",
|
||||
hidden: false,
|
||||
supportedReasoningEfforts: [{ reasoningEffort: "low" }, { reasoningEffort: "high" }],
|
||||
defaultReasoningEffort: "high",
|
||||
isDefault: true,
|
||||
},
|
||||
],
|
||||
nextCursor: null,
|
||||
};
|
||||
}
|
||||
return {};
|
||||
}
|
||||
respond(id, result) { this.responses.push({ id, result }); }
|
||||
respondError(id, code, message) { this.responses.push({ id, error: { code, message } }); }
|
||||
notify(message) { this.options.onNotification(message); }
|
||||
serverRequest(message) { return this.options.onServerRequest(message, this); }
|
||||
close() { this.closed = true; }
|
||||
}
|
||||
|
||||
function createEmitter() {
|
||||
const events = [];
|
||||
return {
|
||||
events,
|
||||
emitDone: () => events.push(["done"]),
|
||||
sessionId: (id) => events.push(["session", id]),
|
||||
text: (text) => events.push(["text", text]),
|
||||
reasoning: (text) => events.push(["reasoning", text]),
|
||||
reasoningEnd: () => events.push(["reasoning-end"]),
|
||||
toolCall: (name, args, id) => events.push(["tool-call", name, args, id]),
|
||||
toolResult: (id, output, name) => events.push(["tool-result", id, output, name]),
|
||||
fileChange: (id, changes, status) => events.push(["file-change", id, changes, status]),
|
||||
webSearch: (id, query, status) => events.push(["web-search", id, query, status]),
|
||||
planUpdate: (id, items, status) => events.push(["plan", id, items, status]),
|
||||
warning: (id, message) => events.push(["warning", id, message]),
|
||||
usage: (usage) => events.push(["usage", usage]),
|
||||
};
|
||||
}
|
||||
|
||||
async function waitFor(predicate) {
|
||||
for (let index = 0; index < 50; index += 1) {
|
||||
if (predicate()) return;
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
}
|
||||
throw new Error("condition not reached");
|
||||
}
|
||||
|
||||
test("permission modes map to fail-closed Codex policies", () => {
|
||||
assert.deepEqual(resolveCodexPermissionConfig("observer"), {
|
||||
approvalPolicy: "never",
|
||||
approvalsReviewer: "user",
|
||||
sandbox: "read-only",
|
||||
sandboxPolicy: { type: "readOnly", networkAccess: false },
|
||||
});
|
||||
assert.equal(resolveCodexPermissionConfig("confirm").approvalPolicy, "on-request");
|
||||
assert.equal(resolveCodexPermissionConfig("confirm").sandbox, "read-only");
|
||||
assert.equal(resolveCodexPermissionConfig("auto").sandbox, "danger-full-access");
|
||||
});
|
||||
|
||||
test("turn input uses text plus local images only", () => {
|
||||
assert.deepEqual(buildTurnInput("hello", [
|
||||
{ filePath: "/tmp/a.png", mediaType: "image/png" },
|
||||
{ filePath: "/tmp/a.txt", mediaType: "text/plain" },
|
||||
]), [
|
||||
{ type: "text", text: "hello", text_elements: [] },
|
||||
{ type: "localImage", path: "/tmp/a.png" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("runtime maps lifecycle, activities, usage, and retry warnings", async () => {
|
||||
let connection;
|
||||
const runtime = new CodexAppServerRuntime({
|
||||
connectionFactory: (options) => (connection = new FakeConnection(options)),
|
||||
});
|
||||
const emitter = createEmitter();
|
||||
const run = runtime.runTurn({
|
||||
requestId: "request-1",
|
||||
chatSessionId: "chat-1",
|
||||
prompt: "hello",
|
||||
cwd: "/repo",
|
||||
model: "gpt-test/high",
|
||||
permissionMode: "confirm",
|
||||
env: { HOME: "/home" },
|
||||
binPath: "/bin/codex",
|
||||
injectedMcpServers: [],
|
||||
emitter,
|
||||
});
|
||||
await waitFor(() => connection?.requests.some((request) => request.method === "turn/start"));
|
||||
|
||||
connection.notify({ method: "item/agentMessage/delta", params: { threadId: "thread-1", turnId: "turn-1", itemId: "msg-1", delta: "Hi" } });
|
||||
connection.notify({ method: "turn/plan/updated", params: { threadId: "thread-1", turnId: "turn-1", plan: [{ step: "Inspect", status: "completed" }] } });
|
||||
connection.notify({ method: "item/started", params: { threadId: "thread-1", turnId: "turn-1", item: { type: "webSearch", id: "search-1", query: "Netcatty" } } });
|
||||
connection.notify({ method: "item/completed", params: { threadId: "thread-1", turnId: "turn-1", item: { type: "fileChange", id: "file-1", status: "completed", changes: [{ path: "a.ts", kind: { type: "add" } }] } } });
|
||||
connection.notify({ method: "thread/tokenUsage/updated", params: { threadId: "thread-1", turnId: "turn-1", tokenUsage: { last: { inputTokens: 10, cachedInputTokens: 2, outputTokens: 3, reasoningOutputTokens: 1, totalTokens: 13 } } } });
|
||||
connection.notify({ method: "error", params: { threadId: "thread-1", turnId: "turn-1", willRetry: true, error: { message: "network" } } });
|
||||
connection.notify({ method: "warning", params: { message: "global warning" } });
|
||||
connection.notify({ method: "turn/completed", params: { threadId: "thread-1", turn: { id: "turn-1", status: "completed", error: null } } });
|
||||
await run;
|
||||
|
||||
assert.ok(emitter.events.some((event) => event[0] === "text" && event[1] === "Hi"));
|
||||
assert.ok(emitter.events.some((event) => event[0] === "plan"));
|
||||
assert.ok(emitter.events.some((event) => event[0] === "web-search" && event[3] === "running"));
|
||||
assert.ok(emitter.events.some((event) => event[0] === "file-change" && event[3] === "completed"));
|
||||
assert.ok(emitter.events.some((event) => event[0] === "usage" && event[1].cachedInputTokens === 2));
|
||||
assert.ok(emitter.events.some((event) => event[0] === "warning" && /retrying/.test(event[2])));
|
||||
assert.ok(emitter.events.some((event) => event[0] === "warning" && event[2] === "global warning"));
|
||||
assert.ok(emitter.events.some((event) => event[0] === "done"));
|
||||
});
|
||||
|
||||
test("runtime bounds command output and avoids replaying streamed message prefixes", async () => {
|
||||
let connection;
|
||||
const runtime = new CodexAppServerRuntime({
|
||||
connectionFactory: (options) => (connection = new FakeConnection(options)),
|
||||
});
|
||||
const emitter = createEmitter();
|
||||
const run = runtime.runTurn({
|
||||
requestId: "request-bounded-output",
|
||||
chatSessionId: "chat-bounded-output",
|
||||
prompt: "run",
|
||||
permissionMode: "confirm",
|
||||
env: {},
|
||||
binPath: "/bin/codex",
|
||||
injectedMcpServers: [],
|
||||
emitter,
|
||||
});
|
||||
await waitFor(() => connection?.requests.some((request) => request.method === "turn/start"));
|
||||
|
||||
connection.notify({ method: "item/agentMessage/delta", params: {
|
||||
threadId: "thread-1", turnId: "turn-1", itemId: "msg-bounded", delta: "Hello",
|
||||
} });
|
||||
connection.notify({ method: "item/completed", params: {
|
||||
threadId: "thread-1", turnId: "turn-1",
|
||||
item: { type: "agentMessage", id: "msg-bounded", text: "Hello world" },
|
||||
} });
|
||||
connection.notify({ method: "item/commandExecution/outputDelta", params: {
|
||||
threadId: "thread-1", turnId: "turn-1", itemId: "cmd-bounded",
|
||||
delta: "x".repeat(1024 * 1024 + 1024),
|
||||
} });
|
||||
connection.notify({ method: "item/completed", params: {
|
||||
threadId: "thread-1", turnId: "turn-1",
|
||||
item: { type: "commandExecution", id: "cmd-bounded", command: "large", exitCode: 0 },
|
||||
} });
|
||||
connection.notify({ method: "turn/completed", params: {
|
||||
threadId: "thread-1", turn: { id: "turn-1", status: "completed", error: null },
|
||||
} });
|
||||
await run;
|
||||
|
||||
assert.deepEqual(
|
||||
emitter.events.filter((event) => event[0] === "text"),
|
||||
[["text", "Hello"], ["text", " world"]],
|
||||
);
|
||||
const toolResult = emitter.events.find((event) => event[0] === "tool-result");
|
||||
assert.ok(toolResult);
|
||||
assert.ok(toolResult[2].length < 1024 * 1024 + 200);
|
||||
assert.match(toolResult[2], /output truncated: 1049600 characters total/);
|
||||
assert.match(toolResult[2], /\[exit code: 0\]$/);
|
||||
});
|
||||
|
||||
test("runtime delegates injected MCP approvals to Netcatty's policy gate", async () => {
|
||||
let connection;
|
||||
const runtime = new CodexAppServerRuntime({
|
||||
connectionFactory: (options) => (connection = new FakeConnection(options)),
|
||||
});
|
||||
const run = runtime.runTurn({
|
||||
requestId: "request-mcp-policy",
|
||||
chatSessionId: "chat-mcp-policy",
|
||||
prompt: "inspect the terminal",
|
||||
permissionMode: "confirm",
|
||||
env: {},
|
||||
binPath: "/bin/codex",
|
||||
injectedMcpServers: [{
|
||||
name: "netcatty-remote-hosts",
|
||||
command: "/abs/electron",
|
||||
args: ["/abs/server.cjs"],
|
||||
env: [{ name: "NETCATTY_MCP_PERMISSION_MODE", value: "confirm" }],
|
||||
}],
|
||||
emitter: createEmitter(),
|
||||
});
|
||||
await waitFor(() => connection?.requests.some((request) => request.method === "turn/start"));
|
||||
|
||||
const threadStart = connection.requests.find((request) => request.method === "thread/start");
|
||||
assert.deepEqual(threadStart.params.config.mcp_servers["netcatty-remote-hosts"], {
|
||||
command: "/abs/electron",
|
||||
args: ["/abs/server.cjs"],
|
||||
env: { NETCATTY_MCP_PERMISSION_MODE: "confirm" },
|
||||
default_tools_approval_mode: "approve",
|
||||
});
|
||||
|
||||
connection.notify({
|
||||
method: "turn/completed",
|
||||
params: { threadId: "thread-1", turn: { id: "turn-1", status: "completed", error: null } },
|
||||
});
|
||||
await run;
|
||||
});
|
||||
|
||||
test("runtime routes native approvals and request_user_input responses", async () => {
|
||||
let connection;
|
||||
let interaction;
|
||||
const runtime = new CodexAppServerRuntime({
|
||||
connectionFactory: (options) => (connection = new FakeConnection(options)),
|
||||
sendInteractionRequest: (payload) => { interaction = payload; return true; },
|
||||
});
|
||||
const run = runtime.runTurn({
|
||||
requestId: "request-2",
|
||||
chatSessionId: "chat-2",
|
||||
prompt: "change it",
|
||||
permissionMode: "confirm",
|
||||
env: {},
|
||||
binPath: "/bin/codex",
|
||||
injectedMcpServers: [],
|
||||
emitter: createEmitter(),
|
||||
});
|
||||
await waitFor(() => connection?.requests.some((request) => request.method === "turn/start"));
|
||||
|
||||
await connection.serverRequest({
|
||||
id: 70,
|
||||
method: "item/commandExecution/requestApproval",
|
||||
params: {
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
itemId: "cmd-1",
|
||||
command: "npm test",
|
||||
cwd: "/repo",
|
||||
availableDecisions: ["accept", "acceptForSession", "decline", "cancel"],
|
||||
},
|
||||
});
|
||||
assert.equal(interaction.kind, "command");
|
||||
assert.deepEqual(interaction.availableDecisions, ["accept", "acceptForSession", "decline", "cancel"]);
|
||||
runtime.respondInteraction(interaction.interactionId, { decision: "session" });
|
||||
assert.deepEqual(connection.responses.at(-1), { id: 70, result: { decision: "acceptForSession" } });
|
||||
|
||||
await connection.serverRequest({
|
||||
id: 72,
|
||||
method: "item/permissions/requestApproval",
|
||||
params: {
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
itemId: "permissions-1",
|
||||
permissions: { network: { enabled: true }, fileSystem: null },
|
||||
cwd: "/repo",
|
||||
},
|
||||
});
|
||||
runtime.respondInteraction(interaction.interactionId, { decision: "once" });
|
||||
assert.deepEqual(connection.responses.at(-1), {
|
||||
id: 72,
|
||||
result: { permissions: { network: { enabled: true } }, scope: "turn" },
|
||||
});
|
||||
|
||||
await connection.serverRequest({
|
||||
id: 71,
|
||||
method: "item/tool/requestUserInput",
|
||||
params: { threadId: "thread-1", turnId: "turn-1", itemId: "question-1", questions: [{ id: "choice", question: "Choose", header: "Mode", isOther: true, isSecret: false, options: null }] },
|
||||
});
|
||||
runtime.respondInteraction(interaction.interactionId, { answers: { choice: { answers: ["safe"] } } });
|
||||
assert.deepEqual(connection.responses.at(-1), { id: 71, result: { answers: { choice: { answers: ["safe"] } } } });
|
||||
|
||||
connection.notify({ method: "turn/completed", params: { threadId: "thread-1", turn: { id: "turn-1", status: "completed", error: null } } });
|
||||
await run;
|
||||
});
|
||||
|
||||
test("cancelInteractionTimeout re-arms the absolute approval deadline (Catty/MCP style)", async () => {
|
||||
let connection;
|
||||
let interaction;
|
||||
const realNow = Date.now;
|
||||
let now = 5_000_000;
|
||||
Date.now = () => now;
|
||||
|
||||
const runtime = new CodexAppServerRuntime({
|
||||
connectionFactory: (options) => (connection = new FakeConnection(options)),
|
||||
sendInteractionRequest: (payload) => { interaction = payload; return true; },
|
||||
});
|
||||
try {
|
||||
const run = runtime.runTurn({
|
||||
requestId: "request-timeout-cancel",
|
||||
chatSessionId: "chat-timeout-cancel",
|
||||
prompt: "ask",
|
||||
permissionMode: "confirm",
|
||||
env: {},
|
||||
binPath: "/bin/codex",
|
||||
injectedMcpServers: [],
|
||||
emitter: createEmitter(),
|
||||
});
|
||||
await waitFor(() => connection?.requests.some((request) => request.method === "turn/start"));
|
||||
|
||||
await connection.serverRequest({
|
||||
id: 90,
|
||||
method: "item/tool/requestUserInput",
|
||||
params: {
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
itemId: "question-timeout",
|
||||
questions: [{ id: "choice", question: "Choose", header: "Mode", isOther: true, isSecret: false, options: null }],
|
||||
autoResolutionMs: 100,
|
||||
},
|
||||
});
|
||||
assert.ok(interaction?.interactionId);
|
||||
|
||||
// Jump close to the absolute ceiling, then cancel idle. Remaining absolute ~30ms.
|
||||
now += 70;
|
||||
assert.equal(runtime.cancelInteractionTimeout(interaction.interactionId), true);
|
||||
assert.equal(runtime.cancelInteractionTimeout(interaction.interactionId), false);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 15));
|
||||
assert.equal(
|
||||
connection.responses.some((response) => response.id === 90),
|
||||
false,
|
||||
"must stay pending before absolute expiry",
|
||||
);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 80));
|
||||
assert.equal(
|
||||
connection.responses.some((response) => response.id === 90),
|
||||
true,
|
||||
"absolute deadline must auto-reject after remaining time elapses",
|
||||
);
|
||||
assert.deepEqual(connection.responses.at(-1), {
|
||||
id: 90,
|
||||
result: { answers: {} },
|
||||
});
|
||||
|
||||
connection.notify({ method: "turn/completed", params: { threadId: "thread-1", turn: { id: "turn-1", status: "completed", error: null } } });
|
||||
await run;
|
||||
} finally {
|
||||
Date.now = realNow;
|
||||
}
|
||||
});
|
||||
|
||||
test("cancelInteractionTimeout still allows explicit approve before absolute expiry", async () => {
|
||||
let connection;
|
||||
let interaction;
|
||||
const runtime = new CodexAppServerRuntime({
|
||||
connectionFactory: (options) => (connection = new FakeConnection(options)),
|
||||
sendInteractionRequest: (payload) => { interaction = payload; return true; },
|
||||
});
|
||||
const run = runtime.runTurn({
|
||||
requestId: "request-timeout-approve",
|
||||
chatSessionId: "chat-timeout-approve",
|
||||
prompt: "ask",
|
||||
permissionMode: "confirm",
|
||||
env: {},
|
||||
binPath: "/bin/codex",
|
||||
injectedMcpServers: [],
|
||||
emitter: createEmitter(),
|
||||
});
|
||||
await waitFor(() => connection?.requests.some((request) => request.method === "turn/start"));
|
||||
|
||||
await connection.serverRequest({
|
||||
id: 91,
|
||||
method: "item/commandExecution/requestApproval",
|
||||
params: {
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
itemId: "cmd-approve",
|
||||
command: "echo ok",
|
||||
cwd: "/tmp",
|
||||
reason: "demo",
|
||||
},
|
||||
});
|
||||
assert.ok(interaction?.interactionId);
|
||||
assert.equal(runtime.cancelInteractionTimeout(interaction.interactionId), true);
|
||||
assert.equal(runtime.respondInteraction(interaction.interactionId, { decision: "once" }), true);
|
||||
assert.deepEqual(connection.responses.at(-1), {
|
||||
id: 91,
|
||||
result: { decision: "accept" },
|
||||
});
|
||||
|
||||
connection.notify({ method: "turn/completed", params: { threadId: "thread-1", turn: { id: "turn-1", status: "completed", error: null } } });
|
||||
await run;
|
||||
});
|
||||
|
||||
test("runtime steers the active turn with text, local images, and a stable user message id", async () => {
|
||||
let connection;
|
||||
const runtime = new CodexAppServerRuntime({
|
||||
connectionFactory: (options) => (connection = new FakeConnection(options)),
|
||||
});
|
||||
const run = runtime.runTurn({
|
||||
requestId: "request-steer",
|
||||
chatSessionId: "chat-steer",
|
||||
prompt: "initial",
|
||||
permissionMode: "confirm",
|
||||
env: {},
|
||||
binPath: "/bin/codex",
|
||||
injectedMcpServers: [],
|
||||
emitter: createEmitter(),
|
||||
});
|
||||
await waitFor(() => connection?.requests.some((request) => request.method === "turn/start"));
|
||||
|
||||
const result = await runtime.steerTurn("request-steer", {
|
||||
chatSessionId: "chat-steer",
|
||||
prompt: "use this image",
|
||||
attachments: [
|
||||
{ filePath: "/tmp/image.png", mediaType: "image/png" },
|
||||
{ filePath: "/tmp/notes.txt", mediaType: "text/plain" },
|
||||
],
|
||||
clientUserMessageId: "user-steer-1",
|
||||
});
|
||||
|
||||
assert.deepEqual(result, { status: "accepted" });
|
||||
assert.deepEqual(connection.requests.find((request) => request.method === "turn/steer"), {
|
||||
method: "turn/steer",
|
||||
params: {
|
||||
threadId: "thread-1",
|
||||
expectedTurnId: "turn-1",
|
||||
input: [
|
||||
{ type: "text", text: "use this image", text_elements: [] },
|
||||
{ type: "localImage", path: "/tmp/image.png" },
|
||||
],
|
||||
clientUserMessageId: "user-steer-1",
|
||||
},
|
||||
});
|
||||
|
||||
connection.notify({ method: "turn/completed", params: { threadId: "thread-1", turn: { id: "turn-1", status: "completed", error: null } } });
|
||||
await run;
|
||||
});
|
||||
|
||||
test("runtime serializes steering and classifies non-steerable turns", async () => {
|
||||
let connection;
|
||||
let releaseSteer;
|
||||
const runtime = new CodexAppServerRuntime({
|
||||
connectionFactory: (options) => {
|
||||
connection = new FakeConnection(options);
|
||||
connection.turnSteerGate = new Promise((resolve) => { releaseSteer = resolve; });
|
||||
return connection;
|
||||
},
|
||||
});
|
||||
const run = runtime.runTurn({
|
||||
requestId: "request-steer-busy",
|
||||
chatSessionId: "chat-steer-busy",
|
||||
prompt: "initial",
|
||||
permissionMode: "confirm",
|
||||
env: {},
|
||||
binPath: "/bin/codex",
|
||||
injectedMcpServers: [],
|
||||
emitter: createEmitter(),
|
||||
});
|
||||
await waitFor(() => connection?.requests.some((request) => request.method === "turn/start"));
|
||||
|
||||
const first = runtime.steerTurn("request-steer-busy", {
|
||||
chatSessionId: "chat-steer-busy",
|
||||
prompt: "first",
|
||||
clientUserMessageId: "user-first",
|
||||
});
|
||||
await waitFor(() => connection.requests.some((request) => request.method === "turn/steer"));
|
||||
assert.equal((await runtime.steerTurn("request-steer-busy", {
|
||||
chatSessionId: "chat-steer-busy",
|
||||
prompt: "second",
|
||||
clientUserMessageId: "user-second",
|
||||
})).status, "busy");
|
||||
releaseSteer();
|
||||
assert.equal((await first).status, "accepted");
|
||||
|
||||
const error = new Error("active turn cannot be steered");
|
||||
error.data = { activeTurnNotSteerable: { turnKind: "review" } };
|
||||
connection.turnSteerError = error;
|
||||
const rejected = await runtime.steerTurn("request-steer-busy", {
|
||||
chatSessionId: "chat-steer-busy",
|
||||
prompt: "review change",
|
||||
clientUserMessageId: "user-review",
|
||||
});
|
||||
assert.deepEqual(rejected, {
|
||||
status: "not-steerable",
|
||||
turnKind: "review",
|
||||
message: "active turn cannot be steered",
|
||||
});
|
||||
|
||||
connection.notify({ method: "turn/completed", params: { threadId: "thread-1", turn: { id: "turn-1", status: "completed", error: null } } });
|
||||
await run;
|
||||
});
|
||||
|
||||
test("stop during steering cancels the UI result without creating a replacement turn", async () => {
|
||||
let connection;
|
||||
let releaseSteer;
|
||||
const runtime = new CodexAppServerRuntime({
|
||||
connectionFactory: (options) => {
|
||||
connection = new FakeConnection(options);
|
||||
connection.turnSteerGate = new Promise((resolve) => { releaseSteer = resolve; });
|
||||
return connection;
|
||||
},
|
||||
});
|
||||
const run = runtime.runTurn({
|
||||
requestId: "request-steer-stop",
|
||||
chatSessionId: "chat-steer-stop",
|
||||
prompt: "initial",
|
||||
permissionMode: "confirm",
|
||||
env: {},
|
||||
binPath: "/bin/codex",
|
||||
injectedMcpServers: [],
|
||||
emitter: createEmitter(),
|
||||
});
|
||||
await waitFor(() => connection?.requests.some((request) => request.method === "turn/start"));
|
||||
const steer = runtime.steerTurn("request-steer-stop", {
|
||||
chatSessionId: "chat-steer-stop",
|
||||
prompt: "too late",
|
||||
clientUserMessageId: "user-steer-stop",
|
||||
});
|
||||
await waitFor(() => connection.requests.some((request) => request.method === "turn/steer"));
|
||||
assert.equal(await runtime.cancelTurn("request-steer-stop"), true);
|
||||
releaseSteer();
|
||||
assert.equal((await steer).status, "cancelled");
|
||||
assert.equal(connection.requests.filter((request) => request.method === "turn/start").length, 1);
|
||||
|
||||
connection.notify({ method: "turn/completed", params: { threadId: "thread-1", turn: { id: "turn-1", status: "interrupted", error: null } } });
|
||||
await run;
|
||||
});
|
||||
|
||||
test("stop requested while turn/start is pending interrupts the assigned turn", async () => {
|
||||
let connection;
|
||||
let releaseTurnStart;
|
||||
const runtime = new CodexAppServerRuntime({
|
||||
connectionFactory: (options) => {
|
||||
connection = new FakeConnection(options);
|
||||
connection.turnStartGate = new Promise((resolve) => { releaseTurnStart = resolve; });
|
||||
return connection;
|
||||
},
|
||||
});
|
||||
const emitter = createEmitter();
|
||||
const run = runtime.runTurn({
|
||||
requestId: "request-stop",
|
||||
chatSessionId: "chat-stop",
|
||||
prompt: "wait",
|
||||
permissionMode: "confirm",
|
||||
env: {},
|
||||
binPath: "/bin/codex",
|
||||
injectedMcpServers: [],
|
||||
emitter,
|
||||
});
|
||||
await waitFor(() => connection?.requests.some((request) => request.method === "turn/start"));
|
||||
assert.equal(await runtime.cancelTurn("request-stop"), true);
|
||||
assert.equal(connection.requests.some((request) => request.method === "turn/interrupt"), false);
|
||||
|
||||
releaseTurnStart();
|
||||
await waitFor(() => connection.requests.some((request) => request.method === "turn/interrupt"));
|
||||
connection.notify({
|
||||
method: "turn/completed",
|
||||
params: { threadId: "thread-1", turn: { id: "turn-1", status: "interrupted", error: null } },
|
||||
});
|
||||
await run;
|
||||
assert.equal(connection.requests.filter((request) => request.method === "turn/interrupt").length, 1);
|
||||
assert.ok(emitter.events.some((event) => event[0] === "done"));
|
||||
});
|
||||
|
||||
test("failed interrupt force-settles the turn and releases its connection", async () => {
|
||||
let connection;
|
||||
const runtime = new CodexAppServerRuntime({
|
||||
interruptGraceMs: 5,
|
||||
connectionFactory: (options) => {
|
||||
connection = new FakeConnection(options);
|
||||
connection.turnInterruptError = new Error("interrupt unavailable");
|
||||
return connection;
|
||||
},
|
||||
});
|
||||
const run = runtime.runTurn({
|
||||
requestId: "request-interrupt-failure",
|
||||
chatSessionId: "chat-interrupt-failure",
|
||||
prompt: "wait",
|
||||
permissionMode: "confirm",
|
||||
env: {},
|
||||
binPath: "/bin/codex",
|
||||
injectedMcpServers: [],
|
||||
emitter: createEmitter(),
|
||||
});
|
||||
await waitFor(() => connection?.requests.some((request) => request.method === "turn/start"));
|
||||
|
||||
assert.equal(await runtime.cancelTurn("request-interrupt-failure"), true);
|
||||
await Promise.race([
|
||||
run,
|
||||
new Promise((_, reject) => setTimeout(() => reject(new Error("turn did not settle")), 50)),
|
||||
]);
|
||||
|
||||
assert.equal(connection.closed, true);
|
||||
assert.equal(runtime.activeByRequest.size, 0);
|
||||
assert.equal(runtime.activeByThread.size, 0);
|
||||
assert.equal(runtime.activeByTurn.size, 0);
|
||||
});
|
||||
|
||||
test("hung interrupt request times out and force-settles the turn", async () => {
|
||||
let connection;
|
||||
const runtime = new CodexAppServerRuntime({
|
||||
interruptRequestTimeoutMs: 5,
|
||||
interruptGraceMs: 5,
|
||||
connectionFactory: (options) => {
|
||||
connection = new FakeConnection(options);
|
||||
connection.turnInterruptGate = new Promise(() => {});
|
||||
return connection;
|
||||
},
|
||||
});
|
||||
const run = runtime.runTurn({
|
||||
requestId: "request-interrupt-hung",
|
||||
chatSessionId: "chat-interrupt-hung",
|
||||
prompt: "wait",
|
||||
permissionMode: "confirm",
|
||||
env: {},
|
||||
binPath: "/bin/codex",
|
||||
injectedMcpServers: [],
|
||||
emitter: createEmitter(),
|
||||
});
|
||||
await waitFor(() => connection?.requests.some((request) => request.method === "turn/start"));
|
||||
|
||||
assert.equal(await runtime.cancelTurn("request-interrupt-hung"), true);
|
||||
await Promise.race([
|
||||
run,
|
||||
new Promise((_, reject) => setTimeout(() => reject(new Error("turn did not settle")), 50)),
|
||||
]);
|
||||
|
||||
assert.equal(connection.closed, true);
|
||||
assert.equal(runtime.activeByRequest.size, 0);
|
||||
});
|
||||
|
||||
test("acknowledged interrupt force-settles when completion never arrives", async () => {
|
||||
let connection;
|
||||
const runtime = new CodexAppServerRuntime({
|
||||
interruptGraceMs: 5,
|
||||
connectionFactory: (options) => (connection = new FakeConnection(options)),
|
||||
});
|
||||
const run = runtime.runTurn({
|
||||
requestId: "request-interrupt-no-completion",
|
||||
chatSessionId: "chat-interrupt-no-completion",
|
||||
prompt: "wait",
|
||||
permissionMode: "confirm",
|
||||
env: {},
|
||||
binPath: "/bin/codex",
|
||||
injectedMcpServers: [],
|
||||
emitter: createEmitter(),
|
||||
});
|
||||
await waitFor(() => connection?.requests.some((request) => request.method === "turn/start"));
|
||||
|
||||
assert.equal(await runtime.cancelTurn("request-interrupt-no-completion"), true);
|
||||
await Promise.race([
|
||||
run,
|
||||
new Promise((_, reject) => setTimeout(() => reject(new Error("turn did not settle")), 50)),
|
||||
]);
|
||||
|
||||
assert.equal(connection.closed, true);
|
||||
assert.equal(runtime.activeByRequest.size, 0);
|
||||
});
|
||||
|
||||
test("idle superseded app-server connections close when their active turn finishes", async () => {
|
||||
const connections = [];
|
||||
const runtime = new CodexAppServerRuntime({
|
||||
connectionFactory: (options) => {
|
||||
const connection = new FakeConnection(options);
|
||||
connection.threadId = `thread-${connections.length + 1}`;
|
||||
connection.turnId = `turn-${connections.length + 1}`;
|
||||
connections.push(connection);
|
||||
return connection;
|
||||
},
|
||||
});
|
||||
const firstRun = runtime.runTurn({
|
||||
requestId: "request-config-a",
|
||||
chatSessionId: "chat-config-a",
|
||||
prompt: "first",
|
||||
permissionMode: "confirm",
|
||||
env: { PROFILE: "a" },
|
||||
binPath: "/bin/codex",
|
||||
injectedMcpServers: [],
|
||||
emitter: createEmitter(),
|
||||
});
|
||||
await waitFor(() => connections[0]?.requests.some((request) => request.method === "turn/start"));
|
||||
const secondRun = runtime.runTurn({
|
||||
requestId: "request-config-b",
|
||||
chatSessionId: "chat-config-b",
|
||||
prompt: "second",
|
||||
permissionMode: "confirm",
|
||||
env: { PROFILE: "b" },
|
||||
binPath: "/bin/codex",
|
||||
injectedMcpServers: [],
|
||||
emitter: createEmitter(),
|
||||
});
|
||||
await waitFor(() => connections[1]?.requests.some((request) => request.method === "turn/start"));
|
||||
|
||||
connections[0].notify({
|
||||
method: "turn/completed",
|
||||
params: { threadId: "thread-1", turn: { id: "turn-1", status: "completed", error: null } },
|
||||
});
|
||||
await firstRun;
|
||||
assert.equal(connections[0].closed, true);
|
||||
assert.equal(connections[1].closed, false);
|
||||
|
||||
connections[1].notify({
|
||||
method: "turn/completed",
|
||||
params: { threadId: "thread-2", turn: { id: "turn-2", status: "completed", error: null } },
|
||||
});
|
||||
await secondRun;
|
||||
assert.equal(connections[1].closed, false);
|
||||
runtime.close();
|
||||
});
|
||||
|
||||
test("force-cancelling the preferred connection keeps another active connection reusable", async () => {
|
||||
const connections = [];
|
||||
const runtime = new CodexAppServerRuntime({
|
||||
interruptGraceMs: 5,
|
||||
connectionFactory: (options) => {
|
||||
const connection = new FakeConnection(options);
|
||||
connection.threadId = `thread-reuse-${connections.length + 1}`;
|
||||
connection.turnId = `turn-reuse-${connections.length + 1}`;
|
||||
connections.push(connection);
|
||||
return connection;
|
||||
},
|
||||
});
|
||||
const firstRun = runtime.runTurn({
|
||||
requestId: "request-reuse-a",
|
||||
chatSessionId: "chat-reuse-a",
|
||||
prompt: "first",
|
||||
permissionMode: "confirm",
|
||||
env: { PROFILE: "reuse-a" },
|
||||
binPath: "/bin/codex",
|
||||
injectedMcpServers: [],
|
||||
emitter: createEmitter(),
|
||||
});
|
||||
await waitFor(() => connections[0]?.requests.some((request) => request.method === "turn/start"));
|
||||
const secondRun = runtime.runTurn({
|
||||
requestId: "request-reuse-b",
|
||||
chatSessionId: "chat-reuse-b",
|
||||
prompt: "second",
|
||||
permissionMode: "confirm",
|
||||
env: { PROFILE: "reuse-b" },
|
||||
binPath: "/bin/codex",
|
||||
injectedMcpServers: [],
|
||||
emitter: createEmitter(),
|
||||
});
|
||||
await waitFor(() => connections[1]?.requests.some((request) => request.method === "turn/start"));
|
||||
|
||||
assert.equal(await runtime.cancelTurn("request-reuse-b"), true);
|
||||
await secondRun;
|
||||
connections[0].notify({
|
||||
method: "turn/completed",
|
||||
params: {
|
||||
threadId: "thread-reuse-1",
|
||||
turn: { id: "turn-reuse-1", status: "completed", error: null },
|
||||
},
|
||||
});
|
||||
await firstRun;
|
||||
|
||||
assert.equal(connections[0].closed, false);
|
||||
runtime.close();
|
||||
});
|
||||
|
||||
test("unsupported requests fail immediately and warn without hanging", async () => {
|
||||
let connection;
|
||||
const emitter = createEmitter();
|
||||
const runtime = new CodexAppServerRuntime({
|
||||
connectionFactory: (options) => (connection = new FakeConnection(options)),
|
||||
});
|
||||
const run = runtime.runTurn({
|
||||
requestId: "request-unsupported",
|
||||
chatSessionId: "chat-unsupported",
|
||||
prompt: "hello",
|
||||
permissionMode: "confirm",
|
||||
env: {},
|
||||
binPath: "/bin/codex",
|
||||
injectedMcpServers: [],
|
||||
emitter,
|
||||
});
|
||||
await waitFor(() => connection?.requests.some((request) => request.method === "turn/start"));
|
||||
await connection.serverRequest({
|
||||
id: 99,
|
||||
method: "item/unknown/requestApproval",
|
||||
params: { threadId: "thread-1", turnId: "turn-1" },
|
||||
});
|
||||
assert.deepEqual(connection.responses.at(-1), {
|
||||
id: 99,
|
||||
error: { code: -32601, message: "Unsupported Codex App Server request: item/unknown/requestApproval" },
|
||||
});
|
||||
assert.ok(emitter.events.some((event) => event[0] === "warning"));
|
||||
await connection.serverRequest({
|
||||
id: 100,
|
||||
method: "item/commandExecution/requestApproval",
|
||||
params: { threadId: "thread-1", turnId: "turn-1", itemId: "cmd-no-renderer", command: "rm -rf /" },
|
||||
});
|
||||
assert.deepEqual(connection.responses.at(-1), { id: 100, result: { decision: "decline" } });
|
||||
connection.notify({ method: "turn/completed", params: { threadId: "thread-1", turn: { id: "turn-1", status: "completed", error: null } } });
|
||||
await run;
|
||||
});
|
||||
|
||||
test("model and file-change normalization preserve UI contract", async () => {
|
||||
assert.deepEqual(normalizeFileChanges([
|
||||
{ path: "a", kind: { type: "add" } },
|
||||
{ path: "b", kind: { type: "delete" } },
|
||||
{ path: "c", kind: { type: "update", move_path: null } },
|
||||
]), [
|
||||
{ path: "a", kind: "add" },
|
||||
{ path: "b", kind: "delete" },
|
||||
{ path: "c", kind: "update" },
|
||||
]);
|
||||
assert.equal(mapAppServerModels([{ id: "hidden", hidden: true }]).length, 0);
|
||||
|
||||
let connection;
|
||||
const runtime = new CodexAppServerRuntime({
|
||||
connectionFactory: (options) => (connection = new FakeConnection(options)),
|
||||
});
|
||||
const catalog = await runtime.listModels({ binPath: "/bin/codex", env: {} });
|
||||
assert.equal(connection.requests[0].method, "model/list");
|
||||
assert.equal(catalog.currentModelId, "gpt-test/high");
|
||||
assert.equal(catalog.models[0].id, "gpt-first");
|
||||
assert.deepEqual(catalog.models[1].thinkingLevels, ["low", "high"]);
|
||||
assert.equal(catalog.models[1].defaultThinkingLevel, "high");
|
||||
});
|
||||
Reference in New Issue
Block a user