mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-18 03:52:42 +08:00
Compare commits
35 Commits
v2026.6.8
...
codex/refa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97059b9697 | ||
|
|
f395fca214 | ||
|
|
68a5d4b5f5 | ||
|
|
2abcddaa2f | ||
|
|
faffa4b8f7 | ||
|
|
45ccb20d98 | ||
|
|
dbd74318f7 | ||
|
|
4f21111df9 | ||
|
|
d9efe22cd3 | ||
|
|
107462abae | ||
|
|
04c30720a0 | ||
|
|
58601a7f0e | ||
|
|
9c3d186d7c | ||
|
|
990edcfbf5 | ||
|
|
1a4e815e37 | ||
|
|
aefd49909d | ||
|
|
c67dc59b02 | ||
|
|
e1744184b8 | ||
|
|
2e240e772b | ||
|
|
5836982506 | ||
|
|
40093f5a93 | ||
|
|
d0851435e8 | ||
|
|
3826cda4d8 | ||
|
|
771881d189 | ||
|
|
6db496b04b | ||
|
|
4892bbc10f | ||
|
|
7f6df80537 | ||
|
|
1a2e418500 | ||
|
|
a823cc3b1c | ||
|
|
fd855c831f | ||
|
|
ccf5976d06 | ||
|
|
b5999bc6a0 | ||
|
|
fc6d448138 | ||
|
|
2e745ba225 | ||
|
|
ef47dd610c |
11
.github/workflows/ci.yml
vendored
11
.github/workflows/ci.yml
vendored
@@ -1358,6 +1358,8 @@ jobs:
|
||||
- check_name: check-additional-boundaries-bcd
|
||||
group: boundaries
|
||||
boundary_shard: 2/4,3/4,4/4
|
||||
- check_name: check-session-accessor-boundary
|
||||
group: session-accessor-boundary
|
||||
- check_name: check-additional-extension-channels
|
||||
group: extension-channels
|
||||
- check_name: check-additional-extension-bundled
|
||||
@@ -1504,6 +1506,15 @@ jobs:
|
||||
boundaries)
|
||||
node scripts/run-additional-boundary-checks.mjs
|
||||
;;
|
||||
session-accessor-boundary)
|
||||
if [ ! -f scripts/check-session-accessor-boundary.mjs ]; then
|
||||
echo "[skip] session accessor boundary check is not present in this checkout"
|
||||
elif ! node -e 'const pkg = require("./package.json"); process.exit(pkg.scripts?.["lint:tmp:session-accessor-boundary"] ? 0 : 1);'; then
|
||||
echo "[skip] session accessor boundary script is not present in package.json"
|
||||
else
|
||||
run_check "lint:tmp:session-accessor-boundary" pnpm run lint:tmp:session-accessor-boundary
|
||||
fi
|
||||
;;
|
||||
extension-channels)
|
||||
run_check "lint:extensions:channels" pnpm run lint:extensions:channels
|
||||
;;
|
||||
|
||||
@@ -2074,6 +2074,204 @@ public struct SessionsCompactionRestoreResult: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionFileBrowserEntry: Codable, Sendable {
|
||||
public let path: String
|
||||
public let name: String
|
||||
public let kind: AnyCodable
|
||||
public let sessionkind: SessionFileRelevance?
|
||||
public let size: Int?
|
||||
public let updatedatms: Int?
|
||||
|
||||
public init(
|
||||
path: String,
|
||||
name: String,
|
||||
kind: AnyCodable,
|
||||
sessionkind: SessionFileRelevance?,
|
||||
size: Int?,
|
||||
updatedatms: Int?)
|
||||
{
|
||||
self.path = path
|
||||
self.name = name
|
||||
self.kind = kind
|
||||
self.sessionkind = sessionkind
|
||||
self.size = size
|
||||
self.updatedatms = updatedatms
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case path
|
||||
case name
|
||||
case kind
|
||||
case sessionkind = "sessionKind"
|
||||
case size
|
||||
case updatedatms = "updatedAtMs"
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionFileBrowserResult: Codable, Sendable {
|
||||
public let path: String
|
||||
public let parentpath: String?
|
||||
public let search: String?
|
||||
public let entries: [SessionFileBrowserEntry]
|
||||
public let truncated: Bool?
|
||||
|
||||
public init(
|
||||
path: String,
|
||||
parentpath: String?,
|
||||
search: String?,
|
||||
entries: [SessionFileBrowserEntry],
|
||||
truncated: Bool?)
|
||||
{
|
||||
self.path = path
|
||||
self.parentpath = parentpath
|
||||
self.search = search
|
||||
self.entries = entries
|
||||
self.truncated = truncated
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case path
|
||||
case parentpath = "parentPath"
|
||||
case search
|
||||
case entries
|
||||
case truncated
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionFileEntry: Codable, Sendable {
|
||||
public let path: String
|
||||
public let name: String
|
||||
public let kind: SessionFileKind
|
||||
public let missing: Bool
|
||||
public let size: Int?
|
||||
public let updatedatms: Int?
|
||||
public let content: String?
|
||||
|
||||
public init(
|
||||
path: String,
|
||||
name: String,
|
||||
kind: SessionFileKind,
|
||||
missing: Bool,
|
||||
size: Int?,
|
||||
updatedatms: Int?,
|
||||
content: String?)
|
||||
{
|
||||
self.path = path
|
||||
self.name = name
|
||||
self.kind = kind
|
||||
self.missing = missing
|
||||
self.size = size
|
||||
self.updatedatms = updatedatms
|
||||
self.content = content
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case path
|
||||
case name
|
||||
case kind
|
||||
case missing
|
||||
case size
|
||||
case updatedatms = "updatedAtMs"
|
||||
case content
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionsFilesListParams: Codable, Sendable {
|
||||
public let sessionkey: String
|
||||
public let agentid: String?
|
||||
public let path: String?
|
||||
public let search: String?
|
||||
|
||||
public init(
|
||||
sessionkey: String,
|
||||
agentid: String? = nil,
|
||||
path: String?,
|
||||
search: String?)
|
||||
{
|
||||
self.sessionkey = sessionkey
|
||||
self.agentid = agentid
|
||||
self.path = path
|
||||
self.search = search
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case sessionkey = "sessionKey"
|
||||
case agentid = "agentId"
|
||||
case path
|
||||
case search
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionsFilesListResult: Codable, Sendable {
|
||||
public let sessionkey: String
|
||||
public let root: String?
|
||||
public let files: [SessionFileEntry]
|
||||
public let browser: SessionFileBrowserResult?
|
||||
|
||||
public init(
|
||||
sessionkey: String,
|
||||
root: String?,
|
||||
files: [SessionFileEntry],
|
||||
browser: SessionFileBrowserResult?)
|
||||
{
|
||||
self.sessionkey = sessionkey
|
||||
self.root = root
|
||||
self.files = files
|
||||
self.browser = browser
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case sessionkey = "sessionKey"
|
||||
case root
|
||||
case files
|
||||
case browser
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionsFilesGetParams: Codable, Sendable {
|
||||
public let sessionkey: String
|
||||
public let path: String
|
||||
public let agentid: String?
|
||||
|
||||
public init(
|
||||
sessionkey: String,
|
||||
path: String,
|
||||
agentid: String? = nil)
|
||||
{
|
||||
self.sessionkey = sessionkey
|
||||
self.path = path
|
||||
self.agentid = agentid
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case sessionkey = "sessionKey"
|
||||
case path
|
||||
case agentid = "agentId"
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionsFilesGetResult: Codable, Sendable {
|
||||
public let sessionkey: String
|
||||
public let root: String?
|
||||
public let file: SessionFileEntry
|
||||
|
||||
public init(
|
||||
sessionkey: String,
|
||||
root: String?,
|
||||
file: SessionFileEntry)
|
||||
{
|
||||
self.sessionkey = sessionkey
|
||||
self.root = root
|
||||
self.file = file
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case sessionkey = "sessionKey"
|
||||
case root
|
||||
case file
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionsCreateParams: Codable, Sendable {
|
||||
public let key: String?
|
||||
public let agentid: String?
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
40b3c841849fbc29938a3bbb990e28a5db30142941c8ef0c081a94cee4c78331 plugin-sdk-api-baseline.json
|
||||
40ee8e1bbf112e768d4944776443f90b2441b02e3e950726e4112015cd106108 plugin-sdk-api-baseline.jsonl
|
||||
b121079a0912b3051a9fc319a675ef920da9db23364ca0c0ccd3c9f0a05a3a49 plugin-sdk-api-baseline.json
|
||||
61a0108da670e0f44ba4b861c002eb6eaa5cf63e392d4e7e7de42044cbe7d115 plugin-sdk-api-baseline.jsonl
|
||||
|
||||
@@ -311,7 +311,9 @@ $OPENCLAW_STATE_DIR/tasks/runs.sqlite
|
||||
|
||||
The registry loads into memory at gateway start and syncs writes to SQLite for durability across restarts.
|
||||
The Gateway keeps the SQLite write-ahead log bounded by using SQLite's default
|
||||
autocheckpoint threshold plus periodic and shutdown `TRUNCATE` checkpoints.
|
||||
autocheckpoint threshold plus periodic `PASSIVE` checkpoints. Shutdown and
|
||||
explicit maintenance checkpoints still use `TRUNCATE` so normal closes can
|
||||
reclaim WAL space without making the background sweeper wait on active readers.
|
||||
|
||||
### Automatic maintenance
|
||||
|
||||
|
||||
@@ -143,12 +143,39 @@ The native Codex app-server harness supports context engines that require
|
||||
pre-prompt assembly. Generic CLI backends, including `codex-cli`, do not provide
|
||||
that host capability.
|
||||
|
||||
Codex thread bindings live in OpenClaw's SQLite plugin state and use the stable
|
||||
agent-scoped OpenClaw session key, or an opaque conversation-binding id, as
|
||||
their owner. Physical session ids fence delayed cleanup but may rotate without
|
||||
losing the Codex thread. Context-engine compaction adopts the successor id
|
||||
before continuing native Codex compaction. The bounded store rejects a new
|
||||
binding at its safety limit instead of evicting an existing thread's continuity
|
||||
record.
|
||||
Conversation binds create or resume their Codex thread on the first bound
|
||||
message after channel approval; an abandoned approval consumes no thread row.
|
||||
That first message carries the prepared thread directly into its turn.
|
||||
Subsequent messages use a metadata-only resume to subscribe the shared client,
|
||||
then unsubscribe after the turn completes.
|
||||
The runtime does not poll transcript-adjacent binding files. Upgrades from
|
||||
releases that used `*.jsonl.codex-app-server.json` sidecars migrate them during
|
||||
normal startup preflight. `openclaw doctor --fix` can run the same migration
|
||||
manually.
|
||||
Successfully matched sidecars are archived before the new runtime resumes their
|
||||
threads. Migration imports durable thread ownership only; it does not infer
|
||||
Codex context usage from OpenClaw counters or crawl Codex rollout files. For
|
||||
agent-session harness bindings, the next resume attempts to restore a cached
|
||||
native snapshot when Codex has one, and ongoing turns persist the current-context
|
||||
usage reported by app-server notifications, not the cumulative thread lifetime
|
||||
total. Conversation bindings
|
||||
keep metadata-only resumes and leave continuity and compaction with the native
|
||||
Codex thread. Conflicting or ambiguous sidecars stay in place with a warning for
|
||||
operator review.
|
||||
|
||||
For Codex-backed agents, `/compact` starts native Codex app-server compaction on
|
||||
the bound thread. OpenClaw does not wait for completion, impose an OpenClaw
|
||||
timeout, restart the shared app-server, or fall back to a context-engine or
|
||||
public OpenAI summarizer. If the native Codex thread binding is missing or
|
||||
stale, the command fails closed so the operator sees the real runtime boundary
|
||||
instead of silently switching compaction backends.
|
||||
the bound thread. OpenClaw bounds the request-acceptance RPC but does not wait
|
||||
for compaction completion, restart the shared app-server, or fall back to a
|
||||
context-engine or public OpenAI summarizer. If the native Codex thread binding
|
||||
is missing or stale, the command fails closed so the operator sees the real
|
||||
runtime boundary instead of silently switching compaction backends.
|
||||
|
||||
```json5
|
||||
{
|
||||
|
||||
@@ -20,6 +20,7 @@ Scope includes:
|
||||
- Thinking signature cleanup
|
||||
- Image payload sanitization
|
||||
- Blank text-block cleanup before provider replay
|
||||
- Incomplete reasoning-only length-turn cleanup before provider replay
|
||||
- User-input provenance tagging (for inter-session routed prompts)
|
||||
- Empty assistant error-turn repair for Bedrock Converse replay
|
||||
|
||||
@@ -91,6 +92,21 @@ Implementation:
|
||||
|
||||
---
|
||||
|
||||
## Global rule: incomplete reasoning-only turns
|
||||
|
||||
Assistant turns that hit the provider output limit with only thinking or
|
||||
redacted-thinking content are omitted from the in-memory replay copy. Such turns
|
||||
contain incomplete provider state and may carry a partial thinking signature.
|
||||
|
||||
Empty length turns remain unchanged, as do length turns with visible text, tool
|
||||
calls, or unknown content blocks. Stored transcripts are not rewritten.
|
||||
|
||||
Implementation:
|
||||
|
||||
- `normalizeAssistantReplayContent` in `src/agents/embedded-agent-runner/replay-history.ts`
|
||||
|
||||
---
|
||||
|
||||
## Global rule: inter-session input provenance
|
||||
|
||||
When an agent sends a prompt into another session via `sessions_send` (including
|
||||
|
||||
@@ -13,7 +13,12 @@ CLI, and scripting patterns (snapshots, refs, waits, debug flows).
|
||||
|
||||
## Control API (optional)
|
||||
|
||||
For local integrations only, the Gateway exposes a small loopback HTTP API:
|
||||
For local integrations only, the Gateway exposes a small loopback HTTP API.
|
||||
This standalone server is opt-in — set the environment variable
|
||||
`OPENCLAW_EAGER_BROWSER_CONTROL_SERVER=1` in the gateway service environment
|
||||
and restart the gateway before the HTTP endpoints become available. Without
|
||||
this variable the browser control runtime still works through the CLI and
|
||||
agent tools, but nothing listens on the loopback control port.
|
||||
|
||||
- Status/start/stop: `GET /`, `POST /start`, `POST /stop`
|
||||
- Tabs: `GET /tabs`, `POST /tabs/open`, `POST /tabs/focus`, `DELETE /tabs/:targetId`
|
||||
|
||||
@@ -1,6 +1,44 @@
|
||||
// Codex tests cover doctor contract api plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { legacyConfigRules, normalizeCompatibilityConfig } from "./doctor-contract-api.js";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { PluginStateKeyedStore } from "openclaw/plugin-sdk/plugin-state-runtime";
|
||||
import {
|
||||
createPluginStateKeyedStoreForTests,
|
||||
resetPluginStateStoreForTests,
|
||||
} from "openclaw/plugin-sdk/plugin-state-test-runtime";
|
||||
import type {
|
||||
OpenKeyedStoreOptions,
|
||||
PluginDoctorStateMigrationContext,
|
||||
} from "openclaw/plugin-sdk/runtime-doctor";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
legacyConfigRules,
|
||||
normalizeCompatibilityConfig,
|
||||
stateMigrations,
|
||||
} from "./doctor-contract-api.js";
|
||||
import {
|
||||
bindingStoreKey,
|
||||
CODEX_APP_SERVER_BINDING_MAX_ENTRIES,
|
||||
CODEX_APP_SERVER_BINDING_NAMESPACE,
|
||||
type StoredCodexAppServerBinding,
|
||||
} from "./src/app-server/session-binding.js";
|
||||
import { legacyCodexConversationBindingId } from "./src/conversation-binding-data.js";
|
||||
|
||||
function createDoctorContext(env: NodeJS.ProcessEnv): PluginDoctorStateMigrationContext {
|
||||
return {
|
||||
openPluginStateKeyedStore<T>(options: OpenKeyedStoreOptions) {
|
||||
return createPluginStateKeyedStoreForTests<T>("codex", {
|
||||
...options,
|
||||
env: options.env ?? env,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
resetPluginStateStoreForTests();
|
||||
});
|
||||
|
||||
describe("codex doctor contract", () => {
|
||||
it("reports the retired dynamic tools profile config key", () => {
|
||||
@@ -42,4 +80,856 @@ describe("codex doctor contract", () => {
|
||||
});
|
||||
expect(original.plugins.entries.codex.config).toHaveProperty("codexDynamicToolsProfile");
|
||||
});
|
||||
|
||||
it("imports shipped binding sidecars under session and legacy conversation identities", async () => {
|
||||
const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-doctor-"));
|
||||
const env = { ...process.env, OPENCLAW_STATE_DIR: stateDir };
|
||||
const sessionsDir = path.join(stateDir, "agents", "main", "sessions");
|
||||
const transcriptPath = path.join(sessionsDir, "session-current.jsonl");
|
||||
const sidecarPath = `${transcriptPath}.codex-app-server.json`;
|
||||
const legacyBinding = {
|
||||
schemaVersion: 1,
|
||||
threadId: "thread-1",
|
||||
sessionFile: transcriptPath,
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
};
|
||||
await fs.mkdir(sessionsDir, { recursive: true });
|
||||
await fs.writeFile(transcriptPath, '{"type":"session","id":"session-current"}\n', "utf8");
|
||||
await fs.writeFile(
|
||||
path.join(sessionsDir, "sessions.json"),
|
||||
JSON.stringify({
|
||||
"agent:main:session-1": {
|
||||
sessionId: "session-current",
|
||||
sessionFile: "session-current.jsonl",
|
||||
totalTokens: 42_000,
|
||||
totalTokensFresh: true,
|
||||
contextTokens: 258_400,
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
await fs.writeFile(sidecarPath, JSON.stringify(legacyBinding), "utf8");
|
||||
const params = {
|
||||
config: {},
|
||||
env,
|
||||
stateDir,
|
||||
oauthDir: path.join(stateDir, "oauth"),
|
||||
context: createDoctorContext(env),
|
||||
};
|
||||
const migration = stateMigrations[0];
|
||||
if (!migration) {
|
||||
throw new Error("missing Codex binding migration");
|
||||
}
|
||||
|
||||
await expect(migration.detectLegacyState(params)).resolves.toMatchObject({
|
||||
preview: [expect.stringContaining("legacy sidecar")],
|
||||
});
|
||||
await expect(migration.migrateLegacyState(params)).resolves.toMatchObject({
|
||||
changes: [expect.stringContaining("Migrated 1")],
|
||||
warnings: [],
|
||||
});
|
||||
|
||||
const store = createDoctorContext(env).openPluginStateKeyedStore<StoredCodexAppServerBinding>({
|
||||
namespace: CODEX_APP_SERVER_BINDING_NAMESPACE,
|
||||
maxEntries: CODEX_APP_SERVER_BINDING_MAX_ENTRIES,
|
||||
overflowPolicy: "reject-new",
|
||||
});
|
||||
await expect(
|
||||
store.lookup(
|
||||
bindingStoreKey({
|
||||
kind: "session",
|
||||
agentId: "main",
|
||||
sessionId: "session-current",
|
||||
sessionKey: "agent:main:session-1",
|
||||
}),
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
state: "active",
|
||||
sessionId: "session-current",
|
||||
binding: { threadId: "thread-1" },
|
||||
});
|
||||
await expect(
|
||||
store.lookup(
|
||||
bindingStoreKey({
|
||||
kind: "conversation",
|
||||
bindingId: legacyCodexConversationBindingId(transcriptPath),
|
||||
}),
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
state: "active",
|
||||
binding: {
|
||||
threadId: "thread-1",
|
||||
cwd: "",
|
||||
historyCoveredThrough: expect.any(String),
|
||||
},
|
||||
});
|
||||
await expect(
|
||||
store.lookup(
|
||||
bindingStoreKey({
|
||||
kind: "conversation",
|
||||
bindingId: legacyCodexConversationBindingId(transcriptPath),
|
||||
}),
|
||||
),
|
||||
).resolves.not.toHaveProperty("binding.nativeContextUsage");
|
||||
await expect(fs.access(`${sidecarPath}.migrated`)).resolves.toBeUndefined();
|
||||
await expect(
|
||||
fs.readFile(path.join(sessionsDir, "sessions.json"), "utf8").then(JSON.parse),
|
||||
).resolves.toMatchObject({
|
||||
"agent:main:session-1": { sessionId: "session-current", agentHarnessId: "codex" },
|
||||
});
|
||||
|
||||
await fs.rm(`${sidecarPath}.migrated`);
|
||||
await fs.writeFile(sidecarPath, JSON.stringify(legacyBinding), "utf8");
|
||||
await expect(migration.migrateLegacyState(params)).resolves.toMatchObject({
|
||||
changes: [expect.stringContaining("Migrated 1")],
|
||||
warnings: [],
|
||||
});
|
||||
await expect(fs.access(`${sidecarPath}.migrated`)).resolves.toBeUndefined();
|
||||
|
||||
const resetTranscript = path.join(sessionsDir, "session-before-reset.jsonl");
|
||||
const resetSidecar = `${resetTranscript}.codex-app-server.json`;
|
||||
await fs.writeFile(resetTranscript, '{"type":"session","id":"session-before-reset"}\n', "utf8");
|
||||
await fs.writeFile(
|
||||
resetSidecar,
|
||||
JSON.stringify({ schemaVersion: 1, threadId: "thread-before-reset" }),
|
||||
"utf8",
|
||||
);
|
||||
await expect(migration.migrateLegacyState(params)).resolves.toMatchObject({
|
||||
changes: [expect.stringContaining("Migrated 1 safe")],
|
||||
warnings: [expect.stringContaining("session owner could not be resolved")],
|
||||
});
|
||||
await expect(fs.access(resetSidecar)).resolves.toBeUndefined();
|
||||
await fs.rm(resetSidecar);
|
||||
|
||||
const conflictingTranscript = path.join(sessionsDir, "session-2.jsonl");
|
||||
const conflictingSidecar = `${conflictingTranscript}.codex-app-server.json`;
|
||||
await fs.writeFile(conflictingTranscript, '{"type":"session","id":"session-2"}\n', "utf8");
|
||||
await fs.writeFile(
|
||||
conflictingSidecar,
|
||||
JSON.stringify({ schemaVersion: 1, threadId: "legacy-thread" }),
|
||||
"utf8",
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(sessionsDir, "sessions.json"),
|
||||
JSON.stringify({
|
||||
"agent:main:session-1": {
|
||||
sessionId: "session-1",
|
||||
sessionFile: "session-1.jsonl",
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
"agent:main:session-2": {
|
||||
sessionId: "session-2",
|
||||
sessionFile: "session-2.jsonl",
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
const conflictingSessionKey = bindingStoreKey({
|
||||
kind: "session",
|
||||
agentId: "main",
|
||||
sessionId: "session-2",
|
||||
sessionKey: "agent:main:session-2",
|
||||
});
|
||||
await store.register(conflictingSessionKey, {
|
||||
version: 1,
|
||||
state: "active",
|
||||
binding: {
|
||||
threadId: "legacy-thread",
|
||||
cwd: "/repo",
|
||||
historyCoveredThrough: "2026-01-01T00:00:00.000Z",
|
||||
},
|
||||
});
|
||||
|
||||
await expect(migration.migrateLegacyState(params)).resolves.toMatchObject({
|
||||
changes: [],
|
||||
warnings: [
|
||||
expect.stringContaining(`canonical plugin state changed at ${conflictingSessionKey}`),
|
||||
],
|
||||
});
|
||||
await expect(
|
||||
store.lookup(
|
||||
bindingStoreKey({
|
||||
kind: "conversation",
|
||||
bindingId: legacyCodexConversationBindingId(conflictingTranscript),
|
||||
}),
|
||||
),
|
||||
).resolves.toBeUndefined();
|
||||
await expect(fs.access(conflictingSidecar)).resolves.toBeUndefined();
|
||||
await fs.rm(conflictingSidecar);
|
||||
|
||||
const inverseTranscript = path.join(sessionsDir, "session-3.jsonl");
|
||||
const inverseSidecar = `${inverseTranscript}.codex-app-server.json`;
|
||||
const inverseConversationKey = bindingStoreKey({
|
||||
kind: "conversation",
|
||||
bindingId: legacyCodexConversationBindingId(inverseTranscript),
|
||||
});
|
||||
await fs.writeFile(inverseTranscript, '{"type":"session","id":"session-3"}\n', "utf8");
|
||||
await fs.writeFile(
|
||||
path.join(sessionsDir, "sessions.json"),
|
||||
JSON.stringify({
|
||||
"agent:main:session-3": {
|
||||
sessionId: "session-3",
|
||||
sessionFile: "session-3.jsonl",
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
await fs.writeFile(
|
||||
inverseSidecar,
|
||||
JSON.stringify({ schemaVersion: 1, threadId: "session-thread" }),
|
||||
"utf8",
|
||||
);
|
||||
await store.register(inverseConversationKey, {
|
||||
version: 1,
|
||||
state: "active",
|
||||
binding: { threadId: "conversation-thread", cwd: "/repo" },
|
||||
});
|
||||
|
||||
await expect(migration.migrateLegacyState(params)).resolves.toMatchObject({
|
||||
changes: [expect.stringContaining("Migrated 1")],
|
||||
warnings: [],
|
||||
});
|
||||
await expect(
|
||||
store.lookup(
|
||||
bindingStoreKey({
|
||||
kind: "session",
|
||||
agentId: "main",
|
||||
sessionId: "session-3",
|
||||
sessionKey: "agent:main:session-3",
|
||||
}),
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
state: "active",
|
||||
sessionId: "session-3",
|
||||
binding: { threadId: "conversation-thread" },
|
||||
});
|
||||
await expect(store.lookup(inverseConversationKey)).resolves.toMatchObject({
|
||||
state: "active",
|
||||
binding: { threadId: "conversation-thread" },
|
||||
});
|
||||
await expect(fs.access(`${inverseSidecar}.migrated`)).resolves.toBeUndefined();
|
||||
await fs.rm(stateDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("does not publish Codex session ownership before every binding row persists", async () => {
|
||||
const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-doctor-order-"));
|
||||
const env = { ...process.env, OPENCLAW_STATE_DIR: stateDir };
|
||||
const sessionsDir = path.join(stateDir, "agents", "main", "sessions");
|
||||
const transcriptPath = path.join(sessionsDir, "session-order.jsonl");
|
||||
const sidecarPath = `${transcriptPath}.codex-app-server.json`;
|
||||
const storePath = path.join(sessionsDir, "sessions.json");
|
||||
await fs.mkdir(sessionsDir, { recursive: true });
|
||||
await fs.writeFile(transcriptPath, '{"type":"session","id":"session-order"}\n', "utf8");
|
||||
await fs.writeFile(
|
||||
storePath,
|
||||
JSON.stringify({
|
||||
"agent:main:order": {
|
||||
sessionId: "session-order",
|
||||
sessionFile: "session-order.jsonl",
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
await fs.writeFile(
|
||||
sidecarPath,
|
||||
JSON.stringify({ schemaVersion: 1, threadId: "thread-order" }),
|
||||
"utf8",
|
||||
);
|
||||
const store = createPluginStateKeyedStoreForTests<StoredCodexAppServerBinding>("codex", {
|
||||
namespace: CODEX_APP_SERVER_BINDING_NAMESPACE,
|
||||
maxEntries: CODEX_APP_SERVER_BINDING_MAX_ENTRIES,
|
||||
overflowPolicy: "reject-new",
|
||||
env,
|
||||
});
|
||||
const registerIfAbsent = store.registerIfAbsent.bind(store);
|
||||
let registerCalls = 0;
|
||||
const failingStore: PluginStateKeyedStore<StoredCodexAppServerBinding> = {
|
||||
...store,
|
||||
async registerIfAbsent(key, value, opts) {
|
||||
registerCalls++;
|
||||
if (registerCalls === 2) {
|
||||
throw new Error("injected session binding write failure");
|
||||
}
|
||||
return await registerIfAbsent(key, value, opts);
|
||||
},
|
||||
};
|
||||
const failingContext: PluginDoctorStateMigrationContext = {
|
||||
openPluginStateKeyedStore<T>() {
|
||||
return failingStore as unknown as PluginStateKeyedStore<T>;
|
||||
},
|
||||
};
|
||||
const migration = stateMigrations[0];
|
||||
if (!migration) {
|
||||
throw new Error("missing Codex binding migration");
|
||||
}
|
||||
|
||||
await expect(
|
||||
migration.migrateLegacyState({
|
||||
config: {},
|
||||
env,
|
||||
stateDir,
|
||||
oauthDir: path.join(stateDir, "oauth"),
|
||||
context: failingContext,
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
changes: [expect.stringContaining("Migrated 1 safe")],
|
||||
warnings: [expect.stringContaining("injected session binding write failure")],
|
||||
});
|
||||
await expect(fs.readFile(storePath, "utf8").then(JSON.parse)).resolves.toMatchObject({
|
||||
"agent:main:order": { sessionId: "session-order" },
|
||||
});
|
||||
expect(
|
||||
(JSON.parse(await fs.readFile(storePath, "utf8")) as Record<string, Record<string, unknown>>)[
|
||||
"agent:main:order"
|
||||
],
|
||||
).not.toHaveProperty("agentHarnessId");
|
||||
await expect(
|
||||
store.lookup(
|
||||
bindingStoreKey({
|
||||
kind: "session",
|
||||
agentId: "main",
|
||||
sessionId: "session-order",
|
||||
sessionKey: "agent:main:order",
|
||||
}),
|
||||
),
|
||||
).resolves.toBeUndefined();
|
||||
await expect(fs.access(sidecarPath)).resolves.toBeUndefined();
|
||||
|
||||
await expect(
|
||||
migration.migrateLegacyState({
|
||||
config: {},
|
||||
env,
|
||||
stateDir,
|
||||
oauthDir: path.join(stateDir, "oauth"),
|
||||
context: createDoctorContext(env),
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
changes: [expect.stringContaining("Migrated 1")],
|
||||
warnings: [],
|
||||
});
|
||||
await expect(fs.readFile(storePath, "utf8").then(JSON.parse)).resolves.toMatchObject({
|
||||
"agent:main:order": {
|
||||
sessionId: "session-order",
|
||||
agentHarnessId: "codex",
|
||||
},
|
||||
});
|
||||
await expect(fs.access(`${sidecarPath}.migrated`)).resolves.toBeUndefined();
|
||||
await fs.rm(stateDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("retains a shipped binding when its session now belongs to another harness", async () => {
|
||||
const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-doctor-owner-"));
|
||||
const env = { ...process.env, OPENCLAW_STATE_DIR: stateDir };
|
||||
const sessionsDir = path.join(stateDir, "agents", "main", "sessions");
|
||||
const transcriptPath = path.join(sessionsDir, "session-foreign.jsonl");
|
||||
const sidecarPath = `${transcriptPath}.codex-app-server.json`;
|
||||
await fs.mkdir(sessionsDir, { recursive: true });
|
||||
await fs.writeFile(transcriptPath, '{"type":"session","id":"session-foreign"}\n', "utf8");
|
||||
await fs.writeFile(
|
||||
path.join(sessionsDir, "sessions.json"),
|
||||
JSON.stringify({
|
||||
"agent:main:foreign": {
|
||||
sessionId: "session-foreign",
|
||||
sessionFile: "session-foreign.jsonl",
|
||||
agentHarnessId: "openclaw",
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
await fs.writeFile(
|
||||
sidecarPath,
|
||||
JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
threadId: "thread-foreign",
|
||||
sessionFile: transcriptPath,
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
const migration = stateMigrations[0];
|
||||
if (!migration) {
|
||||
throw new Error("missing Codex binding migration");
|
||||
}
|
||||
|
||||
await expect(
|
||||
migration.migrateLegacyState({
|
||||
config: {},
|
||||
env,
|
||||
stateDir,
|
||||
oauthDir: path.join(stateDir, "oauth"),
|
||||
context: createDoctorContext(env),
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
changes: [],
|
||||
warnings: [expect.stringContaining("owned by agent harness openclaw")],
|
||||
});
|
||||
await expect(fs.access(sidecarPath)).resolves.toBeUndefined();
|
||||
const store = createDoctorContext(env).openPluginStateKeyedStore<StoredCodexAppServerBinding>({
|
||||
namespace: CODEX_APP_SERVER_BINDING_NAMESPACE,
|
||||
maxEntries: CODEX_APP_SERVER_BINDING_MAX_ENTRIES,
|
||||
overflowPolicy: "reject-new",
|
||||
});
|
||||
await expect(
|
||||
store.lookup(
|
||||
bindingStoreKey({
|
||||
kind: "session",
|
||||
agentId: "main",
|
||||
sessionId: "session-foreign",
|
||||
sessionKey: "agent:main:foreign",
|
||||
}),
|
||||
),
|
||||
).resolves.toBeUndefined();
|
||||
await fs.rm(stateDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("imports sidecars from the pre-agent session directory before core moves it", async () => {
|
||||
const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-doctor-legacy-"));
|
||||
const env = { ...process.env, OPENCLAW_STATE_DIR: stateDir };
|
||||
const sessionsDir = path.join(stateDir, "sessions");
|
||||
const transcriptPath = path.join(sessionsDir, "legacy-session.jsonl");
|
||||
const sidecarPath = `${transcriptPath}.codex-app-server.json`;
|
||||
await fs.mkdir(sessionsDir, { recursive: true });
|
||||
await fs.writeFile(transcriptPath, '{"type":"session","id":"legacy-session"}\n', "utf8");
|
||||
await fs.writeFile(
|
||||
path.join(sessionsDir, "sessions.json"),
|
||||
JSON.stringify({
|
||||
"agent:main:legacy": {
|
||||
sessionId: "legacy-session",
|
||||
sessionFile: "legacy-session.jsonl",
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
await fs.writeFile(
|
||||
sidecarPath,
|
||||
JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
threadId: "legacy-thread",
|
||||
sessionFile: transcriptPath,
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
const params = {
|
||||
config: {},
|
||||
env,
|
||||
stateDir,
|
||||
oauthDir: path.join(stateDir, "oauth"),
|
||||
context: createDoctorContext(env),
|
||||
};
|
||||
const migration = stateMigrations[0];
|
||||
if (!migration) {
|
||||
throw new Error("missing Codex binding migration");
|
||||
}
|
||||
|
||||
await expect(migration.migrateLegacyState(params)).resolves.toMatchObject({ warnings: [] });
|
||||
|
||||
const store = createDoctorContext(env).openPluginStateKeyedStore<StoredCodexAppServerBinding>({
|
||||
namespace: CODEX_APP_SERVER_BINDING_NAMESPACE,
|
||||
maxEntries: CODEX_APP_SERVER_BINDING_MAX_ENTRIES,
|
||||
overflowPolicy: "reject-new",
|
||||
});
|
||||
await expect(
|
||||
store.lookup(
|
||||
bindingStoreKey({
|
||||
kind: "session",
|
||||
agentId: "main",
|
||||
sessionId: "legacy-session",
|
||||
sessionKey: "agent:main:legacy",
|
||||
}),
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
state: "active",
|
||||
sessionId: "legacy-session",
|
||||
binding: { threadId: "legacy-thread" },
|
||||
});
|
||||
await expect(fs.access(`${sidecarPath}.migrated`)).resolves.toBeUndefined();
|
||||
await expect(
|
||||
fs.readFile(path.join(sessionsDir, "sessions.json"), "utf8").then(JSON.parse),
|
||||
).resolves.toMatchObject({
|
||||
"agent:main:legacy": { sessionId: "legacy-session", agentHarnessId: "codex" },
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the session index when a shipped sidecar transcript is missing", async () => {
|
||||
const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-doctor-"));
|
||||
const env = { ...process.env, OPENCLAW_STATE_DIR: stateDir };
|
||||
const sessionsDir = path.join(stateDir, "agents", "main", "sessions");
|
||||
const transcriptPath = path.join(sessionsDir, "missing.jsonl");
|
||||
const sidecarPath = `${transcriptPath}.codex-app-server.json`;
|
||||
await fs.mkdir(sessionsDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(sessionsDir, "sessions.json"),
|
||||
JSON.stringify({
|
||||
"agent:main:missing": {
|
||||
sessionId: "session-missing",
|
||||
sessionFile: "missing.jsonl",
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
await fs.writeFile(
|
||||
sidecarPath,
|
||||
JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
threadId: "thread-legacy-conversation",
|
||||
sessionFile: transcriptPath,
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
const migration = stateMigrations[0];
|
||||
if (!migration) {
|
||||
throw new Error("missing Codex binding migration");
|
||||
}
|
||||
|
||||
await expect(
|
||||
migration.migrateLegacyState({
|
||||
config: {},
|
||||
env,
|
||||
stateDir,
|
||||
oauthDir: path.join(stateDir, "oauth"),
|
||||
context: createDoctorContext(env),
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
changes: [expect.stringContaining("Migrated 1")],
|
||||
warnings: [],
|
||||
});
|
||||
|
||||
const store = createDoctorContext(env).openPluginStateKeyedStore<StoredCodexAppServerBinding>({
|
||||
namespace: CODEX_APP_SERVER_BINDING_NAMESPACE,
|
||||
maxEntries: CODEX_APP_SERVER_BINDING_MAX_ENTRIES,
|
||||
overflowPolicy: "reject-new",
|
||||
});
|
||||
await expect(
|
||||
store.lookup(
|
||||
bindingStoreKey({
|
||||
kind: "conversation",
|
||||
bindingId: legacyCodexConversationBindingId(transcriptPath),
|
||||
}),
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
state: "active",
|
||||
binding: { threadId: "thread-legacy-conversation" },
|
||||
});
|
||||
await expect(
|
||||
store.lookup(
|
||||
bindingStoreKey({
|
||||
kind: "session",
|
||||
agentId: "main",
|
||||
sessionId: "session-missing",
|
||||
sessionKey: "agent:main:missing",
|
||||
}),
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
state: "active",
|
||||
sessionId: "session-missing",
|
||||
binding: { threadId: "thread-legacy-conversation" },
|
||||
});
|
||||
await expect(fs.access(`${sidecarPath}.migrated`)).resolves.toBeUndefined();
|
||||
await fs.rm(stateDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("imports a binding without crawling Codex rollout files", async () => {
|
||||
const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-doctor-"));
|
||||
const env = { ...process.env, OPENCLAW_STATE_DIR: stateDir };
|
||||
const sessionsDir = path.join(stateDir, "agents", "main", "sessions");
|
||||
const transcriptPath = path.join(sessionsDir, "session-fresh.jsonl");
|
||||
const sidecarPath = `${transcriptPath}.codex-app-server.json`;
|
||||
await fs.mkdir(sessionsDir, { recursive: true });
|
||||
await fs.writeFile(transcriptPath, '{"type":"session","id":"session-fresh"}\n', "utf8");
|
||||
await fs.writeFile(
|
||||
path.join(sessionsDir, "sessions.json"),
|
||||
JSON.stringify({
|
||||
"agent:main:fresh": {
|
||||
sessionId: "session-fresh",
|
||||
sessionFile: "session-fresh.jsonl",
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
await fs.writeFile(
|
||||
sidecarPath,
|
||||
JSON.stringify({ schemaVersion: 1, threadId: "thread-without-rollout" }),
|
||||
"utf8",
|
||||
);
|
||||
const migration = stateMigrations[0];
|
||||
if (!migration) {
|
||||
throw new Error("missing Codex binding migration");
|
||||
}
|
||||
|
||||
await expect(
|
||||
migration.migrateLegacyState({
|
||||
config: {},
|
||||
env,
|
||||
stateDir,
|
||||
oauthDir: path.join(stateDir, "oauth"),
|
||||
context: createDoctorContext(env),
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
changes: [expect.stringContaining("Migrated 1")],
|
||||
warnings: [],
|
||||
});
|
||||
|
||||
const store = createDoctorContext(env).openPluginStateKeyedStore<StoredCodexAppServerBinding>({
|
||||
namespace: CODEX_APP_SERVER_BINDING_NAMESPACE,
|
||||
maxEntries: CODEX_APP_SERVER_BINDING_MAX_ENTRIES,
|
||||
overflowPolicy: "reject-new",
|
||||
});
|
||||
const targetKey = bindingStoreKey({
|
||||
kind: "conversation",
|
||||
bindingId: legacyCodexConversationBindingId(transcriptPath),
|
||||
});
|
||||
await expect(
|
||||
store.lookup(
|
||||
bindingStoreKey({
|
||||
kind: "session",
|
||||
agentId: "main",
|
||||
sessionId: "session-fresh",
|
||||
sessionKey: "agent:main:fresh",
|
||||
}),
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
state: "active",
|
||||
sessionId: "session-fresh",
|
||||
binding: { threadId: "thread-without-rollout" },
|
||||
});
|
||||
await expect(store.lookup(targetKey)).resolves.toMatchObject({
|
||||
state: "active",
|
||||
binding: { threadId: "thread-without-rollout" },
|
||||
});
|
||||
await expect(fs.access(`${sidecarPath}.migrated`)).resolves.toBeUndefined();
|
||||
await fs.rm(stateDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("retains an ambiguous sidecar and converges after its owner resolves", async () => {
|
||||
const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-doctor-"));
|
||||
const env = { ...process.env, HOME: stateDir, OPENCLAW_STATE_DIR: stateDir };
|
||||
const config = {
|
||||
agents: { list: [{ id: "alpha" }, { id: "beta" }] },
|
||||
session: { store: "~/shared/sessions.json" },
|
||||
};
|
||||
const sessionsDir = path.join(stateDir, "shared");
|
||||
const transcriptPath = path.join(sessionsDir, "ambiguous.jsonl");
|
||||
const sidecarPath = `${transcriptPath}.codex-app-server.json`;
|
||||
await fs.mkdir(sessionsDir, { recursive: true });
|
||||
await fs.writeFile(transcriptPath, '{"type":"message"}\n', "utf8");
|
||||
await fs.writeFile(
|
||||
sidecarPath,
|
||||
JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
threadId: "thread-ambiguous",
|
||||
sessionFile: transcriptPath,
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
const migration = stateMigrations[0];
|
||||
if (!migration) {
|
||||
throw new Error("missing Codex binding migration");
|
||||
}
|
||||
|
||||
await expect(
|
||||
migration.migrateLegacyState({
|
||||
config,
|
||||
env,
|
||||
stateDir,
|
||||
oauthDir: path.join(stateDir, "oauth"),
|
||||
context: createDoctorContext(env),
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
changes: [expect.stringContaining("Migrated 1 safe")],
|
||||
warnings: [expect.stringContaining("session owner could not be resolved")],
|
||||
});
|
||||
|
||||
const store = createDoctorContext(env).openPluginStateKeyedStore<StoredCodexAppServerBinding>({
|
||||
namespace: CODEX_APP_SERVER_BINDING_NAMESPACE,
|
||||
maxEntries: CODEX_APP_SERVER_BINDING_MAX_ENTRIES,
|
||||
overflowPolicy: "reject-new",
|
||||
});
|
||||
await expect(
|
||||
store.lookup(
|
||||
bindingStoreKey({
|
||||
kind: "conversation",
|
||||
bindingId: legacyCodexConversationBindingId(transcriptPath),
|
||||
}),
|
||||
),
|
||||
).resolves.toMatchObject({ state: "active", binding: { threadId: "thread-ambiguous" } });
|
||||
await expect(fs.access(sidecarPath)).resolves.toBeUndefined();
|
||||
|
||||
const conversationKey = bindingStoreKey({
|
||||
kind: "conversation",
|
||||
bindingId: legacyCodexConversationBindingId(transcriptPath),
|
||||
});
|
||||
const imported = await store.lookup(conversationKey);
|
||||
if (imported?.state !== "active") {
|
||||
throw new Error("missing imported Codex conversation binding");
|
||||
}
|
||||
await store.register(conversationKey, {
|
||||
...imported,
|
||||
binding: { ...imported.binding, threadId: "thread-recovered" },
|
||||
});
|
||||
await expect(
|
||||
migration.migrateLegacyState({
|
||||
config,
|
||||
env,
|
||||
stateDir,
|
||||
oauthDir: path.join(stateDir, "oauth"),
|
||||
context: createDoctorContext(env),
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
changes: [],
|
||||
warnings: [expect.stringContaining("session owner could not be resolved")],
|
||||
});
|
||||
await expect(store.lookup(conversationKey)).resolves.toMatchObject({
|
||||
state: "active",
|
||||
binding: { threadId: "thread-recovered" },
|
||||
});
|
||||
|
||||
await fs.writeFile(
|
||||
path.join(sessionsDir, "sessions.json"),
|
||||
JSON.stringify({
|
||||
"agent:alpha:ambiguous": {
|
||||
sessionId: "session-ambiguous",
|
||||
sessionFile: "ambiguous.jsonl",
|
||||
totalTokens: 12_345,
|
||||
totalTokensFresh: true,
|
||||
contextTokens: 128_000,
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
await expect(
|
||||
migration.migrateLegacyState({
|
||||
config,
|
||||
env,
|
||||
stateDir,
|
||||
oauthDir: path.join(stateDir, "oauth"),
|
||||
context: createDoctorContext(env),
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
changes: [expect.stringContaining("Migrated 1")],
|
||||
warnings: [],
|
||||
});
|
||||
await expect(
|
||||
store.lookup(
|
||||
bindingStoreKey({
|
||||
kind: "session",
|
||||
agentId: "alpha",
|
||||
sessionId: "session-ambiguous",
|
||||
sessionKey: "agent:alpha:ambiguous",
|
||||
}),
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
state: "active",
|
||||
sessionId: "session-ambiguous",
|
||||
binding: { threadId: "thread-recovered" },
|
||||
});
|
||||
await expect(store.lookup(conversationKey)).resolves.toMatchObject({
|
||||
state: "active",
|
||||
binding: {
|
||||
threadId: "thread-recovered",
|
||||
},
|
||||
});
|
||||
await expect(store.lookup(conversationKey)).resolves.not.toHaveProperty(
|
||||
"binding.nativeContextUsage",
|
||||
);
|
||||
await expect(fs.access(`${sidecarPath}.migrated`)).resolves.toBeUndefined();
|
||||
await fs.rm(stateDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("uses canonical custom-store, agent, and nested transcript path resolution", async () => {
|
||||
const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-doctor-"));
|
||||
const customStoreRoot = await fs.mkdtemp(
|
||||
path.join(os.tmpdir(), "openclaw-codex-custom-store-"),
|
||||
);
|
||||
const env = { ...process.env, HOME: stateDir, OPENCLAW_STATE_DIR: stateDir };
|
||||
const config = {
|
||||
agents: { list: [{ id: "alpha" }] },
|
||||
session: { store: path.join(customStoreRoot, "{agentId}", "sessions.json") },
|
||||
};
|
||||
const sessionsDir = path.join(customStoreRoot, "alpha");
|
||||
const transcriptPath = path.join(sessionsDir, "nested", "session-custom.jsonl");
|
||||
const sidecarPath = `${transcriptPath}.codex-app-server.json`;
|
||||
await fs.mkdir(path.dirname(transcriptPath), { recursive: true });
|
||||
await fs.writeFile(transcriptPath, '{"type":"session","id":"session-custom"}\n', "utf8");
|
||||
await fs.writeFile(
|
||||
path.join(sessionsDir, "sessions.json"),
|
||||
JSON.stringify({
|
||||
"agent:alpha:custom": {
|
||||
sessionId: "session-custom",
|
||||
sessionFile: "nested/session-custom.jsonl",
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
await fs.writeFile(
|
||||
sidecarPath,
|
||||
JSON.stringify({ schemaVersion: 1, threadId: "thread-custom" }),
|
||||
"utf8",
|
||||
);
|
||||
const unrelatedSidecar = path.join(
|
||||
customStoreRoot,
|
||||
"unrelated",
|
||||
`not-a-session.jsonl.codex-app-server.json`,
|
||||
);
|
||||
await fs.mkdir(path.dirname(unrelatedSidecar), { recursive: true });
|
||||
await fs.writeFile(
|
||||
unrelatedSidecar,
|
||||
JSON.stringify({ schemaVersion: 1, threadId: "unrelated-thread" }),
|
||||
"utf8",
|
||||
);
|
||||
const migration = stateMigrations[0];
|
||||
if (!migration) {
|
||||
throw new Error("missing Codex binding migration");
|
||||
}
|
||||
|
||||
await migration.migrateLegacyState({
|
||||
config,
|
||||
env,
|
||||
stateDir,
|
||||
oauthDir: path.join(stateDir, "oauth"),
|
||||
context: createDoctorContext(env),
|
||||
});
|
||||
|
||||
const store = createDoctorContext(env).openPluginStateKeyedStore<StoredCodexAppServerBinding>({
|
||||
namespace: CODEX_APP_SERVER_BINDING_NAMESPACE,
|
||||
maxEntries: CODEX_APP_SERVER_BINDING_MAX_ENTRIES,
|
||||
overflowPolicy: "reject-new",
|
||||
});
|
||||
await expect(
|
||||
store.lookup(
|
||||
bindingStoreKey({
|
||||
kind: "session",
|
||||
agentId: "alpha",
|
||||
sessionId: "session-custom",
|
||||
sessionKey: "agent:alpha:custom",
|
||||
}),
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
state: "active",
|
||||
sessionId: "session-custom",
|
||||
binding: { threadId: "thread-custom" },
|
||||
});
|
||||
await expect(
|
||||
store.lookup(
|
||||
bindingStoreKey({
|
||||
kind: "conversation",
|
||||
bindingId: legacyCodexConversationBindingId(transcriptPath),
|
||||
}),
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
state: "active",
|
||||
binding: { threadId: "thread-custom" },
|
||||
});
|
||||
await expect(fs.access(unrelatedSidecar)).resolves.toBeUndefined();
|
||||
await fs.rm(stateDir, { recursive: true, force: true });
|
||||
await fs.rm(customStoreRoot, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
/**
|
||||
* Doctor contract hooks for Codex plugin config migrations and session-route
|
||||
* ownership warnings.
|
||||
*/
|
||||
/** Doctor contract hooks for Codex config, state migration, and route ownership. */
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import type { DoctorSessionRouteStateOwner } from "openclaw/plugin-sdk/runtime-doctor";
|
||||
|
||||
@@ -31,9 +28,7 @@ export const legacyConfigRules: LegacyConfigRule[] = [
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Removes retired Codex plugin config keys while preserving unrelated config.
|
||||
*/
|
||||
/** Removes retired Codex plugin config keys while preserving unrelated config. */
|
||||
export function normalizeCompatibilityConfig({ cfg }: { cfg: OpenClawConfig }): {
|
||||
config: OpenClawConfig;
|
||||
changes: string[];
|
||||
@@ -47,10 +42,9 @@ export function normalizeCompatibilityConfig({ cfg }: { cfg: OpenClawConfig }):
|
||||
const nextConfig = structuredClone(cfg) as OpenClawConfig & {
|
||||
plugins?: Record<string, unknown>;
|
||||
};
|
||||
const nextPlugins = asRecord(nextConfig.plugins);
|
||||
const nextEntries = asRecord(nextPlugins?.entries);
|
||||
const nextEntry = asRecord(nextEntries?.codex);
|
||||
const nextPluginConfig = asRecord(nextEntry?.config);
|
||||
const nextPluginConfig = asRecord(
|
||||
asRecord(asRecord(asRecord(nextConfig.plugins)?.entries)?.codex)?.config,
|
||||
);
|
||||
if (!nextPluginConfig) {
|
||||
return { config: cfg, changes: [] };
|
||||
}
|
||||
@@ -75,3 +69,5 @@ export const sessionRouteStateOwners: DoctorSessionRouteStateOwner[] = [
|
||||
authProfilePrefixes: ["codex:", "codex-cli:", "openai-codex:"],
|
||||
},
|
||||
];
|
||||
|
||||
export { stateMigrations } from "./src/migration/session-binding-sidecars.js";
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
// Codex tests cover harness plugin behavior.
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createCodexAppServerAgentHarness } from "./harness.js";
|
||||
import {
|
||||
createCodexTestBindingStore,
|
||||
testCodexAppServerBindingStore,
|
||||
} from "./src/app-server/session-binding.test-helpers.js";
|
||||
|
||||
describe("Codex agent harness supports()", () => {
|
||||
const harness = createCodexAppServerAgentHarness();
|
||||
const harness = createCodexAppServerAgentHarness({
|
||||
bindingStore: testCodexAppServerBindingStore,
|
||||
});
|
||||
|
||||
it("supports the canonical codex virtual provider", () => {
|
||||
expect(harness.supports({ provider: "codex", requestedRuntime: "codex" })).toEqual({
|
||||
@@ -40,8 +49,149 @@ describe("Codex agent harness supports()", () => {
|
||||
});
|
||||
|
||||
it("honors explicit provider id overrides", () => {
|
||||
const narrowHarness = createCodexAppServerAgentHarness({ providerIds: ["codex"] });
|
||||
const narrowHarness = createCodexAppServerAgentHarness({
|
||||
bindingStore: testCodexAppServerBindingStore,
|
||||
providerIds: ["codex"],
|
||||
});
|
||||
const result = narrowHarness.supports({ provider: "openai", requestedRuntime: "codex" });
|
||||
expect(result.supported).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Codex agent harness reset", () => {
|
||||
it("uses the host agent for global session keys", async () => {
|
||||
const bindingStore = createCodexTestBindingStore();
|
||||
const harness = createCodexAppServerAgentHarness({ bindingStore });
|
||||
const identity = {
|
||||
kind: "session" as const,
|
||||
agentId: "work",
|
||||
sessionId: "session-1",
|
||||
sessionKey: "global",
|
||||
};
|
||||
await bindingStore.mutate(identity, {
|
||||
kind: "set",
|
||||
binding: { threadId: "thread-work", cwd: "/repo" },
|
||||
});
|
||||
|
||||
await harness.reset?.({
|
||||
agentId: "work",
|
||||
sessionId: "session-1",
|
||||
sessionKey: "global",
|
||||
reason: "reset",
|
||||
});
|
||||
|
||||
await expect(bindingStore.read(identity)).resolves.toBeUndefined();
|
||||
await expect(
|
||||
bindingStore.mutate(identity, {
|
||||
kind: "set",
|
||||
binding: { threadId: "thread-stale", cwd: "/stale" },
|
||||
}),
|
||||
).resolves.toBe(false);
|
||||
const nextIdentity = { ...identity, sessionId: "session-2" };
|
||||
await expect(
|
||||
bindingStore.mutate(nextIdentity, {
|
||||
kind: "set",
|
||||
binding: { threadId: "thread-next", cwd: "/next" },
|
||||
}),
|
||||
).resolves.toBe(false);
|
||||
await expect(
|
||||
bindingStore.mutate(nextIdentity, {
|
||||
kind: "reclaim-generation",
|
||||
expectedPreviousSessionId: identity.sessionId,
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
await expect(
|
||||
bindingStore.mutate(nextIdentity, {
|
||||
kind: "set",
|
||||
binding: { threadId: "thread-next", cwd: "/next" },
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
await expect(bindingStore.read(nextIdentity)).resolves.toMatchObject({
|
||||
threadId: "thread-next",
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts an absent binding but rejects a mismatched reset generation", async () => {
|
||||
const bindingStore = createCodexTestBindingStore();
|
||||
const harness = createCodexAppServerAgentHarness({ bindingStore });
|
||||
const current = {
|
||||
kind: "session" as const,
|
||||
agentId: "main",
|
||||
sessionId: "session-1",
|
||||
sessionKey: "agent:main:main",
|
||||
};
|
||||
|
||||
await expect(
|
||||
harness.reset?.({
|
||||
agentId: "main",
|
||||
sessionId: "missing-session",
|
||||
sessionKey: "agent:main:missing",
|
||||
reason: "reset",
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
await bindingStore.mutate(current, {
|
||||
kind: "set",
|
||||
binding: { threadId: "thread-1", cwd: "/repo" },
|
||||
});
|
||||
await expect(
|
||||
harness.reset?.({
|
||||
agentId: "main",
|
||||
sessionId: "session-2",
|
||||
sessionKey: current.sessionKey,
|
||||
reason: "reset",
|
||||
}),
|
||||
).rejects.toThrow("binding generation changed");
|
||||
await expect(bindingStore.read(current)).resolves.toMatchObject({ threadId: "thread-1" });
|
||||
});
|
||||
|
||||
it("reclaims a stale generation left while the Codex plugin was unavailable", async () => {
|
||||
const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-reset-"));
|
||||
const storePath = path.join(stateDir, "sessions.json");
|
||||
const sessionKey = "agent:main:main";
|
||||
await fs.writeFile(
|
||||
storePath,
|
||||
JSON.stringify({
|
||||
[sessionKey]: {
|
||||
sessionId: "session-2",
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
const bindingStore = createCodexTestBindingStore();
|
||||
const harness = createCodexAppServerAgentHarness({
|
||||
bindingStore,
|
||||
resolveConfig: () => ({ session: { store: storePath } }),
|
||||
});
|
||||
const stale = {
|
||||
kind: "session" as const,
|
||||
agentId: "main",
|
||||
sessionId: "session-1",
|
||||
sessionKey,
|
||||
};
|
||||
await bindingStore.mutate(stale, {
|
||||
kind: "set",
|
||||
binding: { threadId: "thread-stale", cwd: "/repo" },
|
||||
});
|
||||
|
||||
await expect(
|
||||
harness.reset?.({
|
||||
agentId: "main",
|
||||
sessionId: "session-2",
|
||||
sessionKey,
|
||||
reason: "reset",
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
const current = { ...stale, sessionId: "session-2" };
|
||||
await expect(bindingStore.read(current)).resolves.toBeUndefined();
|
||||
await expect(
|
||||
bindingStore.mutate(current, {
|
||||
kind: "set",
|
||||
binding: { threadId: "thread-delayed", cwd: "/repo" },
|
||||
}),
|
||||
).resolves.toBe(false);
|
||||
await fs.rm(stateDir, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,11 +7,13 @@ import type {
|
||||
AgentHarnessCompactResult,
|
||||
ContextEngineHostCapability,
|
||||
} from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import type {
|
||||
CodexAppServerListModelsOptions,
|
||||
CodexAppServerModel,
|
||||
CodexAppServerModelListResult,
|
||||
} from "./src/app-server/models.js";
|
||||
import type { CodexAppServerBindingStore } from "./src/app-server/session-binding.js";
|
||||
|
||||
const DEFAULT_CODEX_HARNESS_PROVIDER_IDS = new Set(["codex", "openai"]);
|
||||
const CODEX_APP_SERVER_CONTEXT_ENGINE_HOST_CAPABILITIES = [
|
||||
@@ -37,12 +39,14 @@ type CodexAppServerAgentHarness = AgentHarness & {
|
||||
* Creates the Codex app-server harness used for attempts, side questions,
|
||||
* compaction, reset, and disposal.
|
||||
*/
|
||||
export function createCodexAppServerAgentHarness(options?: {
|
||||
export function createCodexAppServerAgentHarness(options: {
|
||||
id?: string;
|
||||
label?: string;
|
||||
providerIds?: Iterable<string>;
|
||||
pluginConfig?: unknown;
|
||||
resolvePluginConfig?: () => unknown;
|
||||
resolveConfig?: () => OpenClawConfig | undefined;
|
||||
bindingStore: CodexAppServerBindingStore;
|
||||
}): AgentHarness {
|
||||
const providerIds = new Set(
|
||||
[...(options?.providerIds ?? DEFAULT_CODEX_HARNESS_PROVIDER_IDS)].map((id) =>
|
||||
@@ -71,6 +75,7 @@ export function createCodexAppServerAgentHarness(options?: {
|
||||
// cold provider catalog reads do not pull in the whole Codex runtime.
|
||||
const { runCodexAppServerAttempt } = await import("./src/app-server/run-attempt.js");
|
||||
return runCodexAppServerAttempt(params, {
|
||||
bindingStore: options.bindingStore,
|
||||
pluginConfig: options?.resolvePluginConfig?.() ?? options?.pluginConfig,
|
||||
nativeHookRelay: { enabled: true },
|
||||
});
|
||||
@@ -78,6 +83,7 @@ export function createCodexAppServerAgentHarness(options?: {
|
||||
runSideQuestion: async (params) => {
|
||||
const { runCodexAppServerSideQuestion } = await import("./src/app-server/side-question.js");
|
||||
return runCodexAppServerSideQuestion(params, {
|
||||
bindingStore: options.bindingStore,
|
||||
pluginConfig: options?.resolvePluginConfig?.() ?? options?.pluginConfig,
|
||||
nativeHookRelay: { enabled: true },
|
||||
});
|
||||
@@ -85,20 +91,43 @@ export function createCodexAppServerAgentHarness(options?: {
|
||||
compact: async (params) => {
|
||||
const { maybeCompactCodexAppServerSession } = await import("./src/app-server/compact.js");
|
||||
return maybeCompactCodexAppServerSession(params, {
|
||||
bindingStore: options.bindingStore,
|
||||
pluginConfig: options?.resolvePluginConfig?.() ?? options?.pluginConfig,
|
||||
});
|
||||
},
|
||||
compactAfterContextEngine: async (params) => {
|
||||
const { maybeCompactCodexAppServerSession } = await import("./src/app-server/compact.js");
|
||||
return maybeCompactCodexAppServerSession(params, {
|
||||
bindingStore: options.bindingStore,
|
||||
pluginConfig: options?.resolvePluginConfig?.() ?? options?.pluginConfig,
|
||||
allowNonManualNativeRequest: true,
|
||||
});
|
||||
},
|
||||
reset: async (params) => {
|
||||
if (params.sessionFile) {
|
||||
const { clearCodexAppServerBinding } = await import("./src/app-server/session-binding.js");
|
||||
await clearCodexAppServerBinding(params.sessionFile);
|
||||
if (params.sessionId) {
|
||||
const { reclaimCurrentCodexSessionGeneration, sessionBindingIdentity } =
|
||||
await import("./src/app-server/session-binding.js");
|
||||
const identity = sessionBindingIdentity({
|
||||
agentId: params.agentId,
|
||||
sessionId: params.sessionId,
|
||||
sessionKey: params.sessionKey,
|
||||
});
|
||||
let retired = await options.bindingStore.retireSessionGeneration(identity);
|
||||
if (retired === "conflict") {
|
||||
const reclaimed = await reclaimCurrentCodexSessionGeneration({
|
||||
bindingStore: options.bindingStore,
|
||||
identity,
|
||||
config: options.resolveConfig?.(),
|
||||
});
|
||||
if (reclaimed) {
|
||||
retired = await options.bindingStore.retireSessionGeneration(identity);
|
||||
}
|
||||
}
|
||||
if (retired === "conflict") {
|
||||
throw new Error(
|
||||
`Codex binding generation changed before session ${params.sessionId} could reset`,
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
dispose: async () => {
|
||||
|
||||
@@ -4,10 +4,30 @@ import { createTestPluginApi } from "openclaw/plugin-sdk/plugin-test-api";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createCodexAppServerAgentHarness } from "./harness.js";
|
||||
import plugin from "./index.js";
|
||||
import {
|
||||
createCodexAppServerBindingStore,
|
||||
sessionBindingIdentity,
|
||||
} from "./src/app-server/session-binding.js";
|
||||
import {
|
||||
createCodexTestBindingStateStore,
|
||||
testCodexAppServerBindingStore,
|
||||
} from "./src/app-server/session-binding.test-helpers.js";
|
||||
|
||||
const runCodexAppServerAttemptMock = vi.hoisted(() => vi.fn());
|
||||
const runCodexAppServerSideQuestionMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
function createCodexTestRuntime(
|
||||
current?: () => unknown,
|
||||
stateStore = createCodexTestBindingStateStore(),
|
||||
) {
|
||||
return {
|
||||
...(current ? { config: { current } } : {}),
|
||||
state: {
|
||||
openSyncKeyedStore: () => stateStore,
|
||||
},
|
||||
} as never;
|
||||
}
|
||||
|
||||
vi.mock("./src/app-server/run-attempt.js", () => ({
|
||||
runCodexAppServerAttempt: runCodexAppServerAttemptMock,
|
||||
}));
|
||||
@@ -39,7 +59,6 @@ describe("codex plugin", () => {
|
||||
const registerMigrationProvider = vi.fn();
|
||||
const registerProvider = vi.fn();
|
||||
const on = vi.fn();
|
||||
const onConversationBindingResolved = vi.fn();
|
||||
|
||||
plugin.register(
|
||||
createTestPluginApi({
|
||||
@@ -48,14 +67,13 @@ describe("codex plugin", () => {
|
||||
source: "test",
|
||||
config: {},
|
||||
pluginConfig: {},
|
||||
runtime: {} as never,
|
||||
runtime: createCodexTestRuntime(),
|
||||
registerAgentHarness,
|
||||
registerCommand,
|
||||
registerMediaUnderstandingProvider,
|
||||
registerMigrationProvider,
|
||||
registerProvider,
|
||||
on,
|
||||
onConversationBindingResolved,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -65,9 +83,6 @@ describe("codex plugin", () => {
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
const inboundClaimRegistration = mockCall(on) as [unknown, unknown] | undefined;
|
||||
const bindingResolvedRegistration = mockCall(onConversationBindingResolved) as
|
||||
| [unknown]
|
||||
| undefined;
|
||||
|
||||
expect(providerRegistration.id).toBe("codex");
|
||||
expect(providerRegistration.label).toBe("Codex");
|
||||
@@ -94,33 +109,12 @@ describe("codex plugin", () => {
|
||||
expect(migrationRegistration?.label).toBe("Codex");
|
||||
expect(inboundClaimRegistration?.[0]).toBe("inbound_claim");
|
||||
expect(typeof inboundClaimRegistration?.[1]).toBe("function");
|
||||
expect(typeof bindingResolvedRegistration?.[0]).toBe("function");
|
||||
});
|
||||
|
||||
it("registers with capture APIs that do not expose conversation binding hooks yet", () => {
|
||||
const registerProvider = vi.fn();
|
||||
const api = createTestPluginApi({
|
||||
id: "codex",
|
||||
name: "Codex",
|
||||
source: "test",
|
||||
config: {},
|
||||
pluginConfig: {},
|
||||
runtime: {} as never,
|
||||
registerAgentHarness: vi.fn(),
|
||||
registerCommand: vi.fn(),
|
||||
registerMediaUnderstandingProvider: vi.fn(),
|
||||
registerProvider,
|
||||
on: vi.fn(),
|
||||
});
|
||||
delete (api as { onConversationBindingResolved?: unknown }).onConversationBindingResolved;
|
||||
|
||||
plugin.register(api);
|
||||
expect(registerProvider).toHaveBeenCalledTimes(1);
|
||||
expect((mockCallArg(registerProvider) as { id?: string } | undefined)?.id).toBe("codex");
|
||||
});
|
||||
|
||||
it("claims the Codex routing providers by default", () => {
|
||||
const harness = createCodexAppServerAgentHarness();
|
||||
const harness = createCodexAppServerAgentHarness({
|
||||
bindingStore: testCodexAppServerBindingStore,
|
||||
});
|
||||
|
||||
expect(harness.deliveryDefaults?.sourceVisibleReplies).toBe("message_tool");
|
||||
expect(
|
||||
@@ -141,8 +135,196 @@ describe("codex plugin", () => {
|
||||
expect(unsupported.supported).toBe(false);
|
||||
});
|
||||
|
||||
it("clears only ended session binding rows in the owning agent scope", async () => {
|
||||
const stateStore = createCodexTestBindingStateStore();
|
||||
const bindingStore = createCodexAppServerBindingStore(stateStore);
|
||||
const on = vi.fn();
|
||||
plugin.register(
|
||||
createTestPluginApi({
|
||||
id: "codex",
|
||||
name: "Codex",
|
||||
source: "test",
|
||||
config: {},
|
||||
pluginConfig: {},
|
||||
runtime: createCodexTestRuntime(undefined, stateStore),
|
||||
registerAgentHarness: vi.fn(),
|
||||
registerCommand: vi.fn(),
|
||||
registerMediaUnderstandingProvider: vi.fn(),
|
||||
registerMigrationProvider: vi.fn(),
|
||||
registerProvider: vi.fn(),
|
||||
on,
|
||||
}),
|
||||
);
|
||||
const sessionEnd = on.mock.calls.find(([name]) => name === "session_end")?.[1] as
|
||||
| ((
|
||||
event: { sessionId: string; sessionKey?: string; reason?: string },
|
||||
ctx: { agentId?: string; sessionId: string; sessionKey?: string },
|
||||
) => Promise<void>)
|
||||
| undefined;
|
||||
if (!sessionEnd) {
|
||||
throw new Error("missing Codex session_end hook");
|
||||
}
|
||||
const identity = sessionBindingIdentity({
|
||||
agentId: "worker",
|
||||
sessionId: "session-1",
|
||||
sessionKey: "agent:worker:session-1",
|
||||
});
|
||||
const setBinding = () =>
|
||||
bindingStore.mutate(identity, {
|
||||
kind: "set",
|
||||
binding: { threadId: "thread-1", cwd: "/repo" },
|
||||
});
|
||||
|
||||
for (const reason of ["shutdown", "restart", "compaction", "unknown"] as const) {
|
||||
await setBinding();
|
||||
await sessionEnd(
|
||||
{ sessionId: "session-1", sessionKey: "agent:worker:session-1", reason },
|
||||
{ agentId: "worker", sessionId: "session-1" },
|
||||
);
|
||||
await expect(bindingStore.read(identity)).resolves.toMatchObject({
|
||||
threadId: "thread-1",
|
||||
});
|
||||
}
|
||||
for (const reason of ["new", "reset", "idle", "daily", "deleted"] as const) {
|
||||
await setBinding();
|
||||
await sessionEnd(
|
||||
{ sessionId: "session-1", sessionKey: "agent:worker:session-1", reason },
|
||||
{ agentId: "worker", sessionId: "session-1" },
|
||||
);
|
||||
await expect(bindingStore.read(identity)).resolves.toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("adopts compaction successors before delayed lifecycle cleanup", async () => {
|
||||
const stateStore = createCodexTestBindingStateStore();
|
||||
const bindingStore = createCodexAppServerBindingStore(stateStore);
|
||||
const on = vi.fn();
|
||||
plugin.register(
|
||||
createTestPluginApi({
|
||||
id: "codex",
|
||||
name: "Codex",
|
||||
source: "test",
|
||||
config: {},
|
||||
pluginConfig: {},
|
||||
runtime: createCodexTestRuntime(undefined, stateStore),
|
||||
registerAgentHarness: vi.fn(),
|
||||
registerCommand: vi.fn(),
|
||||
registerMediaUnderstandingProvider: vi.fn(),
|
||||
registerMigrationProvider: vi.fn(),
|
||||
registerProvider: vi.fn(),
|
||||
on,
|
||||
}),
|
||||
);
|
||||
const afterCompaction = on.mock.calls.find(([name]) => name === "after_compaction")?.[1] as
|
||||
| ((
|
||||
event: {
|
||||
messageCount: number;
|
||||
compactedCount: number;
|
||||
previousSessionId?: string;
|
||||
},
|
||||
ctx: { agentId?: string; sessionId?: string; sessionKey?: string },
|
||||
) => Promise<void>)
|
||||
| undefined;
|
||||
const sessionEnd = on.mock.calls.find(([name]) => name === "session_end")?.[1] as
|
||||
| ((
|
||||
event: { sessionId: string; sessionKey?: string; reason?: string },
|
||||
ctx: { agentId?: string; sessionId: string; sessionKey?: string },
|
||||
) => Promise<void>)
|
||||
| undefined;
|
||||
if (!afterCompaction || !sessionEnd) {
|
||||
throw new Error("missing Codex compaction lifecycle hooks");
|
||||
}
|
||||
const sessionKey = "agent:worker:telegram:chat-1";
|
||||
const previous = sessionBindingIdentity({
|
||||
agentId: "worker",
|
||||
sessionId: "session-1",
|
||||
sessionKey,
|
||||
});
|
||||
const successor = sessionBindingIdentity({
|
||||
agentId: "worker",
|
||||
sessionId: "session-2",
|
||||
sessionKey,
|
||||
});
|
||||
const newest = sessionBindingIdentity({
|
||||
agentId: "worker",
|
||||
sessionId: "session-3",
|
||||
sessionKey,
|
||||
});
|
||||
await bindingStore.mutate(previous, {
|
||||
kind: "set",
|
||||
binding: { threadId: "thread-1", cwd: "/repo" },
|
||||
});
|
||||
|
||||
await afterCompaction(
|
||||
{ messageCount: 1, compactedCount: 1, previousSessionId: "session-1" },
|
||||
{ agentId: "worker", sessionId: "session-2", sessionKey },
|
||||
);
|
||||
await expect(bindingStore.read(previous)).resolves.toBeUndefined();
|
||||
await expect(bindingStore.read(successor)).resolves.toMatchObject({ threadId: "thread-1" });
|
||||
|
||||
await afterCompaction(
|
||||
{ messageCount: 1, compactedCount: 1, previousSessionId: "session-2" },
|
||||
{ agentId: "worker", sessionId: "session-3", sessionKey },
|
||||
);
|
||||
await afterCompaction(
|
||||
{ messageCount: 1, compactedCount: 1, previousSessionId: "session-1" },
|
||||
{ agentId: "worker", sessionId: "session-2", sessionKey },
|
||||
);
|
||||
await expect(bindingStore.read(successor)).resolves.toBeUndefined();
|
||||
await expect(bindingStore.read(newest)).resolves.toMatchObject({ threadId: "thread-1" });
|
||||
|
||||
await sessionEnd(
|
||||
{ sessionId: "session-1", sessionKey, reason: "reset" },
|
||||
{ agentId: "worker", sessionId: "session-1", sessionKey },
|
||||
);
|
||||
await sessionEnd(
|
||||
{ sessionId: "session-2", sessionKey, reason: "compaction" },
|
||||
{ agentId: "worker", sessionId: "session-2", sessionKey },
|
||||
);
|
||||
await expect(bindingStore.read(newest)).resolves.toMatchObject({ threadId: "thread-1" });
|
||||
expect(stateStore.entries()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("ignores compaction for a session without a Codex binding", async () => {
|
||||
const warn = vi.fn();
|
||||
const on = vi.fn();
|
||||
plugin.register(
|
||||
createTestPluginApi({
|
||||
id: "codex",
|
||||
name: "Codex",
|
||||
source: "test",
|
||||
config: {},
|
||||
pluginConfig: {},
|
||||
logger: { debug: vi.fn(), info: vi.fn(), warn, error: vi.fn() },
|
||||
runtime: createCodexTestRuntime(),
|
||||
registerAgentHarness: vi.fn(),
|
||||
registerCommand: vi.fn(),
|
||||
registerMediaUnderstandingProvider: vi.fn(),
|
||||
registerMigrationProvider: vi.fn(),
|
||||
registerProvider: vi.fn(),
|
||||
on,
|
||||
}),
|
||||
);
|
||||
const afterCompaction = on.mock.calls.find(([name]) => name === "after_compaction")?.[1] as
|
||||
| ((event: object, ctx: { sessionId?: string; sessionKey?: string }) => Promise<void>)
|
||||
| undefined;
|
||||
if (!afterCompaction) {
|
||||
throw new Error("missing Codex after_compaction hook");
|
||||
}
|
||||
|
||||
await afterCompaction(
|
||||
{ previousSessionId: "session-1" },
|
||||
{ sessionId: "session-2", sessionKey: "agent:main:main" },
|
||||
);
|
||||
|
||||
expect(warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("enables the native hook relay for public Codex app-server attempts", async () => {
|
||||
const harness = createCodexAppServerAgentHarness({ pluginConfig: { appServer: {} } });
|
||||
const harness = createCodexAppServerAgentHarness({
|
||||
bindingStore: testCodexAppServerBindingStore,
|
||||
pluginConfig: { appServer: {} },
|
||||
});
|
||||
const result = { success: true };
|
||||
runCodexAppServerAttemptMock.mockResolvedValueOnce(result);
|
||||
|
||||
@@ -151,6 +333,7 @@ describe("codex plugin", () => {
|
||||
expect(runCodexAppServerAttemptMock).toHaveBeenCalledWith(
|
||||
{ prompt: "hello" },
|
||||
{
|
||||
bindingStore: testCodexAppServerBindingStore,
|
||||
pluginConfig: { appServer: {} },
|
||||
nativeHookRelay: { enabled: true },
|
||||
},
|
||||
@@ -185,11 +368,7 @@ describe("codex plugin", () => {
|
||||
source: "test",
|
||||
config: {},
|
||||
pluginConfig: { codexPlugins: { enabled: false } },
|
||||
runtime: {
|
||||
config: {
|
||||
current: () => liveConfig,
|
||||
},
|
||||
} as never,
|
||||
runtime: createCodexTestRuntime(() => liveConfig),
|
||||
registerAgentHarness,
|
||||
registerCommand: vi.fn(),
|
||||
registerMediaUnderstandingProvider: vi.fn(),
|
||||
@@ -209,14 +388,49 @@ describe("codex plugin", () => {
|
||||
expect(runCodexAppServerAttemptMock).toHaveBeenCalledWith(
|
||||
{ prompt: "calendar" },
|
||||
{
|
||||
bindingStore: expect.any(Object),
|
||||
pluginConfig: liveConfig.plugins.entries.codex.config,
|
||||
nativeHookRelay: { enabled: true },
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("does not resurrect startup Codex config after the live entry is removed", async () => {
|
||||
const registerAgentHarness = vi.fn();
|
||||
plugin.register(
|
||||
createTestPluginApi({
|
||||
id: "codex",
|
||||
name: "Codex",
|
||||
source: "test",
|
||||
config: {},
|
||||
pluginConfig: { appServer: { mode: "yolo" } },
|
||||
runtime: createCodexTestRuntime(() => ({ plugins: { entries: {} } })),
|
||||
registerAgentHarness,
|
||||
registerCommand: vi.fn(),
|
||||
registerMediaUnderstandingProvider: vi.fn(),
|
||||
registerMigrationProvider: vi.fn(),
|
||||
registerProvider: vi.fn(),
|
||||
on: vi.fn(),
|
||||
}),
|
||||
);
|
||||
const harness = mockCallArg(registerAgentHarness) as ReturnType<
|
||||
typeof createCodexAppServerAgentHarness
|
||||
>;
|
||||
runCodexAppServerAttemptMock.mockResolvedValueOnce({ success: true });
|
||||
|
||||
await harness.runAttempt({ prompt: "default policy" } as never);
|
||||
|
||||
expect(runCodexAppServerAttemptMock).toHaveBeenCalledWith(
|
||||
{ prompt: "default policy" },
|
||||
expect.objectContaining({ pluginConfig: undefined }),
|
||||
);
|
||||
});
|
||||
|
||||
it("enables the native hook relay for public Codex side questions", async () => {
|
||||
const harness = createCodexAppServerAgentHarness({ pluginConfig: { appServer: {} } });
|
||||
const harness = createCodexAppServerAgentHarness({
|
||||
bindingStore: testCodexAppServerBindingStore,
|
||||
pluginConfig: { appServer: {} },
|
||||
});
|
||||
const runSideQuestion = harness["runSideQuestion"];
|
||||
const result = { text: "ok" };
|
||||
runCodexAppServerSideQuestionMock.mockResolvedValueOnce(result);
|
||||
@@ -229,6 +443,7 @@ describe("codex plugin", () => {
|
||||
expect(runCodexAppServerSideQuestionMock).toHaveBeenCalledWith(
|
||||
{ question: "btw" },
|
||||
{
|
||||
bindingStore: testCodexAppServerBindingStore,
|
||||
pluginConfig: { appServer: {} },
|
||||
nativeHookRelay: { enabled: true },
|
||||
},
|
||||
|
||||
@@ -4,47 +4,71 @@
|
||||
*/
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { mutateConfigFile } from "openclaw/plugin-sdk/config-mutation";
|
||||
import { resolveLivePluginConfigObject } from "openclaw/plugin-sdk/plugin-config-runtime";
|
||||
import {
|
||||
resolveLivePluginConfigObject,
|
||||
resolvePluginConfigObject,
|
||||
} from "openclaw/plugin-sdk/plugin-config-runtime";
|
||||
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { createCodexAppServerAgentHarness } from "./harness.js";
|
||||
import { buildCodexMediaUnderstandingProvider } from "./media-understanding-provider.js";
|
||||
import { buildCodexProvider } from "./provider.js";
|
||||
import {
|
||||
CODEX_APP_SERVER_BINDING_MAX_ENTRIES,
|
||||
CODEX_APP_SERVER_BINDING_NAMESPACE,
|
||||
createLazyCodexAppServerBindingStore,
|
||||
type StoredCodexAppServerBinding,
|
||||
} from "./src/app-server/session-binding-store.js";
|
||||
import type { CodexPluginsConfigBlock } from "./src/command-plugins-management.js";
|
||||
import { createCodexCommand } from "./src/commands.js";
|
||||
import {
|
||||
handleCodexConversationBindingResolved,
|
||||
handleCodexConversationInboundClaim,
|
||||
} from "./src/conversation-binding.js";
|
||||
import { buildCodexMigrationProvider } from "./src/migration/provider.js";
|
||||
import {
|
||||
createCodexCliSessionNodeHostCommands,
|
||||
createCodexCliSessionNodeInvokePolicies,
|
||||
listCodexCliSessionsOnNode,
|
||||
resumeCodexCliSessionOnNode,
|
||||
resolveCodexCliSessionForBindingOnNode,
|
||||
} from "./src/node-cli-sessions.js";
|
||||
} from "./src/node-cli-session-registration.js";
|
||||
|
||||
const ENDED_SESSION_REASONS: ReadonlySet<string> = new Set([
|
||||
"new",
|
||||
"reset",
|
||||
"idle",
|
||||
"daily",
|
||||
"deleted",
|
||||
]);
|
||||
|
||||
export default definePluginEntry({
|
||||
id: "codex",
|
||||
name: "Codex",
|
||||
description: "Codex app-server harness and Codex-managed GPT model catalog.",
|
||||
register(api) {
|
||||
const resolveCurrentConfig = () =>
|
||||
api.runtime.config?.current ? (api.runtime.config.current() as OpenClawConfig) : undefined;
|
||||
const runtimeConfigLoader = api.runtime.config?.current
|
||||
? () => api.runtime.config?.current() as OpenClawConfig
|
||||
: undefined;
|
||||
const resolveCurrentConfig = () => runtimeConfigLoader?.();
|
||||
const loadNodeCliSessions = () => import("./src/node-cli-sessions.js");
|
||||
const resolveCurrentPluginConfig = () =>
|
||||
// Codex plugin config can change at runtime; resolve from live config for
|
||||
// harness attempts and binding claims instead of keeping startup values.
|
||||
resolveLivePluginConfigObject(
|
||||
resolveCurrentConfig,
|
||||
runtimeConfigLoader,
|
||||
"codex",
|
||||
api.pluginConfig as Record<string, unknown>,
|
||||
) ?? api.pluginConfig;
|
||||
);
|
||||
const bindingStore = createLazyCodexAppServerBindingStore(
|
||||
api.runtime.state.openSyncKeyedStore<StoredCodexAppServerBinding>({
|
||||
namespace: CODEX_APP_SERVER_BINDING_NAMESPACE,
|
||||
maxEntries: CODEX_APP_SERVER_BINDING_MAX_ENTRIES,
|
||||
overflowPolicy: "reject-new",
|
||||
}),
|
||||
);
|
||||
api.registerAgentHarness(
|
||||
createCodexAppServerAgentHarness({ resolvePluginConfig: resolveCurrentPluginConfig }),
|
||||
createCodexAppServerAgentHarness({
|
||||
bindingStore,
|
||||
resolveConfig: resolveCurrentConfig,
|
||||
resolvePluginConfig: resolveCurrentPluginConfig,
|
||||
}),
|
||||
);
|
||||
api.registerProvider(buildCodexProvider({ pluginConfig: api.pluginConfig }));
|
||||
api.registerMediaUnderstandingProvider(
|
||||
buildCodexMediaUnderstandingProvider({ pluginConfig: api.pluginConfig }),
|
||||
buildCodexMediaUnderstandingProvider({ resolvePluginConfig: resolveCurrentPluginConfig }),
|
||||
);
|
||||
api.registerMigrationProvider(buildCodexMigrationProvider({ runtime: api.runtime }));
|
||||
for (const command of createCodexCliSessionNodeHostCommands()) {
|
||||
@@ -55,43 +79,43 @@ export default definePluginEntry({
|
||||
}
|
||||
api.registerCommand(
|
||||
createCodexCommand({
|
||||
pluginConfig: api.pluginConfig,
|
||||
resolvePluginConfig: resolveCurrentPluginConfig,
|
||||
deps: {
|
||||
listCodexCliSessionsOnNode: (params) =>
|
||||
listCodexCliSessionsOnNode({ runtime: api.runtime, ...params }),
|
||||
resolveCodexCliSessionForBindingOnNode: (params) =>
|
||||
resolveCodexCliSessionForBindingOnNode({ runtime: api.runtime, ...params }),
|
||||
bindingStore,
|
||||
listCodexCliSessionsOnNode: async (params) =>
|
||||
await (
|
||||
await loadNodeCliSessions()
|
||||
).listCodexCliSessionsOnNode({
|
||||
runtime: api.runtime,
|
||||
...params,
|
||||
}),
|
||||
resolveCodexCliSessionForBindingOnNode: async (params) =>
|
||||
await (
|
||||
await loadNodeCliSessions()
|
||||
).resolveCodexCliSessionForBindingOnNode({
|
||||
runtime: api.runtime,
|
||||
...params,
|
||||
}),
|
||||
codexPluginsManagementIo: {
|
||||
readConfig: () => {
|
||||
const current = (api.runtime.config?.current?.() ?? {}) as OpenClawConfig;
|
||||
const plugins = (current as Record<string, unknown>).plugins;
|
||||
if (!plugins || typeof plugins !== "object") {
|
||||
const codexPlugins = resolvePluginConfigObject(current, "codex")?.codexPlugins;
|
||||
if (
|
||||
!codexPlugins ||
|
||||
typeof codexPlugins !== "object" ||
|
||||
Array.isArray(codexPlugins)
|
||||
) {
|
||||
return Promise.resolve({});
|
||||
}
|
||||
const entries = (plugins as Record<string, unknown>).entries;
|
||||
if (!entries || typeof entries !== "object") {
|
||||
return Promise.resolve({});
|
||||
}
|
||||
const codexEntry = (entries as Record<string, unknown>).codex;
|
||||
if (!codexEntry || typeof codexEntry !== "object") {
|
||||
return Promise.resolve({});
|
||||
}
|
||||
const config = (codexEntry as Record<string, unknown>).config;
|
||||
if (!config || typeof config !== "object") {
|
||||
return Promise.resolve({});
|
||||
}
|
||||
const codexPlugins = (config as Record<string, unknown>).codexPlugins;
|
||||
if (!codexPlugins || typeof codexPlugins !== "object") {
|
||||
return Promise.resolve({});
|
||||
}
|
||||
const declared = (codexPlugins as Record<string, unknown>).plugins;
|
||||
const block = codexPlugins as Record<string, unknown>;
|
||||
const declared = block.plugins;
|
||||
if (!declared || typeof declared !== "object") {
|
||||
return Promise.resolve({
|
||||
enabled: (codexPlugins as Record<string, unknown>).enabled === true,
|
||||
enabled: block.enabled === true,
|
||||
});
|
||||
}
|
||||
return Promise.resolve({
|
||||
enabled: (codexPlugins as Record<string, unknown>).enabled === true,
|
||||
enabled: block.enabled === true,
|
||||
plugins: declared as Record<string, never>,
|
||||
});
|
||||
},
|
||||
@@ -101,17 +125,12 @@ export default definePluginEntry({
|
||||
// Create the nested plugin config path on demand so codex
|
||||
// plugin commands can enable/update Codex-managed plugins.
|
||||
const root = draft as Record<string, unknown>;
|
||||
root.plugins = (root.plugins ?? {}) as Record<string, unknown>;
|
||||
const pluginsBlock = root.plugins as Record<string, unknown>;
|
||||
pluginsBlock.entries = (pluginsBlock.entries ?? {}) as Record<string, unknown>;
|
||||
const entries = pluginsBlock.entries as Record<string, unknown>;
|
||||
entries.codex = (entries.codex ?? {}) as Record<string, unknown>;
|
||||
const codexEntry = entries.codex as Record<string, unknown>;
|
||||
codexEntry.config = (codexEntry.config ?? {}) as Record<string, unknown>;
|
||||
const config = codexEntry.config as Record<string, unknown>;
|
||||
config.codexPlugins = (config.codexPlugins ?? {}) as Record<string, unknown>;
|
||||
const codexPlugins = config.codexPlugins as Record<string, unknown>;
|
||||
codexPlugins.plugins = (codexPlugins.plugins ?? {}) as Record<string, unknown>;
|
||||
const pluginsBlock = (root.plugins ??= {}) as Record<string, unknown>;
|
||||
const entries = (pluginsBlock.entries ??= {}) as Record<string, unknown>;
|
||||
const codexEntry = (entries.codex ??= {}) as Record<string, unknown>;
|
||||
const config = (codexEntry.config ??= {}) as Record<string, unknown>;
|
||||
const codexPlugins = (config.codexPlugins ??= {}) as Record<string, unknown>;
|
||||
codexPlugins.plugins ??= {};
|
||||
update(codexPlugins as CodexPluginsConfigBlock);
|
||||
},
|
||||
});
|
||||
@@ -120,14 +139,58 @@ export default definePluginEntry({
|
||||
},
|
||||
}),
|
||||
);
|
||||
api.on("inbound_claim", (event, ctx) =>
|
||||
handleCodexConversationInboundClaim(event, ctx, {
|
||||
api.on("inbound_claim", async (event, ctx) => {
|
||||
const { handleCodexConversationInboundClaim } = await import("./src/conversation-binding.js");
|
||||
return await handleCodexConversationInboundClaim(event, ctx, {
|
||||
bindingStore,
|
||||
pluginConfig: resolveCurrentPluginConfig(),
|
||||
config: resolveCurrentConfig(),
|
||||
resumeCodexCliSessionOnNode: (params) =>
|
||||
resumeCodexCliSessionOnNode({ runtime: api.runtime, ...params }),
|
||||
}),
|
||||
);
|
||||
api.onConversationBindingResolved?.(handleCodexConversationBindingResolved);
|
||||
resumeCodexCliSessionOnNode: async (params) =>
|
||||
await (
|
||||
await loadNodeCliSessions()
|
||||
).resumeCodexCliSessionOnNode({
|
||||
runtime: api.runtime,
|
||||
...params,
|
||||
}),
|
||||
});
|
||||
});
|
||||
api.on("after_compaction", async (event, ctx) => {
|
||||
const previousSessionId = event.previousSessionId?.trim();
|
||||
const sessionId = ctx.sessionId?.trim();
|
||||
if (!previousSessionId || !sessionId || previousSessionId === sessionId) {
|
||||
return;
|
||||
}
|
||||
const config = resolveCurrentConfig();
|
||||
const sessionKey = ctx.sessionKey?.trim();
|
||||
const { sessionBindingIdentity } = await import("./src/app-server/session-binding.js");
|
||||
const identity = sessionBindingIdentity({
|
||||
sessionId,
|
||||
...(sessionKey ? { sessionKey } : {}),
|
||||
...(ctx.agentId ? { agentId: ctx.agentId } : {}),
|
||||
...(config ? { config } : {}),
|
||||
});
|
||||
const adopted = await bindingStore.adoptSessionGeneration(identity, previousSessionId);
|
||||
if (adopted === "conflict") {
|
||||
api.logger.warn?.(
|
||||
`codex: could not adopt compacted session generation ${sessionId} (${adopted}); secondary native compaction will skip`,
|
||||
);
|
||||
}
|
||||
});
|
||||
api.on("session_end", async (event, ctx) => {
|
||||
if (!event.reason || !ENDED_SESSION_REASONS.has(event.reason)) {
|
||||
return;
|
||||
}
|
||||
const sessionKey = event.sessionKey ?? ctx.sessionKey;
|
||||
const config = resolveCurrentConfig();
|
||||
const { sessionBindingIdentity } = await import("./src/app-server/session-binding.js");
|
||||
await bindingStore.retireSessionGeneration(
|
||||
sessionBindingIdentity({
|
||||
sessionId: event.sessionId,
|
||||
...(sessionKey ? { sessionKey } : {}),
|
||||
...(ctx.agentId ? { agentId: ctx.agentId } : {}),
|
||||
...(config ? { config } : {}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -2,8 +2,25 @@
|
||||
import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { buildCodexMediaUnderstandingProvider } from "./media-understanding-provider.js";
|
||||
import type { CodexAppServerClient } from "./src/app-server/client.js";
|
||||
import { CodexAppServerRpcError, type CodexAppServerClient } from "./src/app-server/client.js";
|
||||
import type { CodexServerNotification, JsonValue } from "./src/app-server/protocol.js";
|
||||
import { adaptCodexTestClientFactory } from "./src/app-server/test-support.js";
|
||||
|
||||
const EXPECTED_MEDIA_THREAD_CONFIG = {
|
||||
project_doc_max_bytes: 0,
|
||||
web_search: "disabled",
|
||||
"tools.experimental_request_user_input.enabled": false,
|
||||
"features.hooks": false,
|
||||
"features.multi_agent": false,
|
||||
"features.apps": false,
|
||||
"features.plugins": false,
|
||||
"features.image_generation": false,
|
||||
"features.skill_mcp_dependency_install": false,
|
||||
"features.memories": false,
|
||||
"features.goals": false,
|
||||
"features.code_mode": false,
|
||||
"features.code_mode_only": false,
|
||||
};
|
||||
|
||||
const sharedClientMocks = vi.hoisted(() => ({
|
||||
createIsolatedCodexAppServerClient: vi.fn(),
|
||||
@@ -85,13 +102,15 @@ function createFakeClient(options?: {
|
||||
inputModalities?: string[];
|
||||
completeWithItems?: boolean;
|
||||
notifyError?: string;
|
||||
approvalRequestMethod?: string;
|
||||
responseText?: string;
|
||||
turnStartError?: Error;
|
||||
preBindNotificationCount?: number;
|
||||
interruptError?: Error;
|
||||
unsubscribeError?: Error;
|
||||
}) {
|
||||
const notifications = new Set<(notification: CodexServerNotification) => void>();
|
||||
const requestHandlers = new Set<(request: { method: string }) => JsonValue | undefined>();
|
||||
const closeHandlers = new Set<() => void>();
|
||||
const requests: Array<{ method: string; params?: JsonValue }> = [];
|
||||
const approvalResponses: JsonValue[] = [];
|
||||
const request = vi.fn(async (method: string, params?: JsonValue) => {
|
||||
requests.push({ method, params });
|
||||
if (method === "model/list") {
|
||||
@@ -104,51 +123,60 @@ function createFakeClient(options?: {
|
||||
return threadStartResult();
|
||||
}
|
||||
if (method === "turn/start") {
|
||||
if (options?.approvalRequestMethod) {
|
||||
for (const handler of requestHandlers) {
|
||||
const response = handler({ method: options.approvalRequestMethod });
|
||||
if (response !== undefined) {
|
||||
approvalResponses.push(response);
|
||||
if (options?.turnStartError) {
|
||||
throw options.turnStartError;
|
||||
}
|
||||
if (options?.preBindNotificationCount) {
|
||||
for (let index = 0; index < options.preBindNotificationCount; index += 1) {
|
||||
for (const notify of notifications) {
|
||||
notify({
|
||||
method: "item/started",
|
||||
params: { threadId: "thread-1", turnId: "turn-1" },
|
||||
});
|
||||
}
|
||||
}
|
||||
return turnStartResult();
|
||||
}
|
||||
if (options?.notifyError) {
|
||||
for (const notify of notifications) {
|
||||
notify({
|
||||
method: "error",
|
||||
params: {
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
error: {
|
||||
message: options.notifyError,
|
||||
codexErrorInfo: null,
|
||||
additionalDetails: null,
|
||||
const emitTurnNotifications = () => {
|
||||
if (options?.notifyError) {
|
||||
for (const notify of notifications) {
|
||||
notify({
|
||||
method: "error",
|
||||
params: {
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
error: {
|
||||
message: options.notifyError,
|
||||
codexErrorInfo: null,
|
||||
additionalDetails: null,
|
||||
},
|
||||
willRetry: false,
|
||||
},
|
||||
willRetry: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
} else if (!options?.completeWithItems) {
|
||||
for (const notify of notifications) {
|
||||
notify({
|
||||
method: "item/agentMessage/delta",
|
||||
params: {
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
itemId: "msg-1",
|
||||
delta: options?.responseText ?? "A red square.",
|
||||
},
|
||||
});
|
||||
notify({
|
||||
method: "turn/completed",
|
||||
params: {
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
turn: turnStartResult("completed").turn,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (!options?.completeWithItems) {
|
||||
for (const notify of notifications) {
|
||||
notify({
|
||||
method: "item/agentMessage/delta",
|
||||
params: {
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
itemId: "msg-1",
|
||||
delta: options?.responseText ?? "A red square.",
|
||||
},
|
||||
});
|
||||
notify({
|
||||
method: "turn/completed",
|
||||
params: {
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
turn: turnStartResult("completed").turn,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
emitTurnNotifications();
|
||||
return turnStartResult(
|
||||
options?.completeWithItems ? "completed" : "inProgress",
|
||||
options?.completeWithItems
|
||||
@@ -164,6 +192,12 @@ function createFakeClient(options?: {
|
||||
: [],
|
||||
);
|
||||
}
|
||||
if (method === "turn/interrupt" && options?.interruptError) {
|
||||
throw options.interruptError;
|
||||
}
|
||||
if (method === "thread/unsubscribe" && options?.unsubscribeError) {
|
||||
throw options.unsubscribeError;
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
@@ -173,14 +207,17 @@ function createFakeClient(options?: {
|
||||
notifications.add(handler);
|
||||
return () => notifications.delete(handler);
|
||||
},
|
||||
addRequestHandler(handler: (request: { method: string }) => JsonValue | undefined) {
|
||||
requestHandlers.add(handler);
|
||||
return () => requestHandlers.delete(handler);
|
||||
addRequestHandler() {
|
||||
return () => undefined;
|
||||
},
|
||||
addCloseHandler(handler: () => void) {
|
||||
closeHandlers.add(handler);
|
||||
return () => closeHandlers.delete(handler);
|
||||
},
|
||||
close: vi.fn(),
|
||||
} as unknown as CodexAppServerClient;
|
||||
|
||||
return { client, requests, approvalResponses };
|
||||
return { client, requests };
|
||||
}
|
||||
|
||||
describe("codex media understanding provider", () => {
|
||||
@@ -192,11 +229,9 @@ describe("codex media understanding provider", () => {
|
||||
|
||||
it("runs image understanding through a bounded Codex app-server turn", async () => {
|
||||
const { client, requests } = createFakeClient();
|
||||
const clientFactory = vi.fn(
|
||||
async (_startOptions, _authProfileId, _agentDir, _config) => client,
|
||||
);
|
||||
const clientFactory = vi.fn(async () => client);
|
||||
const provider = buildCodexMediaUnderstandingProvider({
|
||||
clientFactory,
|
||||
clientLeaseFactory: adaptCodexTestClientFactory(clientFactory),
|
||||
});
|
||||
const cfg = {
|
||||
auth: {
|
||||
@@ -219,35 +254,33 @@ describe("codex media understanding provider", () => {
|
||||
});
|
||||
|
||||
expect(result).toEqual({ text: "A red square.", model: "gpt-5.4" });
|
||||
expect(requests.map((entry) => entry.method)).toEqual([
|
||||
"model/list",
|
||||
"thread/start",
|
||||
"turn/start",
|
||||
]);
|
||||
expect(clientFactory).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
undefined,
|
||||
"/tmp/openclaw-agent",
|
||||
cfg,
|
||||
expect.objectContaining({ timeoutMs: 30_000 }),
|
||||
);
|
||||
expect(requests.map((entry) => entry.method)).toEqual([
|
||||
"model/list",
|
||||
"thread/start",
|
||||
"turn/start",
|
||||
"thread/unsubscribe",
|
||||
]);
|
||||
expect(requests[0]?.params).toEqual({ limit: 100, cursor: null, includeHidden: true });
|
||||
expect(requests[1]?.params).toEqual({
|
||||
model: "gpt-5.4",
|
||||
modelProvider: "openai",
|
||||
cwd: "/tmp/openclaw-agent",
|
||||
approvalPolicy: "on-request",
|
||||
cwd: "/tmp/openclaw-agent/codex-media-home",
|
||||
approvalPolicy: "never",
|
||||
sandbox: "read-only",
|
||||
serviceName: "OpenClaw",
|
||||
personality: "none",
|
||||
developerInstructions:
|
||||
"You are OpenClaw's bounded image-understanding worker. Describe only the provided image content. Do not call tools, edit files, or ask follow-up questions.",
|
||||
config: {
|
||||
"features.code_mode": false,
|
||||
"features.code_mode_only": false,
|
||||
},
|
||||
config: EXPECTED_MEDIA_THREAD_CONFIG,
|
||||
environments: [],
|
||||
dynamicTools: [],
|
||||
experimentalRawEvents: true,
|
||||
ephemeral: true,
|
||||
persistExtendedHistory: false,
|
||||
});
|
||||
expect(requests[2]?.params).toEqual({
|
||||
threadId: "thread-1",
|
||||
@@ -255,9 +288,6 @@ describe("codex media understanding provider", () => {
|
||||
{ type: "text", text: "Describe briefly.", text_elements: [] },
|
||||
{ type: "image", url: "data:image/png;base64,aW1hZ2UtYnl0ZXM=" },
|
||||
],
|
||||
cwd: "/tmp/openclaw-agent",
|
||||
approvalPolicy: "on-request",
|
||||
model: "gpt-5.4",
|
||||
effort: "low",
|
||||
});
|
||||
});
|
||||
@@ -265,8 +295,12 @@ describe("codex media understanding provider", () => {
|
||||
it("treats a blank agent directory as absent when starting the app-server", async () => {
|
||||
const { client, requests } = createFakeClient();
|
||||
const clientFactory = vi.fn(async () => client);
|
||||
const provider = buildCodexMediaUnderstandingProvider({ clientFactory });
|
||||
const cfg = {};
|
||||
const provider = buildCodexMediaUnderstandingProvider({
|
||||
clientLeaseFactory: adaptCodexTestClientFactory(clientFactory),
|
||||
});
|
||||
const cfg = {
|
||||
agents: { list: [{ id: "main", agentDir: "/tmp/openclaw-default-agent" }] },
|
||||
};
|
||||
|
||||
await provider.describeImage?.({
|
||||
buffer: Buffer.from("image-bytes"),
|
||||
@@ -279,9 +313,16 @@ describe("codex media understanding provider", () => {
|
||||
agentDir: " ",
|
||||
});
|
||||
|
||||
expect(clientFactory).toHaveBeenCalledWith(expect.any(Object), undefined, undefined, cfg);
|
||||
expect(requests[1]?.params).toEqual(expect.objectContaining({ cwd: process.cwd() }));
|
||||
expect(requests[2]?.params).toEqual(expect.objectContaining({ cwd: process.cwd() }));
|
||||
expect(clientFactory).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
undefined,
|
||||
"/tmp/openclaw-default-agent",
|
||||
cfg,
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(requests[1]?.params).toEqual(
|
||||
expect.objectContaining({ cwd: "/tmp/openclaw-default-agent/codex-media-home" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("passes the scoped auth store into isolated app-server startup", async () => {
|
||||
@@ -323,7 +364,7 @@ describe("codex media understanding provider", () => {
|
||||
try {
|
||||
const { client } = createFakeClient();
|
||||
const provider = buildCodexMediaUnderstandingProvider({
|
||||
clientFactory: async () => client,
|
||||
clientLeaseFactory: adaptCodexTestClientFactory(async () => client),
|
||||
});
|
||||
|
||||
const result = await provider.describeImage?.({
|
||||
@@ -346,33 +387,97 @@ describe("codex media understanding provider", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("declines approval requests during image understanding", async () => {
|
||||
const { client, approvalResponses } = createFakeClient({
|
||||
approvalRequestMethod: "item/permissions/requestApproval",
|
||||
});
|
||||
it("starts the media deadline before client acquisition", async () => {
|
||||
vi.useFakeTimers();
|
||||
const provider = buildCodexMediaUnderstandingProvider({
|
||||
clientFactory: async () => client,
|
||||
clientLeaseFactory: adaptCodexTestClientFactory(
|
||||
async () => await new Promise<CodexAppServerClient>(() => {}),
|
||||
),
|
||||
});
|
||||
|
||||
await provider.describeImage?.({
|
||||
const description = provider.describeImage?.({
|
||||
buffer: Buffer.from("image-bytes"),
|
||||
fileName: "image.png",
|
||||
mime: "image/png",
|
||||
provider: "codex",
|
||||
model: "gpt-5.4",
|
||||
prompt: "Describe briefly.",
|
||||
timeoutMs: 30_000,
|
||||
timeoutMs: 100,
|
||||
cfg: {},
|
||||
agentDir: "/tmp/openclaw-agent",
|
||||
});
|
||||
const rejected = expect(description).rejects.toThrow(
|
||||
"Codex app-server image understanding timed out",
|
||||
);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
await rejected;
|
||||
});
|
||||
|
||||
it("retires a media client lease that resolves after its deadline", async () => {
|
||||
let resolveLease!: (lease: {
|
||||
client: CodexAppServerClient;
|
||||
release: () => void;
|
||||
abandon: () => Promise<void>;
|
||||
}) => void;
|
||||
const pendingLease = new Promise<{
|
||||
client: CodexAppServerClient;
|
||||
release: () => void;
|
||||
abandon: () => Promise<void>;
|
||||
}>((resolve) => {
|
||||
resolveLease = resolve;
|
||||
});
|
||||
const clientLeaseFactory = vi.fn(async () => await pendingLease);
|
||||
const provider = buildCodexMediaUnderstandingProvider({ clientLeaseFactory });
|
||||
const description = provider.describeImage?.({
|
||||
buffer: Buffer.from("image-bytes"),
|
||||
fileName: "image.png",
|
||||
mime: "image/png",
|
||||
provider: "codex",
|
||||
model: "gpt-5.4",
|
||||
timeoutMs: 5,
|
||||
cfg: {},
|
||||
agentDir: "/tmp/openclaw-agent",
|
||||
});
|
||||
|
||||
expect(approvalResponses).toEqual([{ permissions: {}, scope: "turn" }]);
|
||||
await expect(description).rejects.toThrow("Codex app-server image understanding timed out");
|
||||
const { client } = createFakeClient();
|
||||
const release = vi.fn();
|
||||
const abandon = vi.fn(async () => undefined);
|
||||
resolveLease({ client, release, abandon });
|
||||
await vi.waitFor(() => expect(abandon).toHaveBeenCalledOnce());
|
||||
|
||||
expect(release).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("releases the bounded route between isolated media calls", async () => {
|
||||
const { client, requests } = createFakeClient();
|
||||
const provider = buildCodexMediaUnderstandingProvider({
|
||||
clientLeaseFactory: adaptCodexTestClientFactory(async () => client),
|
||||
});
|
||||
const request = {
|
||||
buffer: Buffer.from("image-bytes"),
|
||||
fileName: "image.png",
|
||||
mime: "image/png",
|
||||
provider: "codex",
|
||||
model: "gpt-5.4",
|
||||
timeoutMs: 30_000,
|
||||
cfg: {},
|
||||
agentDir: "/tmp/openclaw-agent",
|
||||
};
|
||||
|
||||
const first = await provider.describeImage?.(request);
|
||||
const second = await provider.describeImage?.(request);
|
||||
|
||||
expect(first?.text).toBe("A red square.");
|
||||
expect(second?.text).toBe("A red square.");
|
||||
expect(requests.filter((entry) => entry.method === "model/list")).toHaveLength(2);
|
||||
expect(requests.filter((entry) => entry.method === "thread/start")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("extracts text from terminal turn items", async () => {
|
||||
const { client } = createFakeClient({ completeWithItems: true });
|
||||
const provider = buildCodexMediaUnderstandingProvider({
|
||||
clientFactory: async () => client,
|
||||
clientLeaseFactory: adaptCodexTestClientFactory(async () => client),
|
||||
});
|
||||
|
||||
const result = await provider.describeImages?.({
|
||||
@@ -391,7 +496,7 @@ describe("codex media understanding provider", () => {
|
||||
it("rejects text-only Codex app-server models before starting a turn", async () => {
|
||||
const { client, requests } = createFakeClient({ inputModalities: ["text"] });
|
||||
const provider = buildCodexMediaUnderstandingProvider({
|
||||
clientFactory: async () => client,
|
||||
clientLeaseFactory: adaptCodexTestClientFactory(async () => client),
|
||||
});
|
||||
|
||||
await expect(
|
||||
@@ -412,7 +517,7 @@ describe("codex media understanding provider", () => {
|
||||
it("surfaces Codex app-server turn errors", async () => {
|
||||
const { client } = createFakeClient({ notifyError: "vision unavailable" });
|
||||
const provider = buildCodexMediaUnderstandingProvider({
|
||||
clientFactory: async () => client,
|
||||
clientLeaseFactory: adaptCodexTestClientFactory(async () => client),
|
||||
});
|
||||
|
||||
await expect(
|
||||
@@ -429,12 +534,107 @@ describe("codex media understanding provider", () => {
|
||||
).rejects.toThrow("vision unavailable");
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "structured rejection",
|
||||
error: new CodexAppServerRpcError({ message: "turn rejected" }, "turn/start"),
|
||||
abandonCount: 0,
|
||||
},
|
||||
{
|
||||
name: "ambiguous timeout",
|
||||
error: new Error("turn/start timed out"),
|
||||
abandonCount: 1,
|
||||
},
|
||||
])("handles $name with exact media lease ownership", async ({ error, abandonCount }) => {
|
||||
const { client } = createFakeClient({ turnStartError: error });
|
||||
const release = vi.fn();
|
||||
const abandon = vi.fn(async () => undefined);
|
||||
const provider = buildCodexMediaUnderstandingProvider({
|
||||
clientLeaseFactory: async () => ({ client, release, abandon }),
|
||||
});
|
||||
|
||||
await expect(
|
||||
provider.describeImage?.({
|
||||
buffer: Buffer.from("image-bytes"),
|
||||
fileName: "image.png",
|
||||
mime: "image/png",
|
||||
provider: "codex",
|
||||
model: "gpt-5.4",
|
||||
timeoutMs: 30_000,
|
||||
cfg: {},
|
||||
agentDir: "/tmp/openclaw-agent",
|
||||
}),
|
||||
).rejects.toBe(error);
|
||||
|
||||
expect(abandon).toHaveBeenCalledTimes(abandonCount);
|
||||
expect(release).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("retires the media client when thread cleanup is unconfirmed", async () => {
|
||||
const { client } = createFakeClient({ unsubscribeError: new Error("unsubscribe failed") });
|
||||
const release = vi.fn();
|
||||
const abandon = vi.fn(async () => undefined);
|
||||
const provider = buildCodexMediaUnderstandingProvider({
|
||||
clientLeaseFactory: async () => ({ client, release, abandon }),
|
||||
});
|
||||
|
||||
await expect(
|
||||
provider.describeImage?.({
|
||||
buffer: Buffer.from("image-bytes"),
|
||||
fileName: "image.png",
|
||||
mime: "image/png",
|
||||
provider: "codex",
|
||||
model: "gpt-5.4",
|
||||
timeoutMs: 30_000,
|
||||
cfg: {},
|
||||
agentDir: "/tmp/openclaw-agent",
|
||||
}),
|
||||
).resolves.toEqual({ text: "A red square.", model: "gpt-5.4" });
|
||||
|
||||
expect(abandon).toHaveBeenCalledOnce();
|
||||
expect(release).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("retires the media client when an accepted turn cannot be interrupted", async () => {
|
||||
const { client, requests } = createFakeClient({
|
||||
preBindNotificationCount: 257,
|
||||
interruptError: new Error("interrupt timeout"),
|
||||
});
|
||||
const release = vi.fn();
|
||||
const abandon = vi.fn(async () => undefined);
|
||||
const provider = buildCodexMediaUnderstandingProvider({
|
||||
clientLeaseFactory: async () => ({ client, release, abandon }),
|
||||
});
|
||||
|
||||
await expect(
|
||||
provider.describeImage?.({
|
||||
buffer: Buffer.from("image-bytes"),
|
||||
fileName: "image.png",
|
||||
mime: "image/png",
|
||||
provider: "codex",
|
||||
model: "gpt-5.4",
|
||||
timeoutMs: 30_000,
|
||||
cfg: {},
|
||||
agentDir: "/tmp/openclaw-agent",
|
||||
}),
|
||||
).rejects.toThrow("pre-bind notification buffer exceeded 256 entries");
|
||||
|
||||
expect(requests.map((entry) => entry.method)).toEqual([
|
||||
"model/list",
|
||||
"thread/start",
|
||||
"turn/start",
|
||||
"turn/interrupt",
|
||||
]);
|
||||
expect(abandon).toHaveBeenCalledOnce();
|
||||
expect(release).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("runs structured extraction through the same bounded Codex app-server path", async () => {
|
||||
const { client, requests } = createFakeClient({
|
||||
responseText: '{"summary":"red square","tags":["shape"]}',
|
||||
});
|
||||
const provider = buildCodexMediaUnderstandingProvider({
|
||||
clientFactory: async () => client,
|
||||
clientLeaseFactory: adaptCodexTestClientFactory(async () => client),
|
||||
});
|
||||
|
||||
const result = await provider.extractStructured?.({
|
||||
@@ -475,25 +675,21 @@ describe("codex media understanding provider", () => {
|
||||
"model/list",
|
||||
"thread/start",
|
||||
"turn/start",
|
||||
"thread/unsubscribe",
|
||||
]);
|
||||
expect(requests[1]?.params).toEqual({
|
||||
model: "gpt-5.4",
|
||||
modelProvider: "openai",
|
||||
cwd: "/tmp/openclaw-agent",
|
||||
approvalPolicy: "on-request",
|
||||
cwd: "/tmp/openclaw-agent/codex-media-home",
|
||||
approvalPolicy: "never",
|
||||
sandbox: "read-only",
|
||||
serviceName: "OpenClaw",
|
||||
personality: "none",
|
||||
developerInstructions:
|
||||
"You are OpenClaw's bounded structured-extraction worker. Return only the requested extraction. Do not call tools, edit files, ask follow-up questions, or include secrets.",
|
||||
config: {
|
||||
"features.code_mode": false,
|
||||
"features.code_mode_only": false,
|
||||
},
|
||||
config: EXPECTED_MEDIA_THREAD_CONFIG,
|
||||
environments: [],
|
||||
dynamicTools: [],
|
||||
experimentalRawEvents: true,
|
||||
ephemeral: true,
|
||||
persistExtendedHistory: false,
|
||||
});
|
||||
const turnParams = requests[2]?.params as
|
||||
| {
|
||||
@@ -506,9 +702,9 @@ describe("codex media understanding provider", () => {
|
||||
}
|
||||
| undefined;
|
||||
expect(turnParams?.threadId).toBe("thread-1");
|
||||
expect(turnParams?.approvalPolicy).toBe("on-request");
|
||||
expect(turnParams?.model).toBe("gpt-5.4");
|
||||
expect(turnParams?.cwd).toBe("/tmp/openclaw-agent");
|
||||
expect(turnParams?.approvalPolicy).toBeUndefined();
|
||||
expect(turnParams?.model).toBeUndefined();
|
||||
expect(turnParams?.cwd).toBeUndefined();
|
||||
expect(turnParams?.effort).toBe("low");
|
||||
expect(turnParams?.input).toHaveLength(3);
|
||||
expect(turnParams?.input?.[0]?.type).toBe("text");
|
||||
@@ -531,7 +727,7 @@ describe("codex media understanding provider", () => {
|
||||
responseText: '{"summary":"only text"}',
|
||||
});
|
||||
const provider = buildCodexMediaUnderstandingProvider({
|
||||
clientFactory: async () => client,
|
||||
clientLeaseFactory: adaptCodexTestClientFactory(async () => client),
|
||||
});
|
||||
|
||||
await expect(
|
||||
@@ -551,7 +747,7 @@ describe("codex media understanding provider", () => {
|
||||
it("returns a controlled error when structured JSON parsing fails", async () => {
|
||||
const { client } = createFakeClient({ responseText: "not json" });
|
||||
const provider = buildCodexMediaUnderstandingProvider({
|
||||
clientFactory: async () => client,
|
||||
clientLeaseFactory: adaptCodexTestClientFactory(async () => client),
|
||||
});
|
||||
|
||||
await expect(
|
||||
@@ -580,7 +776,7 @@ describe("codex media understanding provider", () => {
|
||||
responseText: '{"summary":123,"tags":["shape"]}',
|
||||
});
|
||||
const provider = buildCodexMediaUnderstandingProvider({
|
||||
clientFactory: async () => client,
|
||||
clientLeaseFactory: adaptCodexTestClientFactory(async () => client),
|
||||
});
|
||||
|
||||
await expect(
|
||||
|
||||
@@ -1,549 +1,35 @@
|
||||
/**
|
||||
* Codex-backed media understanding provider for bounded image description and
|
||||
* structured extraction turns.
|
||||
*/
|
||||
import {
|
||||
type JsonSchemaObject,
|
||||
validateJsonSchemaValue,
|
||||
} from "openclaw/plugin-sdk/json-schema-runtime";
|
||||
import type {
|
||||
ImagesDescriptionRequest,
|
||||
ImagesDescriptionResult,
|
||||
MediaUnderstandingProvider,
|
||||
StructuredExtractionRequest,
|
||||
StructuredExtractionResult,
|
||||
} from "openclaw/plugin-sdk/media-understanding";
|
||||
import { resolveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime";
|
||||
/** Lazy registration facade for Codex-backed media understanding. */
|
||||
import type { MediaUnderstandingProvider } from "openclaw/plugin-sdk/media-understanding";
|
||||
import { CODEX_PROVIDER_ID, FALLBACK_CODEX_MODELS } from "./provider-catalog.js";
|
||||
import type { CodexAppServerClientFactory } from "./src/app-server/client-factory.js";
|
||||
import type { CodexAppServerClient } from "./src/app-server/client.js";
|
||||
import { resolveCodexAppServerRuntimeOptions } from "./src/app-server/config.js";
|
||||
import { readModelListResult } from "./src/app-server/models.js";
|
||||
import {
|
||||
assertCodexThreadStartResponse,
|
||||
assertCodexTurnStartResponse,
|
||||
readCodexErrorNotification,
|
||||
readCodexTurnCompletedNotification,
|
||||
} from "./src/app-server/protocol-validators.js";
|
||||
import {
|
||||
isJsonObject,
|
||||
type CodexServerNotification,
|
||||
type CodexThreadItem,
|
||||
type CodexThreadStartParams,
|
||||
type CodexTurn,
|
||||
type CodexTurnStartParams,
|
||||
type CodexUserInput,
|
||||
type JsonObject,
|
||||
type JsonValue,
|
||||
} from "./src/app-server/protocol.js";
|
||||
import { buildCodexRuntimeThreadConfig } from "./src/app-server/thread-lifecycle.js";
|
||||
import type { CodexAppServerClientLeaseFactory } from "./src/app-server/shared-client.js";
|
||||
|
||||
const DEFAULT_CODEX_IMAGE_MODEL =
|
||||
FALLBACK_CODEX_MODELS.find((model) => model.inputModalities.includes("image"))?.id ??
|
||||
FALLBACK_CODEX_MODELS[0]?.id;
|
||||
const DEFAULT_CODEX_IMAGE_PROMPT = "Describe the image.";
|
||||
|
||||
/** Dependencies and plugin config for Codex media-understanding calls. */
|
||||
export type CodexMediaUnderstandingProviderOptions = {
|
||||
pluginConfig?: unknown;
|
||||
clientFactory?: CodexAppServerClientFactory;
|
||||
resolvePluginConfig?: () => unknown;
|
||||
clientLeaseFactory?: CodexAppServerClientLeaseFactory;
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds the media-understanding provider that delegates image tasks to an
|
||||
* isolated Codex app-server session.
|
||||
*/
|
||||
/** Builds a provider whose app-server implementation loads on first use. */
|
||||
export function buildCodexMediaUnderstandingProvider(
|
||||
options: CodexMediaUnderstandingProviderOptions = {},
|
||||
): MediaUnderstandingProvider {
|
||||
let runtime: Promise<typeof import("./src/media-understanding-provider.runtime.js")> | undefined;
|
||||
const load = () => (runtime ??= import("./src/media-understanding-provider.runtime.js"));
|
||||
return {
|
||||
id: CODEX_PROVIDER_ID,
|
||||
capabilities: ["image"],
|
||||
...(DEFAULT_CODEX_IMAGE_MODEL ? { defaultModels: { image: DEFAULT_CODEX_IMAGE_MODEL } } : {}),
|
||||
describeImage: async (req) =>
|
||||
describeCodexImages(
|
||||
{
|
||||
images: [
|
||||
{
|
||||
buffer: req.buffer,
|
||||
fileName: req.fileName,
|
||||
mime: req.mime,
|
||||
},
|
||||
],
|
||||
provider: req.provider,
|
||||
model: req.model,
|
||||
prompt: req.prompt,
|
||||
maxTokens: req.maxTokens,
|
||||
timeoutMs: req.timeoutMs,
|
||||
profile: req.profile,
|
||||
preferredProfile: req.preferredProfile,
|
||||
authStore: req.authStore,
|
||||
agentDir: req.agentDir,
|
||||
cfg: req.cfg,
|
||||
},
|
||||
options,
|
||||
),
|
||||
describeImages: async (req) => describeCodexImages(req, options),
|
||||
extractStructured: async (req) => extractCodexStructured(req, options),
|
||||
describeImage: async ({ buffer, fileName, mime, ...request }) =>
|
||||
await (
|
||||
await load()
|
||||
).describeCodexImages({ ...request, images: [{ buffer, fileName, mime }] }, options),
|
||||
describeImages: async (request) => await (await load()).describeCodexImages(request, options),
|
||||
extractStructured: async (request) =>
|
||||
await (await load()).extractCodexStructured(request, options),
|
||||
};
|
||||
}
|
||||
|
||||
async function describeCodexImages(
|
||||
req: ImagesDescriptionRequest,
|
||||
options: CodexMediaUnderstandingProviderOptions,
|
||||
): Promise<ImagesDescriptionResult> {
|
||||
const model = req.model.trim();
|
||||
if (!model) {
|
||||
throw new Error("Codex image understanding requires model id.");
|
||||
}
|
||||
|
||||
const text = await runBoundedCodexVisionTurn({
|
||||
model,
|
||||
profile: req.profile,
|
||||
timeoutMs: req.timeoutMs,
|
||||
agentDir: req.agentDir,
|
||||
authStore: req.authStore,
|
||||
cfg: req.cfg,
|
||||
options,
|
||||
taskLabel: "image understanding",
|
||||
developerInstructions:
|
||||
"You are OpenClaw's bounded image-understanding worker. Describe only the provided image content. Do not call tools, edit files, or ask follow-up questions.",
|
||||
input: [
|
||||
{ type: "text", text: buildCodexImagePrompt(req), text_elements: [] },
|
||||
...req.images.map((image) => ({
|
||||
type: "image" as const,
|
||||
url: `data:${image.mime ?? "image/png"};base64,${image.buffer.toString("base64")}`,
|
||||
})),
|
||||
],
|
||||
requiredModalities: ["text", "image"],
|
||||
});
|
||||
return { text, model };
|
||||
}
|
||||
|
||||
type BoundedCodexVisionTurnParams = {
|
||||
model: string;
|
||||
profile?: string;
|
||||
timeoutMs: number;
|
||||
agentDir?: string;
|
||||
authStore?: ImagesDescriptionRequest["authStore"];
|
||||
cfg: ImagesDescriptionRequest["cfg"];
|
||||
options: CodexMediaUnderstandingProviderOptions;
|
||||
taskLabel: string;
|
||||
developerInstructions: string;
|
||||
input: CodexUserInput[];
|
||||
requiredModalities: string[];
|
||||
};
|
||||
|
||||
async function runBoundedCodexVisionTurn(params: BoundedCodexVisionTurnParams): Promise<string> {
|
||||
const appServer = resolveCodexAppServerRuntimeOptions({
|
||||
pluginConfig: params.options.pluginConfig,
|
||||
});
|
||||
const timeoutMs = resolveTimerTimeoutMs(params.timeoutMs, 100, 100);
|
||||
const agentDir = params.agentDir?.trim() || undefined;
|
||||
const cwd = agentDir ?? process.cwd();
|
||||
const ownsClient = !params.options.clientFactory;
|
||||
// Tests inject a client factory; production creates an isolated app-server
|
||||
// client so media tasks cannot reuse the interactive attempt session.
|
||||
const client = params.options.clientFactory
|
||||
? await params.options.clientFactory(appServer.start, params.profile, agentDir, params.cfg)
|
||||
: await import("./src/app-server/shared-client.js").then(
|
||||
({ createIsolatedCodexAppServerClient }) =>
|
||||
createIsolatedCodexAppServerClient({
|
||||
startOptions: appServer.start,
|
||||
timeoutMs,
|
||||
authProfileId: params.profile,
|
||||
agentDir,
|
||||
authProfileStore: params.authStore,
|
||||
config: params.cfg,
|
||||
}),
|
||||
);
|
||||
const abortController = new AbortController();
|
||||
const timeout = setTimeout(() => abortController.abort("timeout"), timeoutMs);
|
||||
timeout.unref?.();
|
||||
|
||||
try {
|
||||
await assertCodexModelSupportsInput({
|
||||
client,
|
||||
model: params.model,
|
||||
requiredModalities: params.requiredModalities,
|
||||
timeoutMs,
|
||||
signal: abortController.signal,
|
||||
});
|
||||
const thread = assertCodexThreadStartResponse(
|
||||
await client.request<unknown>(
|
||||
"thread/start",
|
||||
{
|
||||
model: params.model,
|
||||
modelProvider: "openai",
|
||||
cwd,
|
||||
approvalPolicy: "on-request",
|
||||
sandbox: "read-only",
|
||||
serviceName: "OpenClaw",
|
||||
developerInstructions: params.developerInstructions,
|
||||
// Media workers are bounded read-only turns; native code mode and
|
||||
// dynamic tools stay disabled to avoid side effects while inspecting media.
|
||||
config: buildCodexRuntimeThreadConfig(undefined, { nativeCodeModeEnabled: false }),
|
||||
environments: [],
|
||||
dynamicTools: [],
|
||||
experimentalRawEvents: true,
|
||||
persistExtendedHistory: false,
|
||||
ephemeral: true,
|
||||
} satisfies CodexThreadStartParams,
|
||||
{ timeoutMs, signal: abortController.signal },
|
||||
),
|
||||
);
|
||||
const collector = createCodexTurnCollector(thread.thread.id, params.taskLabel);
|
||||
const cleanup = client.addNotificationHandler(collector.handleNotification);
|
||||
const requestCleanup = client.addRequestHandler(denyCodexImageApprovalRequest);
|
||||
try {
|
||||
const turn = assertCodexTurnStartResponse(
|
||||
await client.request<unknown>(
|
||||
"turn/start",
|
||||
{
|
||||
threadId: thread.thread.id,
|
||||
input: params.input,
|
||||
cwd,
|
||||
approvalPolicy: "on-request",
|
||||
model: params.model,
|
||||
effort: "low",
|
||||
} satisfies CodexTurnStartParams,
|
||||
{ timeoutMs, signal: abortController.signal },
|
||||
),
|
||||
);
|
||||
const text = await collector.collect(turn.turn, {
|
||||
timeoutMs,
|
||||
signal: abortController.signal,
|
||||
});
|
||||
return text;
|
||||
} finally {
|
||||
requestCleanup();
|
||||
cleanup();
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
if (ownsClient) {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function extractCodexStructured(
|
||||
req: StructuredExtractionRequest,
|
||||
options: CodexMediaUnderstandingProviderOptions,
|
||||
): Promise<StructuredExtractionResult> {
|
||||
const model = req.model.trim();
|
||||
if (!model) {
|
||||
throw new Error("Codex structured extraction requires model id.");
|
||||
}
|
||||
const instructions = req.instructions.trim();
|
||||
if (!instructions) {
|
||||
throw new Error("Codex structured extraction requires instructions.");
|
||||
}
|
||||
if (req.input.length === 0) {
|
||||
throw new Error("Codex structured extraction requires at least one input.");
|
||||
}
|
||||
if (!req.input.some((entry) => entry.type === "image")) {
|
||||
throw new Error("Codex structured extraction requires at least one image input.");
|
||||
}
|
||||
|
||||
const text = await runBoundedCodexVisionTurn({
|
||||
model,
|
||||
profile: req.profile,
|
||||
timeoutMs: req.timeoutMs,
|
||||
agentDir: req.agentDir,
|
||||
authStore: req.authStore,
|
||||
cfg: req.cfg,
|
||||
options,
|
||||
taskLabel: "structured extraction",
|
||||
developerInstructions:
|
||||
"You are OpenClaw's bounded structured-extraction worker. Return only the requested extraction. Do not call tools, edit files, ask follow-up questions, or include secrets.",
|
||||
input: buildCodexStructuredInput(req),
|
||||
requiredModalities: requiredStructuredModalities(),
|
||||
});
|
||||
return normalizeStructuredExtractionResult({ text, model, provider: req.provider, req });
|
||||
}
|
||||
|
||||
function denyCodexImageApprovalRequest(request: { method: string }): JsonValue | undefined {
|
||||
if (
|
||||
request.method === "item/commandExecution/requestApproval" ||
|
||||
request.method === "item/fileChange/requestApproval"
|
||||
) {
|
||||
return {
|
||||
decision: "decline",
|
||||
reason: "OpenClaw Codex image understanding does not grant tool or file approvals.",
|
||||
};
|
||||
}
|
||||
if (request.method === "item/permissions/requestApproval") {
|
||||
return { permissions: {}, scope: "turn" };
|
||||
}
|
||||
if (request.method.includes("requestApproval")) {
|
||||
return {
|
||||
decision: "decline",
|
||||
reason: "OpenClaw Codex image understanding does not grant native approvals.",
|
||||
};
|
||||
}
|
||||
if (request.method === "mcpServer/elicitation/request") {
|
||||
return { action: "decline" };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function assertCodexModelSupportsInput(params: {
|
||||
client: CodexAppServerClient;
|
||||
model: string;
|
||||
requiredModalities: string[];
|
||||
timeoutMs: number;
|
||||
signal: AbortSignal;
|
||||
}): Promise<void> {
|
||||
const result = await params.client.request<unknown>(
|
||||
"model/list",
|
||||
{ limit: 100, cursor: null, includeHidden: false },
|
||||
{ timeoutMs: Math.min(params.timeoutMs, 5_000), signal: params.signal },
|
||||
);
|
||||
const listed = readModelListResult(result).models;
|
||||
const match = listed.find((entry) => entry.model === params.model || entry.id === params.model);
|
||||
if (!match) {
|
||||
throw new Error(`Codex app-server model not found: ${params.model}`);
|
||||
}
|
||||
if (params.requiredModalities.includes("image") && !match.inputModalities.includes("image")) {
|
||||
throw new Error(`Codex app-server model does not support images: ${params.model}`);
|
||||
}
|
||||
if (params.requiredModalities.includes("text") && !match.inputModalities.includes("text")) {
|
||||
throw new Error(`Codex app-server model does not support text: ${params.model}`);
|
||||
}
|
||||
}
|
||||
|
||||
function buildCodexImagePrompt(req: ImagesDescriptionRequest): string {
|
||||
const prompt = req.prompt?.trim() || DEFAULT_CODEX_IMAGE_PROMPT;
|
||||
if (req.images.length <= 1) {
|
||||
return prompt;
|
||||
}
|
||||
return `${prompt}\n\nAnalyze all ${req.images.length} images together.`;
|
||||
}
|
||||
|
||||
function requiredStructuredModalities(): string[] {
|
||||
return ["text", "image"];
|
||||
}
|
||||
|
||||
function buildCodexStructuredInput(req: StructuredExtractionRequest): CodexUserInput[] {
|
||||
return [
|
||||
{ type: "text", text: buildStructuredExtractionPrompt(req), text_elements: [] },
|
||||
...req.input.map((entry) => {
|
||||
if (entry.type === "text") {
|
||||
return { type: "text" as const, text: entry.text, text_elements: [] };
|
||||
}
|
||||
return {
|
||||
type: "image" as const,
|
||||
url: `data:${entry.mime ?? "image/png"};base64,${entry.buffer.toString("base64")}`,
|
||||
};
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
function buildStructuredExtractionPrompt(req: StructuredExtractionRequest): string {
|
||||
return [
|
||||
req.instructions.trim(),
|
||||
req.schemaName ? `Schema name: ${req.schemaName}` : undefined,
|
||||
req.jsonSchema ? `JSON schema:\n${JSON.stringify(req.jsonSchema)}` : undefined,
|
||||
req.jsonMode === false
|
||||
? "Return the extraction as concise text."
|
||||
: "Return valid JSON only. Do not wrap the JSON in Markdown fences.",
|
||||
]
|
||||
.filter((part): part is string => Boolean(part))
|
||||
.join("\n\n");
|
||||
}
|
||||
|
||||
function isJsonSchemaObject(value: unknown): value is JsonSchemaObject {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function normalizeStructuredExtractionResult(params: {
|
||||
text: string;
|
||||
model: string;
|
||||
provider: string;
|
||||
req: StructuredExtractionRequest;
|
||||
}): StructuredExtractionResult {
|
||||
const result: StructuredExtractionResult = {
|
||||
text: params.text,
|
||||
model: params.model,
|
||||
provider: params.provider,
|
||||
contentType: params.req.jsonMode === false ? "text" : "json",
|
||||
};
|
||||
if (params.req.jsonMode !== false) {
|
||||
try {
|
||||
result.parsed = JSON.parse(params.text);
|
||||
} catch {
|
||||
throw new Error("Codex structured extraction returned invalid JSON.");
|
||||
}
|
||||
if (isJsonSchemaObject(params.req.jsonSchema)) {
|
||||
const validation = validateJsonSchemaValue({
|
||||
schema: params.req.jsonSchema,
|
||||
cacheKey: "codex.media-understanding.extractStructured",
|
||||
value: result.parsed,
|
||||
cache: false,
|
||||
});
|
||||
if (!validation.ok) {
|
||||
const message = validation.errors.map((error) => error.text).join("; ") || "invalid";
|
||||
throw new Error(`Codex structured extraction JSON did not match schema: ${message}`);
|
||||
}
|
||||
result.parsed = validation.value;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function createCodexTurnCollector(threadId: string, taskLabel: string) {
|
||||
let turnId: string | undefined;
|
||||
let completedTurn: CodexTurn | undefined;
|
||||
let promptError: string | undefined;
|
||||
const pending: CodexServerNotification[] = [];
|
||||
const assistantTextByItem = new Map<string, string>();
|
||||
const assistantItemOrder: string[] = [];
|
||||
let resolveCompletion: (() => void) | undefined;
|
||||
const completion = new Promise<void>((resolve) => {
|
||||
resolveCompletion = resolve;
|
||||
});
|
||||
|
||||
const rememberAssistantText = (itemId: string, text: string) => {
|
||||
if (!text) {
|
||||
return;
|
||||
}
|
||||
if (!assistantTextByItem.has(itemId)) {
|
||||
assistantItemOrder.push(itemId);
|
||||
}
|
||||
assistantTextByItem.set(itemId, text);
|
||||
};
|
||||
|
||||
const handleNotification = (notification: CodexServerNotification): void => {
|
||||
const params = isJsonObject(notification.params) ? notification.params : undefined;
|
||||
if (!params || readString(params, "threadId") !== threadId) {
|
||||
return;
|
||||
}
|
||||
if (!turnId) {
|
||||
pending.push(notification);
|
||||
return;
|
||||
}
|
||||
const notificationTurnId = readNotificationTurnId(params);
|
||||
if (notificationTurnId !== turnId) {
|
||||
return;
|
||||
}
|
||||
if (notification.method === "item/agentMessage/delta") {
|
||||
const itemId = readString(params, "itemId") ?? readString(params, "id") ?? "assistant";
|
||||
const delta = readString(params, "delta") ?? "";
|
||||
rememberAssistantText(itemId, `${assistantTextByItem.get(itemId) ?? ""}${delta}`);
|
||||
return;
|
||||
}
|
||||
if (notification.method === "turn/completed") {
|
||||
completedTurn =
|
||||
readCodexTurnCompletedNotification(notification.params)?.turn ?? completedTurn;
|
||||
resolveCompletion?.();
|
||||
return;
|
||||
}
|
||||
if (notification.method === "error") {
|
||||
promptError =
|
||||
readCodexErrorNotification(notification.params)?.error.message ??
|
||||
`codex app-server ${taskLabel} turn failed`;
|
||||
resolveCompletion?.();
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
handleNotification,
|
||||
async collect(
|
||||
startedTurn: CodexTurn,
|
||||
options: { timeoutMs: number; signal: AbortSignal },
|
||||
): Promise<string> {
|
||||
turnId = startedTurn.id;
|
||||
if (isTerminalTurn(startedTurn)) {
|
||||
completedTurn = startedTurn;
|
||||
}
|
||||
for (const notification of pending.splice(0)) {
|
||||
handleNotification(notification);
|
||||
}
|
||||
if (!completedTurn && !promptError) {
|
||||
await waitForTurnCompletion({
|
||||
completion,
|
||||
timeoutMs: options.timeoutMs,
|
||||
signal: options.signal,
|
||||
taskLabel,
|
||||
});
|
||||
}
|
||||
if (promptError) {
|
||||
throw new Error(promptError);
|
||||
}
|
||||
if (completedTurn?.status === "failed") {
|
||||
throw new Error(
|
||||
completedTurn.error?.message ?? `codex app-server ${taskLabel} turn failed`,
|
||||
);
|
||||
}
|
||||
const itemText = collectAssistantTextFromItems(completedTurn?.items);
|
||||
const deltaText = assistantItemOrder
|
||||
.map((itemId) => assistantTextByItem.get(itemId)?.trim())
|
||||
.filter((text): text is string => Boolean(text))
|
||||
.join("\n\n")
|
||||
.trim();
|
||||
const text = (itemText || deltaText).trim();
|
||||
if (!text) {
|
||||
throw new Error(`Codex app-server ${taskLabel} turn returned no text.`);
|
||||
}
|
||||
return text;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function waitForTurnCompletion(params: {
|
||||
completion: Promise<void>;
|
||||
timeoutMs: number;
|
||||
signal: AbortSignal;
|
||||
taskLabel: string;
|
||||
}): Promise<void> {
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
let cleanupAbort: (() => void) | undefined;
|
||||
try {
|
||||
await Promise.race([
|
||||
params.completion,
|
||||
new Promise<never>((_, reject) => {
|
||||
timeout = setTimeout(
|
||||
() => reject(new Error(`codex app-server ${params.taskLabel} turn timed out`)),
|
||||
params.timeoutMs,
|
||||
);
|
||||
timeout.unref?.();
|
||||
const abortListener = () =>
|
||||
reject(new Error(`codex app-server ${params.taskLabel} turn aborted`));
|
||||
params.signal.addEventListener("abort", abortListener, { once: true });
|
||||
cleanupAbort = () => params.signal.removeEventListener("abort", abortListener);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
cleanupAbort?.();
|
||||
}
|
||||
}
|
||||
|
||||
function collectAssistantTextFromItems(items: CodexThreadItem[] | undefined): string {
|
||||
return (items ?? [])
|
||||
.filter((item) => item.type === "agentMessage")
|
||||
.map((item) => item.text.trim())
|
||||
.filter(Boolean)
|
||||
.join("\n\n")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function readNotificationTurnId(record: JsonObject): string | undefined {
|
||||
const direct = readString(record, "turnId");
|
||||
if (direct) {
|
||||
return direct;
|
||||
}
|
||||
return isJsonObject(record.turn) ? readString(record.turn, "id") : undefined;
|
||||
}
|
||||
|
||||
function readString(record: JsonObject, key: string): string | undefined {
|
||||
const value = record[key];
|
||||
return typeof value === "string" ? value : undefined;
|
||||
}
|
||||
|
||||
function isTerminalTurn(turn: CodexTurn): boolean {
|
||||
return turn.status === "completed" || turn.status === "interrupted" || turn.status === "failed";
|
||||
}
|
||||
|
||||
@@ -4,10 +4,10 @@ import { CODEX_GPT5_BEHAVIOR_CONTRACT } from "./prompt-overlay.js";
|
||||
import { codexProviderDiscovery } from "./provider-discovery.js";
|
||||
import { buildCodexProvider, buildCodexProviderCatalog } from "./provider.js";
|
||||
import { CodexAppServerClient } from "./src/app-server/client.js";
|
||||
import type { listCodexAppServerModels } from "./src/app-server/models.js";
|
||||
import type { listAllCodexAppServerModels } from "./src/app-server/models.js";
|
||||
import {
|
||||
createIsolatedCodexAppServerClient,
|
||||
getSharedCodexAppServerClient,
|
||||
leaseSharedCodexAppServerClient,
|
||||
resetSharedCodexAppServerClientForTests,
|
||||
} from "./src/app-server/shared-client.js";
|
||||
|
||||
@@ -26,7 +26,8 @@ function createFakeCodexClient(): CodexAppServerClient {
|
||||
return {
|
||||
initialize: vi.fn(async () => undefined),
|
||||
request: vi.fn(async () => ({ data: [] })),
|
||||
setActiveSharedLeaseCountProviderForUnscopedNotifications: vi.fn(),
|
||||
addNotificationHandler: vi.fn(() => () => undefined),
|
||||
addRequestHandler: vi.fn(() => () => undefined),
|
||||
addCloseHandler: vi.fn(() => () => undefined),
|
||||
close: vi.fn(),
|
||||
} as unknown as CodexAppServerClient;
|
||||
@@ -39,7 +40,7 @@ const TEST_CODEX_APP_SERVER_CONFIG = {
|
||||
};
|
||||
|
||||
async function listTestCodexAppServerModels(
|
||||
options: Parameters<typeof listCodexAppServerModels>[0] = {},
|
||||
options: Parameters<typeof listAllCodexAppServerModels>[0] = {},
|
||||
) {
|
||||
expect(options.sharedClient).toBe(false);
|
||||
const client = await createIsolatedCodexAppServerClient({
|
||||
@@ -183,45 +184,33 @@ describe("codex provider", () => {
|
||||
expect(resultProvider?.models.map((model) => model.id)).toEqual(["gpt-5.4"]);
|
||||
});
|
||||
|
||||
it("pages through live discovery before building the provider catalog", async () => {
|
||||
const listModels = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
models: [
|
||||
{
|
||||
id: "gpt-5.4",
|
||||
model: "gpt-5.4",
|
||||
hidden: false,
|
||||
inputModalities: ["text", "image"],
|
||||
supportedReasoningEfforts: ["medium"],
|
||||
},
|
||||
],
|
||||
nextCursor: "page-2",
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
models: [
|
||||
{
|
||||
id: "gpt-5.5",
|
||||
model: "gpt-5.5",
|
||||
hidden: false,
|
||||
inputModalities: ["text"],
|
||||
supportedReasoningEfforts: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
it("delegates all-page discovery to one model lister call", async () => {
|
||||
const listModels = vi.fn(async () => ({
|
||||
models: [
|
||||
{
|
||||
id: "gpt-5.4",
|
||||
model: "gpt-5.4",
|
||||
hidden: false,
|
||||
inputModalities: ["text", "image"],
|
||||
supportedReasoningEfforts: ["medium"],
|
||||
},
|
||||
{
|
||||
id: "gpt-5.5",
|
||||
model: "gpt-5.5",
|
||||
hidden: false,
|
||||
inputModalities: ["text"],
|
||||
supportedReasoningEfforts: [],
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
const result = await buildCodexProviderCatalog({
|
||||
env: {},
|
||||
listModels,
|
||||
});
|
||||
|
||||
expect(listModels).toHaveBeenCalledTimes(1);
|
||||
expectRecordFields(mockCallArg(listModels, 0), {
|
||||
cursor: undefined,
|
||||
limit: 100,
|
||||
sharedClient: false,
|
||||
});
|
||||
expectRecordFields(mockCallArg(listModels, 1), {
|
||||
cursor: "page-2",
|
||||
limit: 100,
|
||||
sharedClient: false,
|
||||
});
|
||||
@@ -277,7 +266,7 @@ describe("codex provider", () => {
|
||||
.mockReturnValueOnce(activeClient)
|
||||
.mockReturnValueOnce(discoveryClient);
|
||||
|
||||
await getSharedCodexAppServerClient({
|
||||
await leaseSharedCodexAppServerClient({
|
||||
startOptions: {
|
||||
transport: "stdio",
|
||||
command: "/tmp/openclaw-test-codex",
|
||||
|
||||
@@ -18,16 +18,11 @@ import {
|
||||
CODEX_PROVIDER_ID,
|
||||
FALLBACK_CODEX_MODELS,
|
||||
} from "./provider-catalog.js";
|
||||
import {
|
||||
type CodexAppServerStartOptions,
|
||||
readCodexPluginConfig,
|
||||
resolveCodexAppServerRuntimeOptions,
|
||||
} from "./src/app-server/config.js";
|
||||
import type { CodexAppServerStartOptions } from "./src/app-server/config.js";
|
||||
import type {
|
||||
CodexAppServerModel,
|
||||
CodexAppServerModelListResult,
|
||||
} from "./src/app-server/models.js";
|
||||
import { buildCodexAppServerUsageSnapshot } from "./src/app-server/rate-limits.js";
|
||||
|
||||
const DEFAULT_DISCOVERY_TIMEOUT_MS = 2500;
|
||||
const LIVE_DISCOVERY_ENV = "OPENCLAW_CODEX_DISCOVERY_LIVE";
|
||||
@@ -39,7 +34,6 @@ const codexCatalogLog = createSubsystemLogger("codex/catalog");
|
||||
type CodexModelLister = (options: {
|
||||
timeoutMs: number;
|
||||
limit?: number;
|
||||
cursor?: string;
|
||||
startOptions?: CodexAppServerStartOptions;
|
||||
sharedClient?: boolean;
|
||||
}) => Promise<CodexAppServerModelListResult>;
|
||||
@@ -123,6 +117,11 @@ export function buildCodexProvider(options: BuildCodexProviderOptions = {}): Pro
|
||||
}
|
||||
const runtimePluginConfig = resolvePluginConfigObject(ctx.config, CODEX_PROVIDER_ID);
|
||||
const pluginConfig = runtimePluginConfig ?? (ctx.config ? undefined : options.pluginConfig);
|
||||
const [{ resolveCodexAppServerRuntimeOptions }, { buildCodexAppServerUsageSnapshot }] =
|
||||
await Promise.all([
|
||||
import("./src/app-server/config.js"),
|
||||
import("./src/app-server/rate-limits.js"),
|
||||
]);
|
||||
const appServer = resolveCodexAppServerRuntimeOptions({ pluginConfig });
|
||||
const rateLimits = await (options.readRateLimits ?? requestCodexAppServerRateLimitsLazy)({
|
||||
timeoutMs: ctx.timeoutMs,
|
||||
@@ -156,13 +155,15 @@ export function buildCodexProvider(options: BuildCodexProviderOptions = {}): Pro
|
||||
export async function buildCodexProviderCatalog(
|
||||
options: BuildCatalogOptions = {},
|
||||
): Promise<{ provider: ModelProviderConfig }> {
|
||||
const { readCodexPluginConfig, resolveCodexAppServerRuntimeOptions } =
|
||||
await import("./src/app-server/config.js");
|
||||
const config = readCodexPluginConfig(options.pluginConfig);
|
||||
const appServer = resolveCodexAppServerRuntimeOptions({ pluginConfig: options.pluginConfig });
|
||||
const timeoutMs = normalizeTimeoutMs(config.discovery?.timeoutMs);
|
||||
let discovered: CodexAppServerModel[] = [];
|
||||
if (config.discovery?.enabled !== false && !shouldSkipLiveDiscovery(options.env)) {
|
||||
discovered = await listModelsBestEffort({
|
||||
listModels: options.listModels ?? listCodexAppServerModelsLazy,
|
||||
listModels: options.listModels ?? listAllCodexAppServerModelsLazy,
|
||||
timeoutMs,
|
||||
startOptions: appServer.start,
|
||||
onDiscoveryFailure: options.onDiscoveryFailure,
|
||||
@@ -200,22 +201,14 @@ async function listModelsBestEffort(params: {
|
||||
onDiscoveryFailure?: (error: unknown) => void;
|
||||
}): Promise<CodexAppServerModel[]> {
|
||||
try {
|
||||
const models: CodexAppServerModel[] = [];
|
||||
let cursor: string | undefined;
|
||||
do {
|
||||
// App-server model listing is paginated; collect every visible model so
|
||||
// aliases and picker rows match the current Codex account.
|
||||
const result = await params.listModels({
|
||||
timeoutMs: params.timeoutMs,
|
||||
limit: MODEL_DISCOVERY_PAGE_LIMIT,
|
||||
cursor,
|
||||
startOptions: params.startOptions,
|
||||
sharedClient: false,
|
||||
});
|
||||
models.push(...result.models.filter((model) => !model.hidden));
|
||||
cursor = result.nextCursor;
|
||||
} while (cursor);
|
||||
return models;
|
||||
// The all-pages helper keeps one app-server client alive across pagination.
|
||||
const result = await params.listModels({
|
||||
timeoutMs: params.timeoutMs,
|
||||
limit: MODEL_DISCOVERY_PAGE_LIMIT,
|
||||
startOptions: params.startOptions,
|
||||
sharedClient: false,
|
||||
});
|
||||
return result.models.filter((model) => !model.hidden);
|
||||
} catch (error) {
|
||||
params.onDiscoveryFailure?.(error);
|
||||
codexCatalogLog.debug("codex model discovery failed; using fallback catalog", {
|
||||
@@ -225,15 +218,14 @@ async function listModelsBestEffort(params: {
|
||||
}
|
||||
}
|
||||
|
||||
async function listCodexAppServerModelsLazy(options: {
|
||||
async function listAllCodexAppServerModelsLazy(options: {
|
||||
timeoutMs: number;
|
||||
limit?: number;
|
||||
cursor?: string;
|
||||
startOptions?: CodexAppServerStartOptions;
|
||||
sharedClient?: boolean;
|
||||
}): Promise<CodexAppServerModelListResult> {
|
||||
const { listCodexAppServerModels } = await import("./src/app-server/models.js");
|
||||
return listCodexAppServerModels(options);
|
||||
const { listAllCodexAppServerModels } = await import("./src/app-server/models.js");
|
||||
return listAllCodexAppServerModels(options);
|
||||
}
|
||||
|
||||
async function requestCodexAppServerRateLimitsLazy(options: {
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
// Codex tests cover app server policy plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
resolveCodexAppServerForModelProvider,
|
||||
resolveCodexAppServerForOpenClawToolPolicy,
|
||||
} from "./app-server-policy.js";
|
||||
import { resolveCodexAppServerForOpenClawToolPolicy } from "./app-server-policy.js";
|
||||
import { readCodexPluginConfig, resolveCodexAppServerRuntimeOptions } from "./config.js";
|
||||
|
||||
describe("Codex app-server policy", () => {
|
||||
@@ -69,143 +66,4 @@ describe("Codex app-server policy", () => {
|
||||
expect(explicitEnv.approvalPolicy).toBe("never");
|
||||
expect(explicitRequirements.approvalPolicy).toBe("never");
|
||||
});
|
||||
|
||||
it("keeps model-backed reviewers for explicit OpenAI model providers", () => {
|
||||
const appServer = resolveCodexAppServerRuntimeOptions({
|
||||
env: {},
|
||||
requirementsToml: null,
|
||||
execMode: "auto",
|
||||
modelProvider: "openai",
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveCodexAppServerForModelProvider({
|
||||
appServer,
|
||||
provider: "codex",
|
||||
model: "openai/gpt-5.5",
|
||||
}).approvalsReviewer,
|
||||
).toBe("auto_review");
|
||||
expect(
|
||||
resolveCodexAppServerForModelProvider({
|
||||
appServer,
|
||||
provider: "codex",
|
||||
model: "gpt-5.5",
|
||||
}).approvalsReviewer,
|
||||
).toBe("user");
|
||||
expect(
|
||||
resolveCodexAppServerForModelProvider({ appServer, provider: "openai" }).approvalsReviewer,
|
||||
).toBe("auto_review");
|
||||
});
|
||||
|
||||
it("uses human approval for OpenAI-compatible custom endpoints", () => {
|
||||
const appServer = resolveCodexAppServerRuntimeOptions({
|
||||
env: {},
|
||||
requirementsToml: null,
|
||||
execMode: "auto",
|
||||
modelProvider: "openai",
|
||||
model: "gpt-5.5",
|
||||
config: {
|
||||
models: {
|
||||
providers: {
|
||||
openai: {
|
||||
baseUrl: "http://localhost:8080/v1",
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(appServer.approvalsReviewer).toBe("user");
|
||||
expect(
|
||||
resolveCodexAppServerForModelProvider({
|
||||
appServer,
|
||||
provider: "openai",
|
||||
model: "gpt-5.5",
|
||||
config: {
|
||||
models: {
|
||||
providers: {
|
||||
openai: {
|
||||
baseUrl: "http://localhost:8080/v1",
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}).approvalsReviewer,
|
||||
).toBe("user");
|
||||
});
|
||||
|
||||
it("uses human approval instead of Codex Guardian for custom model providers", () => {
|
||||
const appServer = resolveCodexAppServerRuntimeOptions({
|
||||
env: {},
|
||||
requirementsToml: null,
|
||||
execMode: "auto",
|
||||
modelProvider: "openai",
|
||||
});
|
||||
|
||||
const resolved = resolveCodexAppServerForModelProvider({
|
||||
appServer,
|
||||
provider: "lmstudio",
|
||||
});
|
||||
const vendorPrefixedModel = resolveCodexAppServerForModelProvider({
|
||||
appServer,
|
||||
provider: "openrouter",
|
||||
model: "openai/gpt-5.5",
|
||||
});
|
||||
|
||||
expect(appServer.approvalsReviewer).toBe("auto_review");
|
||||
expect(resolved.approvalPolicy).toBe("on-request");
|
||||
expect(resolved.sandbox).toBe("workspace-write");
|
||||
expect(resolved.approvalsReviewer).toBe("user");
|
||||
expect(vendorPrefixedModel.approvalsReviewer).toBe("user");
|
||||
});
|
||||
|
||||
it("infers custom providers from provider-qualified model refs", () => {
|
||||
const appServer = resolveCodexAppServerRuntimeOptions({
|
||||
env: {},
|
||||
requirementsToml: null,
|
||||
execMode: "auto",
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveCodexAppServerForModelProvider({
|
||||
appServer,
|
||||
model: "lmstudio/local-model",
|
||||
}).approvalsReviewer,
|
||||
).toBe("user");
|
||||
});
|
||||
|
||||
it("uses provider-qualified model refs to override broad native provider wrappers", () => {
|
||||
const appServer = resolveCodexAppServerRuntimeOptions({
|
||||
env: {},
|
||||
requirementsToml: null,
|
||||
execMode: "auto",
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveCodexAppServerForModelProvider({
|
||||
appServer,
|
||||
provider: "codex",
|
||||
model: "lmstudio/local-model",
|
||||
}).approvalsReviewer,
|
||||
).toBe("user");
|
||||
});
|
||||
|
||||
it("downgrades legacy guardian_subagent for custom model providers", () => {
|
||||
const appServer = resolveCodexAppServerRuntimeOptions({
|
||||
env: {},
|
||||
requirementsToml: null,
|
||||
pluginConfig: {
|
||||
appServer: {
|
||||
mode: "guardian",
|
||||
approvalsReviewer: "guardian_subagent",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveCodexAppServerForModelProvider({ appServer, provider: "local" }).approvalsReviewer,
|
||||
).toBe("user");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,11 +2,10 @@
|
||||
* Policy promotion for Codex app-server runs that can safely use OpenClaw tool
|
||||
* approvals.
|
||||
*/
|
||||
import {
|
||||
canUseCodexModelBackedApprovalsReviewerForModel,
|
||||
type CodexAppServerRuntimeOptions,
|
||||
type CodexPluginConfig,
|
||||
type OpenClawExecPolicyForCodexAppServer,
|
||||
import type {
|
||||
CodexAppServerRuntimeOptions,
|
||||
CodexPluginConfig,
|
||||
OpenClawExecPolicyForCodexAppServer,
|
||||
} from "./config.js";
|
||||
|
||||
/**
|
||||
@@ -45,35 +44,6 @@ export function resolveCodexAppServerForOpenClawToolPolicy(params: {
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveCodexAppServerForModelProvider(params: {
|
||||
appServer: CodexAppServerRuntimeOptions;
|
||||
provider?: string;
|
||||
model?: string;
|
||||
config?: Parameters<typeof canUseCodexModelBackedApprovalsReviewerForModel>[0]["config"];
|
||||
env?: NodeJS.ProcessEnv;
|
||||
agentDir?: string;
|
||||
codexConfigToml?: string | null;
|
||||
}): CodexAppServerRuntimeOptions {
|
||||
const explicitProvider = normalizeModelBackedReviewerProvider(params.provider);
|
||||
if (
|
||||
!isCodexModelBackedApprovalsReviewer(params.appServer.approvalsReviewer) ||
|
||||
canUseCodexModelBackedApprovalsReviewerForModel({
|
||||
modelProvider: explicitProvider,
|
||||
model: params.model,
|
||||
config: params.config,
|
||||
env: params.env,
|
||||
agentDir: params.agentDir,
|
||||
codexConfigToml: params.codexConfigToml,
|
||||
})
|
||||
) {
|
||||
return params.appServer;
|
||||
}
|
||||
return {
|
||||
...params.appServer,
|
||||
approvalsReviewer: "user",
|
||||
};
|
||||
}
|
||||
|
||||
function isCodexAppServerPolicyMode(value: unknown): boolean {
|
||||
return value === "guardian" || value === "yolo";
|
||||
}
|
||||
@@ -83,12 +53,3 @@ function isCodexAppServerApprovalPolicy(value: unknown): boolean {
|
||||
value === "never" || value === "on-request" || value === "on-failure" || value === "untrusted"
|
||||
);
|
||||
}
|
||||
|
||||
function isCodexModelBackedApprovalsReviewer(value: string): boolean {
|
||||
return value === "auto_review" || value === "guardian_subagent";
|
||||
}
|
||||
|
||||
function normalizeModelBackedReviewerProvider(provider: string | undefined): string | undefined {
|
||||
const normalized = provider?.trim().toLowerCase();
|
||||
return normalized || undefined;
|
||||
}
|
||||
|
||||
@@ -285,8 +285,7 @@ function matchesCurrentTurn(
|
||||
if (!requestParams) {
|
||||
return false;
|
||||
}
|
||||
const requestThreadId =
|
||||
readString(requestParams, "threadId") ?? readString(requestParams, "conversationId");
|
||||
const requestThreadId = readString(requestParams, "threadId");
|
||||
const requestTurnId = readString(requestParams, "turnId");
|
||||
return requestThreadId === threadId && requestTurnId === turnId;
|
||||
}
|
||||
|
||||
@@ -2,10 +2,41 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
interruptCodexTurnBestEffort,
|
||||
runCodexTurnStartWithLease,
|
||||
settleCodexAppServerClientLease,
|
||||
unsubscribeCodexThreadBestEffort,
|
||||
validateCodexThreadCreationResponse,
|
||||
} from "./attempt-client-cleanup.js";
|
||||
import { CodexAppServerRpcError } from "./client.js";
|
||||
|
||||
describe("Codex app-server attempt client cleanup", () => {
|
||||
it("keeps the client lease after a structured turn-start rejection", async () => {
|
||||
const abandon = vi.fn(async () => undefined);
|
||||
const error = new CodexAppServerRpcError({ message: "turn rejected" }, "turn/start");
|
||||
|
||||
await expect(
|
||||
runCodexTurnStartWithLease({ abandon } as never, async () => {
|
||||
throw error;
|
||||
}),
|
||||
).rejects.toBe(error);
|
||||
|
||||
expect(abandon).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("abandons only the exact client lease after an ambiguous turn-start timeout", async () => {
|
||||
const abandon = vi.fn(async () => undefined);
|
||||
const otherAbandon = vi.fn(async () => undefined);
|
||||
|
||||
await expect(
|
||||
runCodexTurnStartWithLease({ abandon } as never, async () => {
|
||||
throw new Error("turn/start timed out");
|
||||
}),
|
||||
).rejects.toThrow("turn/start timed out");
|
||||
|
||||
expect(abandon).toHaveBeenCalledTimes(1);
|
||||
expect(otherAbandon).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("interrupts turns with optional request timeout", () => {
|
||||
const request = vi.fn(async () => ({}));
|
||||
|
||||
@@ -22,7 +53,58 @@ describe("Codex app-server attempt client cleanup", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("swallows unsubscribe cleanup failures", async () => {
|
||||
it("unsubscribes a retained thread when its create response is malformed", async () => {
|
||||
const request = vi.fn(async () => ({}));
|
||||
const abandon = vi.fn(async () => undefined);
|
||||
const invalidResponse = { thread: { id: "thread-1" } };
|
||||
|
||||
await expect(
|
||||
validateCodexThreadCreationResponse(
|
||||
{ client: { request } as never, abandon },
|
||||
invalidResponse,
|
||||
() => {
|
||||
throw new Error("invalid thread/start response");
|
||||
},
|
||||
),
|
||||
).rejects.toThrow("invalid thread/start response");
|
||||
|
||||
expect(request).toHaveBeenCalledWith(
|
||||
"thread/unsubscribe",
|
||||
{ threadId: "thread-1" },
|
||||
{ timeoutMs: 5_000 },
|
||||
);
|
||||
expect(abandon).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["omits the retained thread id", {}, vi.fn(async () => ({}))],
|
||||
[
|
||||
"cannot confirm unsubscribe",
|
||||
{ thread: { id: "thread-1" } },
|
||||
vi.fn(async () => {
|
||||
throw new Error("connection lost");
|
||||
}),
|
||||
],
|
||||
])(
|
||||
"retires the client when a malformed create response %s",
|
||||
async (_label, response, request) => {
|
||||
const abandon = vi.fn(async () => undefined);
|
||||
|
||||
await expect(
|
||||
validateCodexThreadCreationResponse(
|
||||
{ client: { request } as never, abandon },
|
||||
response,
|
||||
() => {
|
||||
throw new Error("invalid thread/start response");
|
||||
},
|
||||
),
|
||||
).rejects.toThrow("subscription could not be released");
|
||||
|
||||
expect(abandon).toHaveBeenCalledOnce();
|
||||
},
|
||||
);
|
||||
|
||||
it("reports unsubscribe cleanup failures", async () => {
|
||||
const request = vi.fn(async () => {
|
||||
throw new Error("already gone");
|
||||
});
|
||||
@@ -32,7 +114,7 @@ describe("Codex app-server attempt client cleanup", () => {
|
||||
threadId: "thread-1",
|
||||
timeoutMs: 123,
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
).resolves.toBe(false);
|
||||
|
||||
expect(request).toHaveBeenCalledWith(
|
||||
"thread/unsubscribe",
|
||||
@@ -40,4 +122,31 @@ describe("Codex app-server attempt client cleanup", () => {
|
||||
{ timeoutMs: 123 },
|
||||
);
|
||||
});
|
||||
|
||||
it("returns leases only after thread cleanup is confirmed", async () => {
|
||||
const release = vi.fn();
|
||||
const abandon = vi.fn(async () => undefined);
|
||||
await settleCodexAppServerClientLease(
|
||||
{ client: { request: vi.fn(async () => ({})) }, release, abandon } as never,
|
||||
{ threadId: "thread-ok", timeoutMs: 123 },
|
||||
);
|
||||
expect(release).toHaveBeenCalledOnce();
|
||||
expect(abandon).not.toHaveBeenCalled();
|
||||
|
||||
release.mockClear();
|
||||
await settleCodexAppServerClientLease(
|
||||
{
|
||||
client: {
|
||||
request: vi.fn(async () => {
|
||||
throw new Error("unsubscribe failed");
|
||||
}),
|
||||
},
|
||||
release,
|
||||
abandon,
|
||||
} as never,
|
||||
{ threadId: "thread-stale", timeoutMs: 123 },
|
||||
);
|
||||
expect(release).not.toHaveBeenCalled();
|
||||
expect(abandon).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,14 +2,126 @@
|
||||
* Best-effort cleanup helpers for timed-out or aborted Codex app-server turns.
|
||||
*/
|
||||
import { embeddedAgentLog } from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import type { CodexAppServerClient } from "./client.js";
|
||||
import { retireSharedCodexAppServerClientIfCurrent } from "./shared-client.js";
|
||||
import { CodexAppServerRpcError, type CodexAppServerClient } from "./client.js";
|
||||
import { isJsonObject, readCodexThreadCreationResponseId } from "./protocol.js";
|
||||
import type { CodexAppServerClientLease } from "./shared-client.js";
|
||||
|
||||
/** Timeout for best-effort app-server turn interruption during cleanup. */
|
||||
export const CODEX_APP_SERVER_INTERRUPT_TIMEOUT_MS = 5_000;
|
||||
/** Timeout for best-effort thread unsubscribe during cleanup. */
|
||||
export const CODEX_APP_SERVER_UNSUBSCRIBE_TIMEOUT_MS = 5_000;
|
||||
|
||||
/** The connection's thread-subscription ownership can no longer be proven. */
|
||||
export class CodexAppServerUnsafeSubscriptionError extends Error {
|
||||
constructor(message: string, options?: ErrorOptions) {
|
||||
super(message, options);
|
||||
this.name = "CodexAppServerUnsafeSubscriptionError";
|
||||
}
|
||||
}
|
||||
|
||||
export function isCodexAppServerUnsafeSubscriptionError(
|
||||
error: unknown,
|
||||
): error is CodexAppServerUnsafeSubscriptionError {
|
||||
return error instanceof CodexAppServerUnsafeSubscriptionError;
|
||||
}
|
||||
|
||||
/** A resume response may only describe the thread this connection retained. */
|
||||
export function assertCodexThreadResumeSubscription(
|
||||
requestedThreadId: string,
|
||||
returnedThreadId: string,
|
||||
): void {
|
||||
if (returnedThreadId !== requestedThreadId) {
|
||||
throw new CodexAppServerUnsafeSubscriptionError(
|
||||
`Codex thread/resume returned ${returnedThreadId} for ${requestedThreadId}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Retires the exact client lease when turn acceptance is ambiguous. */
|
||||
export async function runCodexTurnStartWithLease<T>(
|
||||
lease: CodexAppServerClientLease,
|
||||
startTurn: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
try {
|
||||
return await startTurn();
|
||||
} catch (error) {
|
||||
// Structured RPC rejection happens before Codex accepts the turn. Transport,
|
||||
// timeout, and abort failures may hide an accepted turn with an unknown id.
|
||||
if (!(error instanceof CodexAppServerRpcError)) {
|
||||
await lease.abandon();
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** Retries once when native work wins the race immediately before turn/start. */
|
||||
export async function runCodexTurnStartWithNativeTurnRetry<T>(params: {
|
||||
startTurn: () => Promise<T>;
|
||||
waitForActiveTurnCompletion: () => Promise<boolean>;
|
||||
afterActiveTurnCompletion?: () => Promise<void>;
|
||||
onRetry?: () => void;
|
||||
}): Promise<T> {
|
||||
try {
|
||||
return await params.startTurn();
|
||||
} catch (error) {
|
||||
if (!isCodexActiveTurnNotSteerableError(error)) {
|
||||
throw error;
|
||||
}
|
||||
params.onRetry?.();
|
||||
if (!(await params.waitForActiveTurnCompletion())) {
|
||||
throw error;
|
||||
}
|
||||
await params.afterActiveTurnCompletion?.();
|
||||
return await params.startTurn();
|
||||
}
|
||||
}
|
||||
|
||||
/** True for Codex's structured rejection when native work already owns the thread. */
|
||||
export function isCodexActiveTurnNotSteerableError(error: unknown): boolean {
|
||||
if (!(error instanceof CodexAppServerRpcError) || !isJsonObject(error.data)) {
|
||||
return false;
|
||||
}
|
||||
const info = error.data.codexErrorInfo;
|
||||
return isJsonObject(info) && isJsonObject(info.activeTurnNotSteerable);
|
||||
}
|
||||
|
||||
/** Validates a create response and retires the client unless cleanup is confirmed. */
|
||||
export async function validateCodexThreadCreationResponse<T>(
|
||||
owner: {
|
||||
client: CodexAppServerClient;
|
||||
abandon: () => Promise<void>;
|
||||
},
|
||||
response: unknown,
|
||||
validate: (value: unknown) => T,
|
||||
): Promise<T> {
|
||||
try {
|
||||
return validate(response);
|
||||
} catch (error) {
|
||||
const threadId = readCodexThreadCreationResponseId(response);
|
||||
const released = threadId
|
||||
? await unsubscribeCodexThreadBestEffort(owner.client, {
|
||||
threadId,
|
||||
timeoutMs: CODEX_APP_SERVER_UNSUBSCRIBE_TIMEOUT_MS,
|
||||
})
|
||||
: false;
|
||||
if (released) {
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
await owner.abandon();
|
||||
} catch (abandonError) {
|
||||
throw new CodexAppServerUnsafeSubscriptionError(
|
||||
"Codex thread creation response was invalid and its client could not be retired",
|
||||
{ cause: abandonError },
|
||||
);
|
||||
}
|
||||
throw new CodexAppServerUnsafeSubscriptionError(
|
||||
"Codex thread creation response was invalid and its subscription could not be released",
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Sends a turn interrupt without blocking abort cleanup on app-server errors. */
|
||||
export function interruptCodexTurnBestEffort(
|
||||
client: CodexAppServerClient,
|
||||
@@ -36,28 +148,56 @@ export function interruptCodexTurnBestEffort(
|
||||
}
|
||||
}
|
||||
|
||||
/** Unsubscribes from a thread while swallowing cleanup-only failures. */
|
||||
/** Unsubscribes from a thread and reports whether wire cleanup was confirmed. */
|
||||
export async function unsubscribeCodexThreadBestEffort(
|
||||
client: CodexAppServerClient,
|
||||
params: {
|
||||
threadId: string;
|
||||
timeoutMs: number;
|
||||
},
|
||||
): Promise<void> {
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
await client.request(
|
||||
"thread/unsubscribe",
|
||||
{ threadId: params.threadId },
|
||||
{ timeoutMs: params.timeoutMs },
|
||||
);
|
||||
return true;
|
||||
} catch (error) {
|
||||
embeddedAgentLog.debug("codex app-server thread unsubscribe cleanup failed", {
|
||||
threadId: params.threadId,
|
||||
error,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns one exact client lease to the pool only after subscription cleanup succeeds. */
|
||||
export async function settleCodexAppServerClientLease(
|
||||
lease: CodexAppServerClientLease,
|
||||
params: {
|
||||
threadId?: string;
|
||||
timeoutMs: number;
|
||||
abandon?: boolean;
|
||||
},
|
||||
): Promise<void> {
|
||||
if (params.abandon) {
|
||||
await lease.abandon();
|
||||
return;
|
||||
}
|
||||
if (
|
||||
params.threadId &&
|
||||
!(await unsubscribeCodexThreadBestEffort(lease.client, {
|
||||
threadId: params.threadId,
|
||||
timeoutMs: params.timeoutMs,
|
||||
}))
|
||||
) {
|
||||
await lease.abandon();
|
||||
return;
|
||||
}
|
||||
lease.release();
|
||||
}
|
||||
|
||||
/**
|
||||
* Retires the shared client after a timed-out turn so later runs do not reuse a
|
||||
* potentially wedged app-server connection.
|
||||
@@ -68,10 +208,9 @@ export async function retireCodexAppServerClientAfterTimedOutTurn(
|
||||
threadId: string;
|
||||
turnId: string;
|
||||
reason: string;
|
||||
abandonClientLease: () => Promise<void>;
|
||||
},
|
||||
): Promise<void> {
|
||||
const retiredSharedClient = retireSharedCodexAppServerClientIfCurrent(client);
|
||||
const detachedSharedClient = Boolean(retiredSharedClient);
|
||||
interruptCodexTurnBestEffort(client, {
|
||||
threadId: params.threadId,
|
||||
turnId: params.turnId,
|
||||
@@ -81,28 +220,10 @@ export async function retireCodexAppServerClientAfterTimedOutTurn(
|
||||
threadId: params.threadId,
|
||||
timeoutMs: CODEX_APP_SERVER_UNSUBSCRIBE_TIMEOUT_MS,
|
||||
});
|
||||
let closedClient = retiredSharedClient?.closed ?? false;
|
||||
if (!detachedSharedClient) {
|
||||
const close = (client as { close?: () => void }).close;
|
||||
if (typeof close === "function") {
|
||||
try {
|
||||
close.call(client);
|
||||
closedClient = true;
|
||||
} catch (error) {
|
||||
embeddedAgentLog.debug("codex app-server client close failed during timeout cleanup", {
|
||||
threadId: params.threadId,
|
||||
turnId: params.turnId,
|
||||
error,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
await params.abandonClientLease();
|
||||
embeddedAgentLog.warn("codex app-server client retired after timed-out turn", {
|
||||
threadId: params.threadId,
|
||||
turnId: params.turnId,
|
||||
reason: params.reason,
|
||||
detachedSharedClient,
|
||||
closedClient,
|
||||
activeSharedClientLeases: retiredSharedClient?.activeLeases ?? 0,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
isFileChangePatchUpdatedNotification,
|
||||
isAssistantCommentaryCompletionNotification,
|
||||
isNativeToolProgressNotification,
|
||||
isNativeResponseStreamDeltaNotification,
|
||||
isPendingOpenClawDynamicToolCompletionNotification,
|
||||
isRawAssistantProgressNotification,
|
||||
isRawReasoningCompletionNotification,
|
||||
@@ -17,7 +16,6 @@ import {
|
||||
isReasoningProgressNotification,
|
||||
isReasoningItemCompletionNotification,
|
||||
isRetryableErrorNotification,
|
||||
isTurnNotification,
|
||||
readCodexNotificationItem,
|
||||
readNotificationItemId,
|
||||
shouldDisarmAssistantCompletionIdleWatch,
|
||||
@@ -25,6 +23,7 @@ import {
|
||||
} from "./attempt-notifications.js";
|
||||
import { CODEX_POST_REASONING_REPLY_IDLE_TIMEOUT_MS } from "./attempt-timeouts.js";
|
||||
import type { CodexAttemptTurnWatchController } from "./attempt-turn-watches.js";
|
||||
import { isCodexNotificationForTurn } from "./notification-correlation.js";
|
||||
import type { CodexServerNotification } from "./protocol.js";
|
||||
|
||||
type CodexExecutionPhase =
|
||||
@@ -70,7 +69,7 @@ export function isTerminalCodexTurnNotificationForTurn(params: {
|
||||
turnId: string;
|
||||
currentPromptTexts: string[];
|
||||
}): boolean {
|
||||
if (!isTurnNotification(params.notification.params, params.threadId, params.turnId)) {
|
||||
if (!isCodexNotificationForTurn(params.notification.params, params.threadId, params.turnId)) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
@@ -105,16 +104,15 @@ export function applyCodexTurnNotificationState(params: {
|
||||
turnCrossedToolHandoff: boolean;
|
||||
} {
|
||||
const { notification, turnWatches } = params;
|
||||
const isCurrentTurnNotification = isTurnNotification(
|
||||
const isCurrentTurnNotification = isCodexNotificationForTurn(
|
||||
notification.params,
|
||||
params.threadId,
|
||||
params.turnId,
|
||||
);
|
||||
const isTurnCompletion = notification.method === "turn/completed" && isCurrentTurnNotification;
|
||||
const isNativeResponseStreamDelta = isNativeResponseStreamDeltaNotification(notification);
|
||||
let turnCrossedToolHandoff = params.turnCrossedToolHandoff;
|
||||
|
||||
if (isCurrentTurnNotification && !isNativeResponseStreamDelta) {
|
||||
if (isCurrentTurnNotification) {
|
||||
turnWatches.touchActivity(`notification:${notification.method}`, {
|
||||
details: describeNotificationActivity(notification),
|
||||
attemptProgress: true,
|
||||
@@ -250,7 +248,6 @@ export function applyCodexTurnNotificationState(params: {
|
||||
!turnWatches.isCompletionIdleWatchPinnedByTerminalError() &&
|
||||
notification.method !== "turn/completed" &&
|
||||
isCurrentTurnNotification &&
|
||||
!isNativeResponseStreamDelta &&
|
||||
!trackedDynamicToolCompletion &&
|
||||
!rawToolOutputCompletion &&
|
||||
!postToolProgressNeedsTerminalGuard &&
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
/**
|
||||
* Predicates and readers for Codex app-server notification envelopes.
|
||||
*/
|
||||
import { asBoolean } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import {
|
||||
describeCodexNotificationCorrelation,
|
||||
isCodexNotificationForTurn,
|
||||
} from "./notification-correlation.js";
|
||||
import {
|
||||
isJsonObject,
|
||||
type CodexServerNotification,
|
||||
@@ -216,13 +211,6 @@ export function isNativeToolProgressNotification(notification: CodexServerNotifi
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns true for raw native response stream delta events. */
|
||||
export function isNativeResponseStreamDeltaNotification(
|
||||
notification: CodexServerNotification,
|
||||
): boolean {
|
||||
return notification.method.startsWith("response.") && notification.method.endsWith(".delta");
|
||||
}
|
||||
|
||||
/** Returns true for file-change patch update notifications. */
|
||||
export function isFileChangePatchUpdatedNotification(
|
||||
notification: CodexServerNotification,
|
||||
@@ -277,74 +265,9 @@ function readRawAssistantTextPreview(item: JsonObject): string | undefined {
|
||||
return text.length > 240 ? `${text.slice(0, 237)}...` : text;
|
||||
}
|
||||
|
||||
/** Returns true when notification params correlate to a specific thread/turn. */
|
||||
export function isTurnNotification(
|
||||
value: JsonValue | undefined,
|
||||
threadId: string,
|
||||
turnId: string,
|
||||
): boolean {
|
||||
return isCodexNotificationForTurn(value, threadId, turnId);
|
||||
}
|
||||
|
||||
/** Returns true when a correlated notification belongs to another active run. */
|
||||
export function isCodexNotificationOutsideActiveRun(
|
||||
correlation: ReturnType<typeof describeCodexNotificationCorrelation>,
|
||||
): boolean {
|
||||
const hasThreadScope = Boolean(correlation.threadId || correlation.nestedTurnThreadId);
|
||||
if (!hasThreadScope) {
|
||||
return false;
|
||||
}
|
||||
if (!correlation.matchesActiveThread) {
|
||||
return true;
|
||||
}
|
||||
const hasTurnScope = Boolean(correlation.turnId || correlation.nestedTurnId);
|
||||
return hasTurnScope && correlation.matchesActiveTurn === false;
|
||||
}
|
||||
|
||||
/** Checks request params that must contain the current thread and turn ids. */
|
||||
export function isCurrentThreadTurnRequestParams(
|
||||
value: JsonValue | undefined,
|
||||
threadId: string,
|
||||
turnId: string,
|
||||
): boolean {
|
||||
if (!isJsonObject(value)) {
|
||||
return false;
|
||||
}
|
||||
return readString(value, "threadId") === threadId && readString(value, "turnId") === turnId;
|
||||
}
|
||||
|
||||
/** Checks approval request params, accepting `conversationId` as thread id. */
|
||||
export function isCurrentApprovalTurnRequestParams(
|
||||
value: JsonValue | undefined,
|
||||
threadId: string,
|
||||
turnId: string,
|
||||
): boolean {
|
||||
if (!isJsonObject(value)) {
|
||||
return false;
|
||||
}
|
||||
const requestThreadId = readString(value, "threadId") ?? readString(value, "conversationId");
|
||||
return requestThreadId === threadId && readString(value, "turnId") === turnId;
|
||||
}
|
||||
|
||||
/** Checks request params where `turnId` may be omitted or null for the thread. */
|
||||
export function isCurrentThreadOptionalTurnRequestParams(
|
||||
value: JsonValue | undefined,
|
||||
threadId: string,
|
||||
turnId: string,
|
||||
): boolean {
|
||||
if (!isJsonObject(value) || readString(value, "threadId") !== threadId) {
|
||||
return false;
|
||||
}
|
||||
const requestTurnId = value.turnId;
|
||||
return requestTurnId === null || requestTurnId === undefined || requestTurnId === turnId;
|
||||
}
|
||||
|
||||
/** Returns true for app-server error notifications that will retry. */
|
||||
export function isRetryableErrorNotification(value: JsonValue | undefined): boolean {
|
||||
if (!isJsonObject(value)) {
|
||||
return false;
|
||||
}
|
||||
return readBoolean(value, "willRetry") === true || readBoolean(value, "will_retry") === true;
|
||||
return isJsonObject(value) && value.willRetry === true;
|
||||
}
|
||||
|
||||
/** Returns true for terminal app-server thread status strings. */
|
||||
@@ -419,10 +342,6 @@ function readString(record: JsonObject, key: string): string | undefined {
|
||||
return typeof value === "string" ? value : undefined;
|
||||
}
|
||||
|
||||
function readBoolean(record: JsonObject, key: string): boolean | undefined {
|
||||
return asBoolean(record[key]);
|
||||
}
|
||||
|
||||
/** Reads a typed Codex item from notification params when id/type are present. */
|
||||
export function readCodexNotificationItem(
|
||||
params: JsonValue | undefined,
|
||||
|
||||
@@ -9,13 +9,16 @@ import type {
|
||||
} from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { startCodexAttemptThread } from "./attempt-startup.js";
|
||||
import { defaultLeasedCodexAppServerClientFactory } from "./client-factory.js";
|
||||
import { CodexAppServerClient } from "./client.js";
|
||||
import { type CodexPluginConfig, resolveCodexAppServerRuntimeOptions } from "./config.js";
|
||||
import { threadStartResult } from "./run-attempt-test-harness.js";
|
||||
import {
|
||||
clearSharedCodexAppServerClient,
|
||||
getLeasedSharedCodexAppServerClient,
|
||||
releaseLeasedSharedCodexAppServerClient,
|
||||
resetCodexTestBindingStore,
|
||||
testCodexAppServerBindingStore,
|
||||
} from "./session-binding.test-helpers.js";
|
||||
import {
|
||||
leaseSharedCodexAppServerClient,
|
||||
resetSharedCodexAppServerClientForTests,
|
||||
} from "./shared-client.js";
|
||||
import { createClientHarness, createCodexTestModel } from "./test-support.js";
|
||||
|
||||
@@ -85,12 +88,10 @@ function startThreadWithHarness(
|
||||
signal = new AbortController().signal,
|
||||
overrides?: {
|
||||
pluginConfig?: CodexPluginConfig;
|
||||
attemptClientFactory?: (
|
||||
harness: ClientHarness,
|
||||
) => Parameters<typeof startCodexAttemptThread>[0]["attemptClientFactory"];
|
||||
harness?: ClientHarness;
|
||||
paths?: AttemptPaths;
|
||||
skipStartSpy?: boolean;
|
||||
onThreadReserved?: Parameters<typeof startCodexAttemptThread>[0]["onThreadReserved"];
|
||||
},
|
||||
) {
|
||||
const harness = overrides?.harness ?? createClientHarness();
|
||||
@@ -101,8 +102,7 @@ function startThreadWithHarness(
|
||||
const effectivePluginConfig = overrides?.pluginConfig ?? pluginConfig;
|
||||
|
||||
const run = startCodexAttemptThread({
|
||||
attemptClientFactory:
|
||||
overrides?.attemptClientFactory?.(harness) ?? defaultLeasedCodexAppServerClientFactory,
|
||||
bindingStore: testCodexAppServerBindingStore,
|
||||
appServer: resolveCodexAppServerRuntimeOptions({ pluginConfig: effectivePluginConfig }),
|
||||
pluginConfig: effectivePluginConfig,
|
||||
computerUseConfig: effectivePluginConfig.computerUse ?? { enabled: false },
|
||||
@@ -123,10 +123,11 @@ function startThreadWithHarness(
|
||||
sandboxExecServerEnabled: false,
|
||||
sandbox: null,
|
||||
contextEngineProjection: undefined,
|
||||
startupTokenGuard: {},
|
||||
startupTimeoutMs,
|
||||
signal,
|
||||
onStartupTimeout: vi.fn(),
|
||||
spawnedBy: undefined,
|
||||
onThreadReserved: overrides?.onThreadReserved,
|
||||
});
|
||||
|
||||
return { harness, run };
|
||||
@@ -168,12 +169,13 @@ describe("startCodexAttemptThread", () => {
|
||||
vi.useRealTimers();
|
||||
vi.stubEnv("CODEX_API_KEY", "");
|
||||
vi.stubEnv("OPENAI_API_KEY", "");
|
||||
clearSharedCodexAppServerClient();
|
||||
resetCodexTestBindingStore();
|
||||
resetSharedCodexAppServerClientForTests();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.useRealTimers();
|
||||
clearSharedCodexAppServerClient();
|
||||
resetSharedCodexAppServerClientForTests();
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllEnvs();
|
||||
for (const root of tempRoots) {
|
||||
@@ -182,7 +184,7 @@ describe("startCodexAttemptThread", () => {
|
||||
tempRoots.clear();
|
||||
});
|
||||
|
||||
it("clears the shared app-server when top-level thread startup fails with an app error", async () => {
|
||||
it("keeps the shared app-server reusable after a structured startup rejection", async () => {
|
||||
const { harness, run } = startThreadWithHarness(5_000);
|
||||
await answerInitialize(harness);
|
||||
const threadStart = await waitForThreadStart(harness);
|
||||
@@ -192,25 +194,57 @@ describe("startCodexAttemptThread", () => {
|
||||
});
|
||||
|
||||
await expect(run).rejects.toThrow("Invalid bearer token");
|
||||
expect(harness.process.stdin.destroyed).toBe(false);
|
||||
});
|
||||
|
||||
it("retires the client when malformed startup cleanup cannot be confirmed", async () => {
|
||||
const { harness, run } = startThreadWithHarness(5_000);
|
||||
await answerInitialize(harness);
|
||||
const threadStart = await waitForThreadStart(harness);
|
||||
harness.send({ id: threadStart.id, result: { thread: { id: "thread-malformed" } } });
|
||||
const unsubscribe = await waitForRequest(harness, "thread/unsubscribe");
|
||||
harness.send({
|
||||
id: unsubscribe.id,
|
||||
error: { code: -32000, message: "unsubscribe failed" },
|
||||
});
|
||||
|
||||
await expect(run).rejects.toThrow("subscription could not be released");
|
||||
expect(harness.process.stdin.destroyed).toBe(true);
|
||||
});
|
||||
|
||||
it("retires a failed startup client after another active lease releases", async () => {
|
||||
it("retires the client when route cleanup cannot release the subscription", async () => {
|
||||
const { harness, run } = startThreadWithHarness(5_000, undefined, {
|
||||
onThreadReserved: () => {
|
||||
throw new Error("route integration failed");
|
||||
},
|
||||
});
|
||||
await answerInitialize(harness);
|
||||
const threadStart = await waitForThreadStart(harness);
|
||||
harness.send({ id: threadStart.id, result: threadStartResult("thread-route-failed") });
|
||||
const unsubscribe = await waitForRequest(harness, "thread/unsubscribe");
|
||||
harness.send({
|
||||
id: unsubscribe.id,
|
||||
error: { code: -32000, message: "unsubscribe failed" },
|
||||
});
|
||||
|
||||
await expect(run).rejects.toThrow("Codex startup subscription cleanup failed");
|
||||
expect(harness.process.stdin.destroyed).toBe(true);
|
||||
});
|
||||
|
||||
it("does not retire a peer-owned client after a structured startup rejection", async () => {
|
||||
const retained = createClientHarness();
|
||||
const replacement = createClientHarness();
|
||||
const startSpy = vi
|
||||
.spyOn(CodexAppServerClient, "start")
|
||||
.mockReturnValueOnce(retained.client)
|
||||
.mockReturnValueOnce(replacement.client);
|
||||
const startSpy = vi.spyOn(CodexAppServerClient, "start").mockReturnValue(retained.client);
|
||||
const appServer = resolveCodexAppServerRuntimeOptions({ pluginConfig });
|
||||
const paths = createAttemptPaths();
|
||||
|
||||
const retainedLease = getLeasedSharedCodexAppServerClient({
|
||||
const retainedLeasePromise = leaseSharedCodexAppServerClient({
|
||||
startOptions: appServer.start,
|
||||
agentDir: paths.agentDir,
|
||||
preparedAuth: {},
|
||||
});
|
||||
await answerInitialize(retained);
|
||||
await expect(retainedLease).resolves.toBe(retained.client);
|
||||
const retainedLease = await retainedLeasePromise;
|
||||
expect(retainedLease.client).toBe(retained.client);
|
||||
|
||||
const { run } = startThreadWithHarness(5_000, new AbortController().signal, {
|
||||
harness: retained,
|
||||
@@ -226,17 +260,16 @@ describe("startCodexAttemptThread", () => {
|
||||
await expect(run).rejects.toThrow("Invalid bearer token");
|
||||
expect(retained.process.stdin.destroyed).toBe(false);
|
||||
|
||||
expect(releaseLeasedSharedCodexAppServerClient(retained.client)).toBe(true);
|
||||
await vi.waitFor(() => expect(retained.process.stdin.destroyed).toBe(true));
|
||||
|
||||
const replacementLease = getLeasedSharedCodexAppServerClient({
|
||||
retainedLease.release();
|
||||
const nextLeasePromise = leaseSharedCodexAppServerClient({
|
||||
startOptions: appServer.start,
|
||||
agentDir: paths.agentDir,
|
||||
preparedAuth: {},
|
||||
});
|
||||
await answerInitialize(replacement);
|
||||
await expect(replacementLease).resolves.toBe(replacement.client);
|
||||
expect(startSpy).toHaveBeenCalledTimes(2);
|
||||
expect(releaseLeasedSharedCodexAppServerClient(replacement.client)).toBe(true);
|
||||
const nextLease = await nextLeasePromise;
|
||||
expect(nextLease.client).toBe(retained.client);
|
||||
expect(startSpy).toHaveBeenCalledTimes(1);
|
||||
nextLease.release();
|
||||
});
|
||||
|
||||
it("clears the shared app-server when startup abandons an in-flight thread request", async () => {
|
||||
@@ -258,18 +291,20 @@ describe("startCodexAttemptThread", () => {
|
||||
expect(harness.stdinDestroyed).toBe(true);
|
||||
});
|
||||
|
||||
it("aborts abandoned thread startup when another lease keeps the shared app-server alive", async () => {
|
||||
it("retires abandoned thread startup even when another lease shares the client", async () => {
|
||||
const retained = createClientHarness();
|
||||
vi.spyOn(CodexAppServerClient, "start").mockReturnValue(retained.client);
|
||||
const appServer = resolveCodexAppServerRuntimeOptions({ pluginConfig });
|
||||
const paths = createAttemptPaths();
|
||||
|
||||
const retainedLease = getLeasedSharedCodexAppServerClient({
|
||||
const retainedLeasePromise = leaseSharedCodexAppServerClient({
|
||||
startOptions: appServer.start,
|
||||
agentDir: paths.agentDir,
|
||||
preparedAuth: {},
|
||||
});
|
||||
await answerInitialize(retained);
|
||||
await expect(retainedLease).resolves.toBe(retained.client);
|
||||
const retainedLease = await retainedLeasePromise;
|
||||
expect(retainedLease.client).toBe(retained.client);
|
||||
|
||||
const { run } = startThreadWithHarness(100, new AbortController().signal, {
|
||||
harness: retained,
|
||||
@@ -280,11 +315,9 @@ describe("startCodexAttemptThread", () => {
|
||||
const threadStart = await waitForThreadStart(retained);
|
||||
|
||||
await rejected;
|
||||
expect(retained.process.stdin.destroyed).toBe(false);
|
||||
|
||||
retained.send({ id: threadStart.id, result: { threadId: "late-thread" } });
|
||||
expect(releaseLeasedSharedCodexAppServerClient(retained.client)).toBe(true);
|
||||
await vi.waitFor(() => expect(retained.process.stdin.destroyed).toBe(true));
|
||||
expect(threadStart.id).toBeDefined();
|
||||
expect(retained.process.stdin.destroyed).toBe(true);
|
||||
retainedLease.release();
|
||||
});
|
||||
|
||||
it("closes the shared app-server when startup times out during initialize", async () => {
|
||||
@@ -309,45 +342,37 @@ describe("startCodexAttemptThread", () => {
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("closes a startup client that arrives after startup timeout", async () => {
|
||||
let observedFactoryOptions:
|
||||
| {
|
||||
onStartedClient?: (client: CodexAppServerClient) => void;
|
||||
abandonSignal?: AbortSignal;
|
||||
}
|
||||
| undefined;
|
||||
let resolveFactoryDone: () => void = () => undefined;
|
||||
const factoryDone = new Promise<void>((resolve) => {
|
||||
resolveFactoryDone = resolve;
|
||||
it("releases a late startup lease without retiring a peer-owned initializing client", async () => {
|
||||
const harness = createClientHarness();
|
||||
const startSpy = vi.spyOn(CodexAppServerClient, "start").mockReturnValue(harness.client);
|
||||
const paths = createAttemptPaths();
|
||||
const appServer = resolveCodexAppServerRuntimeOptions({ pluginConfig });
|
||||
const peerPromise = leaseSharedCodexAppServerClient({
|
||||
startOptions: appServer.start,
|
||||
agentDir: paths.agentDir,
|
||||
preparedAuth: {},
|
||||
});
|
||||
const { harness, run } = startThreadWithHarness(100, new AbortController().signal, {
|
||||
attemptClientFactory:
|
||||
(factoryHarness) => async (_startOptions, _authProfileId, _agentDir, _config, options) => {
|
||||
try {
|
||||
observedFactoryOptions = options;
|
||||
await new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, 250);
|
||||
});
|
||||
options?.onStartedClient?.(factoryHarness.client);
|
||||
return factoryHarness.client;
|
||||
} finally {
|
||||
resolveFactoryDone();
|
||||
}
|
||||
},
|
||||
const { run } = startThreadWithHarness(100, new AbortController().signal, {
|
||||
harness,
|
||||
paths,
|
||||
skipStartSpy: true,
|
||||
});
|
||||
const rejected = expect(run).rejects.toThrow("codex app-server startup timed out");
|
||||
|
||||
await rejected;
|
||||
await factoryDone;
|
||||
await vi.waitFor(() => expect(harness.stdinDestroyed).toBe(true), {
|
||||
interval: 1,
|
||||
timeout: 2_000,
|
||||
await expect(run).rejects.toThrow("codex app-server startup timed out");
|
||||
expect(harness.stdinDestroyed).toBe(false);
|
||||
await answerInitialize(harness);
|
||||
const peer = await peerPromise;
|
||||
expect(peer.client).toBe(harness.client);
|
||||
await new Promise<void>((resolve) => {
|
||||
setImmediate(resolve);
|
||||
});
|
||||
|
||||
expect(startSpy).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
readHarnessMessages(harness.writes).some((write) => write.method === "thread/start"),
|
||||
).toBe(false);
|
||||
expect(observedFactoryOptions?.onStartedClient).toBeTypeOf("function");
|
||||
expect(observedFactoryOptions?.abandonSignal?.aborted).toBe(true);
|
||||
await peer.abandon();
|
||||
expect(harness.stdinDestroyed).toBe(true);
|
||||
});
|
||||
|
||||
it("clears the shared app-server when cancellation abandons an in-flight thread request", async () => {
|
||||
|
||||
@@ -11,9 +11,15 @@ import {
|
||||
type resolveSandboxContext,
|
||||
} from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import { defaultCodexAppInventoryCache } from "./app-inventory-cache.js";
|
||||
import {
|
||||
CODEX_APP_SERVER_UNSUBSCRIBE_TIMEOUT_MS,
|
||||
CodexAppServerUnsafeSubscriptionError,
|
||||
isCodexAppServerUnsafeSubscriptionError,
|
||||
unsubscribeCodexThreadBestEffort,
|
||||
} from "./attempt-client-cleanup.js";
|
||||
import { buildCodexPluginThreadConfigEligibilityLogData } from "./attempt-diagnostics.js";
|
||||
import { withCodexStartupTimeout } from "./attempt-timeouts.js";
|
||||
import type { CodexAppServerClientFactory } from "./client-factory.js";
|
||||
import { ensureCodexAppServerClientRuntime } from "./client-runtime.js";
|
||||
import { isCodexAppServerConnectionClosedError, type CodexAppServerClient } from "./client.js";
|
||||
import { ensureCodexComputerUse } from "./computer-use.js";
|
||||
import {
|
||||
@@ -48,17 +54,23 @@ import {
|
||||
releaseCodexSandboxExecServerEnvironment,
|
||||
type CodexSandboxExecEnvironment,
|
||||
} from "./sandbox-exec-server.js";
|
||||
import type { CodexAppServerBindingStore } from "./session-binding.js";
|
||||
import {
|
||||
clearSharedCodexAppServerClientIfCurrentAndUnclaimed,
|
||||
clearSharedCodexAppServerClientIfCurrent,
|
||||
releaseLeasedSharedCodexAppServerClient,
|
||||
retireSharedCodexAppServerClientIfCurrent,
|
||||
leaseSharedCodexAppServerClient,
|
||||
type CodexAppServerClientLease,
|
||||
type CodexAppServerClientLeaseFactory,
|
||||
} from "./shared-client.js";
|
||||
import type { CodexAppServerStartupTokenGuard } from "./startup-binding.js";
|
||||
import {
|
||||
startOrResumeThread,
|
||||
type CodexAppServerThreadLifecycleBinding,
|
||||
type CodexContextEngineThreadBootstrapProjection,
|
||||
} from "./thread-lifecycle.js";
|
||||
import {
|
||||
getCodexAppServerTurnRouter,
|
||||
type CodexAppServerTurnRouter,
|
||||
type CodexThreadRouteReservation,
|
||||
} from "./turn-router.js";
|
||||
|
||||
const CODEX_APP_SERVER_STARTUP_CONNECTION_CLOSE_MAX_ATTEMPTS = 3;
|
||||
|
||||
@@ -66,14 +78,15 @@ type CodexSandboxContext = Awaited<ReturnType<typeof resolveSandboxContext>>;
|
||||
|
||||
/** Resources and bindings returned after a Codex attempt thread starts. */
|
||||
export type StartCodexAttemptThreadResult = {
|
||||
client: CodexAppServerClient;
|
||||
turnRouter: CodexAppServerTurnRouter;
|
||||
turnRoute: CodexThreadRouteReservation;
|
||||
thread: CodexAppServerThreadLifecycleBinding;
|
||||
pluginAppServer: CodexAppServerRuntimeOptions;
|
||||
sandboxEnvironment: CodexSandboxExecEnvironment | undefined;
|
||||
environmentSelection: CodexTurnEnvironmentParams[] | undefined;
|
||||
executionCwd: string;
|
||||
sandboxPolicy: CodexSandboxPolicy | undefined;
|
||||
releaseSharedClientLease: () => void;
|
||||
clientLease: CodexAppServerClientLease;
|
||||
mcpElicitationDelegationRequired: boolean;
|
||||
restartContextEngineCodexThread: () => Promise<CodexAppServerThreadLifecycleBinding>;
|
||||
};
|
||||
|
||||
@@ -82,7 +95,8 @@ export type StartCodexAttemptThreadResult = {
|
||||
* run loop must later release.
|
||||
*/
|
||||
export async function startCodexAttemptThread(params: {
|
||||
attemptClientFactory: CodexAppServerClientFactory;
|
||||
bindingStore: CodexAppServerBindingStore;
|
||||
clientLeaseFactory?: CodexAppServerClientLeaseFactory;
|
||||
appServer: CodexAppServerRuntimeOptions;
|
||||
pluginConfig: CodexPluginConfig;
|
||||
computerUseConfig: CodexComputerUseConfig;
|
||||
@@ -105,18 +119,26 @@ export async function startCodexAttemptThread(params: {
|
||||
sandboxExecServerEnabled: boolean;
|
||||
sandbox: CodexSandboxContext;
|
||||
contextEngineProjection: CodexContextEngineThreadBootstrapProjection | undefined;
|
||||
expectedResumeThreadId?: string;
|
||||
startupTokenGuard: CodexAppServerStartupTokenGuard;
|
||||
startupTimeoutMs: number;
|
||||
signal: AbortSignal;
|
||||
onStartupTimeout: () => void | Promise<void>;
|
||||
spawnedBy: EmbeddedRunAttemptParams["spawnedBy"];
|
||||
onThreadReserved?: (client: CodexAppServerClient, threadId: string) => () => void;
|
||||
}): Promise<StartCodexAttemptThreadResult> {
|
||||
let pluginAppServer = params.appServer;
|
||||
let releaseSharedClientLease: (() => void) | undefined;
|
||||
let startupClientForAbandonedRequestCleanup: CodexAppServerClient | undefined;
|
||||
let mcpElicitationDelegationRequired = false;
|
||||
let sharedClientLease: CodexAppServerClientLease | undefined;
|
||||
let releaseStartupResourcesOnTimeout: (() => Promise<void>) | undefined;
|
||||
let startupAbandoned = false;
|
||||
const startupAbandonController = new AbortController();
|
||||
const abandonStartupAcquire = () => startupAbandonController.abort();
|
||||
const abandonStartupClient = async () => {
|
||||
const lease = sharedClientLease;
|
||||
sharedClientLease = undefined;
|
||||
if (lease) {
|
||||
await lease.abandon();
|
||||
}
|
||||
};
|
||||
params.signal.addEventListener("abort", abandonStartupAcquire, { once: true });
|
||||
try {
|
||||
const startupResult = await withCodexStartupTimeout({
|
||||
@@ -127,10 +149,7 @@ export async function startCodexAttemptThread(params: {
|
||||
startupAbandonController.abort();
|
||||
await params.onStartupTimeout();
|
||||
await releaseStartupResourcesOnTimeout?.();
|
||||
releaseSharedClientLease?.();
|
||||
releaseSharedClientLease = undefined;
|
||||
await closeAbandonedStartupClient(startupClientForAbandonedRequestCleanup);
|
||||
startupClientForAbandonedRequestCleanup = undefined;
|
||||
await abandonStartupClient();
|
||||
},
|
||||
operation: async () => {
|
||||
const threadConfig = mergeCodexThreadConfigs(
|
||||
@@ -161,8 +180,9 @@ export async function startCodexAttemptThread(params: {
|
||||
const resolvedPluginPolicy = pluginThreadConfigRequired
|
||||
? resolveCodexPluginsPolicy(pluginThreadConfigPluginConfig)
|
||||
: undefined;
|
||||
const computerUseMcpElicitationDelegationRequired = params.computerUseConfig.enabled;
|
||||
const mcpElicitationDelegationRequired =
|
||||
const computerUseMcpElicitationDelegationRequired =
|
||||
params.computerUseConfig.enabled === true;
|
||||
mcpElicitationDelegationRequired =
|
||||
resolvedPluginPolicy?.enabled === true || computerUseMcpElicitationDelegationRequired;
|
||||
const enabledPluginConfigKeys = resolvedPluginPolicy
|
||||
? resolvedPluginPolicy.pluginPolicies
|
||||
@@ -184,55 +204,48 @@ export async function startCodexAttemptThread(params: {
|
||||
appServer: params.appServer,
|
||||
}),
|
||||
);
|
||||
pluginAppServer = mcpElicitationDelegationRequired
|
||||
const pluginAppServer = mcpElicitationDelegationRequired
|
||||
? {
|
||||
...params.appServer,
|
||||
approvalPolicy: withMcpElicitationsApprovalPolicy(params.appServer.approvalPolicy),
|
||||
}
|
||||
: params.appServer;
|
||||
|
||||
let attemptedClient: CodexAppServerClient | undefined;
|
||||
let attemptedClientAbandoned = false;
|
||||
const startupAttempt = async () => {
|
||||
let startupClientLease: (() => void) | undefined;
|
||||
let startupClient: CodexAppServerClient | undefined;
|
||||
let startupAttemptError: unknown;
|
||||
let startupAttemptSucceeded = false;
|
||||
let startupClientLease: CodexAppServerClientLease | undefined;
|
||||
let clientWorkStarted = false;
|
||||
attemptedClientAbandoned = false;
|
||||
try {
|
||||
startupClient = await params.attemptClientFactory(
|
||||
params.appServer.start,
|
||||
params.startupAuthProfileId,
|
||||
params.agentDir,
|
||||
params.config,
|
||||
{
|
||||
onStartedClient: (client) => {
|
||||
// Timeout cleanup may fire before the client factory resolves;
|
||||
// close any late-arriving client instead of leaking a lease.
|
||||
startupClientForAbandonedRequestCleanup = client;
|
||||
if (startupAbandoned || startupAbandonController.signal.aborted) {
|
||||
void closeAbandonedStartupClient(client);
|
||||
}
|
||||
},
|
||||
abandonSignal: startupAbandonController.signal,
|
||||
startupClientLease = await (
|
||||
params.clientLeaseFactory ?? leaseSharedCodexAppServerClient
|
||||
)({
|
||||
startOptions: params.appServer.start,
|
||||
authProfileId: params.startupAuthProfileId,
|
||||
agentDir: params.agentDir,
|
||||
config: params.config,
|
||||
preparedAuth: {
|
||||
profileId: params.startupAuthProfileId,
|
||||
cacheKey: params.startupAuthAccountCacheKey ?? params.startupEnvApiKeyCacheKey,
|
||||
},
|
||||
);
|
||||
const activeStartupClient = startupClient;
|
||||
let startupClientLeaseReleased = false;
|
||||
startupClientLease = () => {
|
||||
if (startupClientLeaseReleased) {
|
||||
return;
|
||||
}
|
||||
startupClientLeaseReleased = true;
|
||||
releaseLeasedSharedCodexAppServerClient(activeStartupClient);
|
||||
};
|
||||
releaseSharedClientLease = startupClientLease;
|
||||
attemptedClient = activeStartupClient;
|
||||
startupClientForAbandonedRequestCleanup = activeStartupClient;
|
||||
abandonSignal: startupAbandonController.signal,
|
||||
});
|
||||
const activeStartupLease = startupClientLease;
|
||||
const activeStartupClient = activeStartupLease.client;
|
||||
sharedClientLease = startupClientLease;
|
||||
if (startupAbandoned) {
|
||||
throw new Error("codex app-server startup timed out");
|
||||
}
|
||||
if (startupAbandonController.signal.aborted) {
|
||||
throw new Error("codex app-server startup aborted");
|
||||
}
|
||||
clientWorkStarted = true;
|
||||
ensureCodexAppServerClientRuntime(activeStartupClient, {
|
||||
agentDir: params.agentDir,
|
||||
authProfileId: params.startupAuthProfileId,
|
||||
config: params.config,
|
||||
});
|
||||
const turnRouter = getCodexAppServerTurnRouter(activeStartupClient);
|
||||
await ensureCodexComputerUse({
|
||||
client: activeStartupClient,
|
||||
pluginConfig: params.pluginConfig,
|
||||
@@ -264,7 +277,6 @@ export async function startCodexAttemptThread(params: {
|
||||
: undefined;
|
||||
startupSandboxEnvironmentAcquired = Boolean(startupSandboxEnvironment);
|
||||
if (startupAbandonController.signal.aborted) {
|
||||
await releaseStartupSandboxEnvironment();
|
||||
throw new Error("codex app-server startup aborted");
|
||||
}
|
||||
if (
|
||||
@@ -293,9 +305,57 @@ export async function startCodexAttemptThread(params: {
|
||||
const startupSandboxPolicy = startupSandboxEnvironment
|
||||
? resolveCodexExternalSandboxPolicyForOpenClawSandbox(params.sandbox)
|
||||
: undefined;
|
||||
const buildThreadLifecycleParams = (signal: AbortSignal) =>
|
||||
let startupReservation:
|
||||
| { route: CodexThreadRouteReservation; release: () => void }
|
||||
| undefined;
|
||||
const reserveStartupThread = (threadId: string) => {
|
||||
if (startupReservation) {
|
||||
if (startupReservation.route.threadId !== threadId) {
|
||||
throw new Error(
|
||||
`codex app-server reserved ${startupReservation.route.threadId} but started ${threadId}`,
|
||||
);
|
||||
}
|
||||
return { release: startupReservation.release };
|
||||
}
|
||||
const route = turnRouter.reserveThread({
|
||||
threadId,
|
||||
releaseOn: params.signal,
|
||||
});
|
||||
let releaseIntegration: (() => void) | undefined;
|
||||
try {
|
||||
releaseIntegration = params.onThreadReserved?.(activeStartupClient, threadId);
|
||||
} catch (error) {
|
||||
route.release();
|
||||
throw error;
|
||||
}
|
||||
let released = false;
|
||||
const release = () => {
|
||||
if (released) {
|
||||
return;
|
||||
}
|
||||
released = true;
|
||||
if (startupReservation?.route === route) {
|
||||
startupReservation = undefined;
|
||||
}
|
||||
route.release();
|
||||
releaseIntegration?.();
|
||||
};
|
||||
startupReservation = { route, release };
|
||||
return { release };
|
||||
};
|
||||
const releaseStartupResources = async () => {
|
||||
startupReservation?.release();
|
||||
await releaseStartupSandboxEnvironment();
|
||||
};
|
||||
releaseStartupResourcesOnTimeout = releaseStartupResources;
|
||||
const buildThreadLifecycleParams = (
|
||||
signal: AbortSignal,
|
||||
options: { freshStartOnly?: boolean } = {},
|
||||
) =>
|
||||
({
|
||||
client: activeStartupClient,
|
||||
abandonClient: activeStartupLease.abandon,
|
||||
bindingStore: params.bindingStore,
|
||||
params: params.buildAttemptParams(),
|
||||
agentId: params.sessionAgentId,
|
||||
cwd: startupExecutionCwd,
|
||||
@@ -313,7 +373,13 @@ export async function startCodexAttemptThread(params: {
|
||||
mcpServersFingerprintEvaluated: params.bundleMcpThreadConfig.evaluated,
|
||||
environmentSelection: startupEnvironmentSelection,
|
||||
contextEngineProjection: params.contextEngineProjection,
|
||||
freshStartOnly: options.freshStartOnly,
|
||||
expectedResumeThreadId: options.freshStartOnly
|
||||
? undefined
|
||||
: params.expectedResumeThreadId,
|
||||
signal,
|
||||
reserveResumeThread: options.freshStartOnly ? undefined : reserveStartupThread,
|
||||
startupTokenGuard: params.startupTokenGuard,
|
||||
pluginThreadConfig: pluginThreadConfigRequired
|
||||
? {
|
||||
enabled: true,
|
||||
@@ -337,57 +403,65 @@ export async function startCodexAttemptThread(params: {
|
||||
const startupThread = await startOrResumeThread(
|
||||
buildThreadLifecycleParams(startupAbandonController.signal),
|
||||
);
|
||||
try {
|
||||
reserveStartupThread(startupThread.threadId);
|
||||
} catch (error) {
|
||||
const unsubscribed = await unsubscribeCodexThreadBestEffort(activeStartupClient, {
|
||||
threadId: startupThread.threadId,
|
||||
timeoutMs: CODEX_APP_SERVER_UNSUBSCRIBE_TIMEOUT_MS,
|
||||
});
|
||||
if (!unsubscribed) {
|
||||
throw new CodexAppServerUnsafeSubscriptionError(
|
||||
"Codex startup subscription cleanup failed",
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
if (startupAbandonController.signal.aborted) {
|
||||
await releaseStartupSandboxEnvironment();
|
||||
throw new Error("codex app-server startup aborted");
|
||||
}
|
||||
if (!startupReservation) {
|
||||
throw new Error("codex app-server startup did not reserve its thread route");
|
||||
}
|
||||
startupSandboxEnvironmentAcquired = false;
|
||||
startupAttemptSucceeded = true;
|
||||
return {
|
||||
client: activeStartupClient,
|
||||
turnRouter,
|
||||
turnRoute: startupReservation.route,
|
||||
thread: startupThread,
|
||||
sandboxEnvironment: startupSandboxEnvironment,
|
||||
environmentSelection: startupEnvironmentSelection,
|
||||
executionCwd: startupExecutionCwd,
|
||||
sandboxPolicy: startupSandboxPolicy,
|
||||
restartContextEngineCodexThread: () =>
|
||||
startOrResumeThread(buildThreadLifecycleParams(params.signal)),
|
||||
startOrResumeThread(
|
||||
buildThreadLifecycleParams(params.signal, { freshStartOnly: true }),
|
||||
),
|
||||
};
|
||||
} catch (error) {
|
||||
await releaseStartupSandboxEnvironment();
|
||||
await releaseStartupResources();
|
||||
throw error;
|
||||
} finally {
|
||||
if (releaseStartupResourcesOnTimeout === releaseStartupSandboxEnvironment) {
|
||||
if (releaseStartupResourcesOnTimeout === releaseStartupResources) {
|
||||
releaseStartupResourcesOnTimeout = undefined;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
startupAttemptError = error;
|
||||
throw error;
|
||||
} finally {
|
||||
if (!startupAttemptSucceeded) {
|
||||
if (releaseSharedClientLease === startupClientLease) {
|
||||
releaseSharedClientLease = undefined;
|
||||
}
|
||||
startupClientLease?.();
|
||||
if (startupAbandoned || params.signal.aborted) {
|
||||
if (startupClientForAbandonedRequestCleanup === startupClient) {
|
||||
startupClientForAbandonedRequestCleanup = undefined;
|
||||
}
|
||||
await closeAbandonedStartupClient(startupClient);
|
||||
} else if (
|
||||
shouldClearSharedClientAfterStartupRace(startupAttemptError) ||
|
||||
shouldClearSharedClientAfterStartupFailure({
|
||||
error: startupAttemptError,
|
||||
spawnedBy: params.spawnedBy,
|
||||
})
|
||||
) {
|
||||
if (startupClientForAbandonedRequestCleanup === startupClient) {
|
||||
startupClientForAbandonedRequestCleanup = undefined;
|
||||
}
|
||||
await evictFailedStartupClient(startupClient);
|
||||
}
|
||||
if (sharedClientLease === startupClientLease) {
|
||||
sharedClientLease = undefined;
|
||||
}
|
||||
const shouldAbandonStartupClient =
|
||||
clientWorkStarted &&
|
||||
(startupAbandoned ||
|
||||
params.signal.aborted ||
|
||||
isIndeterminateCodexStartupFailure(error));
|
||||
if (shouldAbandonStartupClient) {
|
||||
attemptedClientAbandoned = true;
|
||||
await startupClientLease?.abandon();
|
||||
} else {
|
||||
startupClientLease?.release();
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -402,18 +476,13 @@ export async function startCodexAttemptThread(params: {
|
||||
if (params.signal.aborted || !isCodexAppServerConnectionClosedError(error)) {
|
||||
throw error;
|
||||
}
|
||||
const failedClient = attemptedClient;
|
||||
const clearedSharedClient = clearSharedCodexAppServerClientIfCurrent(failedClient);
|
||||
if (startupClientForAbandonedRequestCleanup === failedClient) {
|
||||
startupClientForAbandonedRequestCleanup = undefined;
|
||||
}
|
||||
if (attempt >= CODEX_APP_SERVER_STARTUP_CONNECTION_CLOSE_MAX_ATTEMPTS) {
|
||||
embeddedAgentLog.warn(
|
||||
"codex app-server connection closed during startup; retries exhausted",
|
||||
{
|
||||
attempt,
|
||||
maxAttempts: CODEX_APP_SERVER_STARTUP_CONNECTION_CLOSE_MAX_ATTEMPTS,
|
||||
clearedSharedClient,
|
||||
abandonedSharedClient: attemptedClientAbandoned,
|
||||
error: formatErrorMessage(error),
|
||||
},
|
||||
);
|
||||
@@ -425,7 +494,7 @@ export async function startCodexAttemptThread(params: {
|
||||
attempt,
|
||||
nextAttempt: attempt + 1,
|
||||
maxAttempts: CODEX_APP_SERVER_STARTUP_CONNECTION_CLOSE_MAX_ATTEMPTS,
|
||||
clearedSharedClient,
|
||||
abandonedSharedClient: attemptedClientAbandoned,
|
||||
error: formatErrorMessage(error),
|
||||
},
|
||||
);
|
||||
@@ -434,32 +503,21 @@ export async function startCodexAttemptThread(params: {
|
||||
throw new Error("codex app-server startup retry loop exited unexpectedly");
|
||||
},
|
||||
});
|
||||
startupClientForAbandonedRequestCleanup = undefined;
|
||||
if (!releaseSharedClientLease) {
|
||||
const completedSharedClientLease = sharedClientLease;
|
||||
if (!completedSharedClientLease) {
|
||||
throw new Error("codex app-server startup succeeded without a shared client lease");
|
||||
}
|
||||
sharedClientLease = undefined;
|
||||
return {
|
||||
...startupResult,
|
||||
pluginAppServer,
|
||||
releaseSharedClientLease,
|
||||
mcpElicitationDelegationRequired,
|
||||
clientLease: completedSharedClientLease,
|
||||
};
|
||||
} catch (error) {
|
||||
if (params.signal.aborted || shouldClearSharedClientAfterStartupAbandon(error)) {
|
||||
releaseSharedClientLease?.();
|
||||
releaseSharedClientLease = undefined;
|
||||
await closeAbandonedStartupClient(startupClientForAbandonedRequestCleanup);
|
||||
startupClientForAbandonedRequestCleanup = undefined;
|
||||
} else if (
|
||||
shouldClearSharedClientAfterStartupRace(error) ||
|
||||
shouldClearSharedClientAfterStartupFailure({
|
||||
error,
|
||||
spawnedBy: params.spawnedBy,
|
||||
})
|
||||
) {
|
||||
releaseSharedClientLease?.();
|
||||
releaseSharedClientLease = undefined;
|
||||
await evictFailedStartupClient(startupClientForAbandonedRequestCleanup);
|
||||
startupClientForAbandonedRequestCleanup = undefined;
|
||||
const shouldAbandonStartupClient =
|
||||
params.signal.aborted || isIndeterminateCodexStartupFailure(error);
|
||||
if (shouldAbandonStartupClient) {
|
||||
await abandonStartupClient();
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
@@ -467,104 +525,13 @@ export async function startCodexAttemptThread(params: {
|
||||
}
|
||||
}
|
||||
|
||||
async function closeAbandonedStartupClient(
|
||||
client: CodexAppServerClient | undefined,
|
||||
): Promise<void> {
|
||||
if (!client) {
|
||||
return;
|
||||
}
|
||||
const unclaimedSharedClient = clearSharedCodexAppServerClientIfCurrentAndUnclaimed(client);
|
||||
if (unclaimedSharedClient.closed) {
|
||||
await closeClientAndWaitIfAvailable(client);
|
||||
return;
|
||||
}
|
||||
if (unclaimedSharedClient.found) {
|
||||
const retired = retireSharedCodexAppServerClientIfCurrent(client);
|
||||
if (retired?.closed) {
|
||||
await closeClientAndWaitIfAvailable(client);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const retiredSharedClient = retireSharedCodexAppServerClientIfCurrent(client);
|
||||
if (retiredSharedClient) {
|
||||
if (retiredSharedClient.closed) {
|
||||
await closeClientAndWaitIfAvailable(client);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (clearSharedCodexAppServerClientIfCurrent(client)) {
|
||||
await closeClientAndWaitIfAvailable(client);
|
||||
return;
|
||||
}
|
||||
await closeClientAndWaitIfAvailable(client);
|
||||
}
|
||||
|
||||
async function closeClientAndWaitIfAvailable(client: CodexAppServerClient): Promise<void> {
|
||||
const closeable = client as {
|
||||
close?: CodexAppServerClient["close"];
|
||||
closeAndWait?: CodexAppServerClient["closeAndWait"];
|
||||
};
|
||||
if (typeof closeable.closeAndWait === "function") {
|
||||
await closeable.closeAndWait();
|
||||
return;
|
||||
}
|
||||
closeable.close?.();
|
||||
}
|
||||
|
||||
async function evictFailedStartupClient(client: CodexAppServerClient | undefined): Promise<void> {
|
||||
if (!client) {
|
||||
return;
|
||||
}
|
||||
const unclaimedSharedClient = clearSharedCodexAppServerClientIfCurrentAndUnclaimed(client);
|
||||
if (unclaimedSharedClient.closed) {
|
||||
await closeClientAndWaitIfAvailable(client);
|
||||
return;
|
||||
}
|
||||
if (unclaimedSharedClient.found) {
|
||||
const retired = retireSharedCodexAppServerClientIfCurrent(client);
|
||||
if (retired?.closed) {
|
||||
await closeClientAndWaitIfAvailable(client);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const retiredSharedClient = retireSharedCodexAppServerClientIfCurrent(client);
|
||||
if (retiredSharedClient) {
|
||||
if (retiredSharedClient.closed) {
|
||||
await closeClientAndWaitIfAvailable(client);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (clearSharedCodexAppServerClientIfCurrent(client)) {
|
||||
await closeClientAndWaitIfAvailable(client);
|
||||
return;
|
||||
}
|
||||
await closeClientAndWaitIfAvailable(client);
|
||||
}
|
||||
|
||||
function shouldClearSharedClientAfterStartupAbandon(error: unknown): boolean {
|
||||
function isIndeterminateCodexStartupFailure(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof Error &&
|
||||
(error.message === "codex app-server startup timed out" ||
|
||||
error.message === "codex app-server startup aborted")
|
||||
isCodexAppServerUnsafeSubscriptionError(error) ||
|
||||
isCodexAppServerConnectionClosedError(error) ||
|
||||
(error instanceof Error &&
|
||||
(error.message.endsWith(" timed out") ||
|
||||
error.message.endsWith(" aborted") ||
|
||||
error.message.includes("write EPIPE")))
|
||||
);
|
||||
}
|
||||
|
||||
function shouldClearSharedClientAfterStartupRace(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof Error &&
|
||||
(shouldClearSharedClientAfterStartupAbandon(error) || error.message.endsWith(" timed out"))
|
||||
);
|
||||
}
|
||||
|
||||
function shouldClearSharedClientAfterStartupFailure(params: {
|
||||
error: unknown;
|
||||
spawnedBy: EmbeddedRunAttemptParams["spawnedBy"];
|
||||
}): boolean {
|
||||
if (!(params.error instanceof Error)) {
|
||||
return !params.spawnedBy;
|
||||
}
|
||||
if (params.error.message.includes("write EPIPE")) {
|
||||
return true;
|
||||
}
|
||||
return !params.spawnedBy;
|
||||
}
|
||||
|
||||
@@ -159,6 +159,39 @@ describe("Codex app-server attempt timeouts", () => {
|
||||
expect(events).toEqual(["cleanup-start", "cleanup-done"]);
|
||||
});
|
||||
|
||||
it("keeps the timeout result when startup resolves during timeout cleanup", async () => {
|
||||
vi.useFakeTimers();
|
||||
const events: string[] = [];
|
||||
let resolveOperation!: (value: string) => void;
|
||||
let finishCleanup!: () => void;
|
||||
const run = withCodexStartupTimeout({
|
||||
timeoutMs: 10,
|
||||
signal: new AbortController().signal,
|
||||
onTimeout: async () => {
|
||||
events.push("cleanup-start");
|
||||
await new Promise<void>((resolve) => {
|
||||
finishCleanup = resolve;
|
||||
});
|
||||
events.push("cleanup-done");
|
||||
},
|
||||
operation: () =>
|
||||
new Promise<string>((resolve) => {
|
||||
resolveOperation = resolve;
|
||||
}),
|
||||
});
|
||||
const rejected = expect(run).rejects.toThrow("codex app-server startup timed out");
|
||||
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
expect(events).toEqual(["cleanup-start"]);
|
||||
resolveOperation("late-ready");
|
||||
await Promise.resolve();
|
||||
expect(events).toEqual(["cleanup-start"]);
|
||||
finishCleanup();
|
||||
|
||||
await rejected;
|
||||
expect(events).toEqual(["cleanup-start", "cleanup-done"]);
|
||||
});
|
||||
|
||||
it("rejects startup timeout when aborted before completion", async () => {
|
||||
vi.useFakeTimers();
|
||||
const controller = new AbortController();
|
||||
|
||||
@@ -52,13 +52,13 @@ export async function withCodexStartupTimeout<T>(params: {
|
||||
};
|
||||
timeout = setTimeout(() => {
|
||||
timeoutError = new Error("codex app-server startup timed out");
|
||||
timeoutCleanup = Promise.resolve(params.onTimeout?.()).then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
void timeoutCleanup.finally(() => {
|
||||
rejectOnce(timeoutError!);
|
||||
});
|
||||
rejectOnce(timeoutError);
|
||||
timeoutCleanup = Promise.resolve()
|
||||
.then(() => params.onTimeout?.())
|
||||
.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
}, params.timeoutMs);
|
||||
const abortListener = () => rejectOnce(new Error("codex app-server startup aborted"));
|
||||
params.signal.addEventListener("abort", abortListener, { once: true });
|
||||
|
||||
@@ -29,7 +29,7 @@ describe("Codex app-server attempt turn watches", () => {
|
||||
const progress: string[] = [];
|
||||
const diagnostics: string[] = [];
|
||||
const controller = createCodexAttemptTurnWatchController({
|
||||
threadId: "thread-1",
|
||||
getThreadId: () => "thread-1",
|
||||
signal: abortController.signal,
|
||||
getTurnId: () => "turn-1",
|
||||
isCompleted: () => completed,
|
||||
|
||||
@@ -29,7 +29,7 @@ export type CodexAttemptTurnWatchController = ReturnType<
|
||||
* notifications and tool handoffs progress.
|
||||
*/
|
||||
export function createCodexAttemptTurnWatchController(params: {
|
||||
threadId: string;
|
||||
getThreadId: () => string;
|
||||
signal: AbortSignal;
|
||||
getTurnId: () => string | undefined;
|
||||
isCompleted: () => boolean;
|
||||
@@ -79,6 +79,7 @@ export function createCodexAttemptTurnWatchController(params: {
|
||||
const turnTerminalIdleTimeoutMs = resolveTimerTimeoutMs(params.turnTerminalIdleTimeoutMs, 1);
|
||||
const interruptTimeoutMs = resolveTimerTimeoutMs(params.interruptTimeoutMs, 1);
|
||||
const resolveWatchTimeoutMs = (timeoutMs: number) => resolveTimerTimeoutMs(timeoutMs, 1);
|
||||
const currentThreadId = () => params.getThreadId();
|
||||
|
||||
const clearCompletionIdleTimer = () => {
|
||||
if (completionIdleTimer) {
|
||||
@@ -227,7 +228,7 @@ export function createCodexAttemptTurnWatchController(params: {
|
||||
clearTerminalIdleTimer();
|
||||
const turnId = params.getTurnId();
|
||||
params.onRecordEvent("turn.assistant_completion_idle_release", {
|
||||
threadId: params.threadId,
|
||||
threadId: currentThreadId(),
|
||||
turnId,
|
||||
idleMs,
|
||||
timeoutMs: turnAssistantCompletionIdleTimeoutMs,
|
||||
@@ -236,7 +237,7 @@ export function createCodexAttemptTurnWatchController(params: {
|
||||
embeddedAgentLog.warn(
|
||||
"codex app-server turn released after completed assistant item without terminal event",
|
||||
{
|
||||
threadId: params.threadId,
|
||||
threadId: currentThreadId(),
|
||||
turnId,
|
||||
idleMs,
|
||||
timeoutMs: turnAssistantCompletionIdleTimeoutMs,
|
||||
@@ -245,7 +246,7 @@ export function createCodexAttemptTurnWatchController(params: {
|
||||
);
|
||||
if (turnId) {
|
||||
params.onInterruptTurn({
|
||||
threadId: params.threadId,
|
||||
threadId: currentThreadId(),
|
||||
turnId,
|
||||
timeoutMs: interruptTimeoutMs,
|
||||
});
|
||||
@@ -278,7 +279,7 @@ export function createCodexAttemptTurnWatchController(params: {
|
||||
params.onTimeout(timeout);
|
||||
params.onMarkTimedOut();
|
||||
params.onRecordEvent("turn.progress_idle_timeout", {
|
||||
threadId: params.threadId,
|
||||
threadId: currentThreadId(),
|
||||
turnId: params.getTurnId(),
|
||||
idleMs,
|
||||
timeoutMs: timeout.timeoutMs,
|
||||
@@ -286,7 +287,7 @@ export function createCodexAttemptTurnWatchController(params: {
|
||||
...timeout.details,
|
||||
});
|
||||
embeddedAgentLog.warn("codex app-server turn idle timed out waiting for progress", {
|
||||
threadId: params.threadId,
|
||||
threadId: currentThreadId(),
|
||||
turnId: params.getTurnId(),
|
||||
idleMs,
|
||||
timeoutMs: timeout.timeoutMs,
|
||||
@@ -331,7 +332,7 @@ export function createCodexAttemptTurnWatchController(params: {
|
||||
params.onTimeout(timeout);
|
||||
params.onMarkTimedOut();
|
||||
params.onRecordEvent("turn.completion_idle_timeout", {
|
||||
threadId: params.threadId,
|
||||
threadId: currentThreadId(),
|
||||
turnId: params.getTurnId(),
|
||||
idleMs,
|
||||
timeoutMs,
|
||||
@@ -339,7 +340,7 @@ export function createCodexAttemptTurnWatchController(params: {
|
||||
...timeout.details,
|
||||
});
|
||||
embeddedAgentLog.warn("codex app-server turn idle timed out waiting for completion", {
|
||||
threadId: params.threadId,
|
||||
threadId: currentThreadId(),
|
||||
turnId: params.getTurnId(),
|
||||
idleMs,
|
||||
timeoutMs,
|
||||
@@ -374,7 +375,7 @@ export function createCodexAttemptTurnWatchController(params: {
|
||||
params.onTimeout(timeout);
|
||||
params.onMarkTimedOut();
|
||||
params.onRecordEvent("turn.terminal_idle_timeout", {
|
||||
threadId: params.threadId,
|
||||
threadId: currentThreadId(),
|
||||
turnId: params.getTurnId(),
|
||||
idleMs,
|
||||
timeoutMs: timeout.timeoutMs,
|
||||
@@ -382,7 +383,7 @@ export function createCodexAttemptTurnWatchController(params: {
|
||||
...timeout.details,
|
||||
});
|
||||
embeddedAgentLog.warn("codex app-server turn idle timed out waiting for terminal event", {
|
||||
threadId: params.threadId,
|
||||
threadId: currentThreadId(),
|
||||
turnId: params.getTurnId(),
|
||||
idleMs,
|
||||
timeoutMs: timeout.timeoutMs,
|
||||
@@ -457,9 +458,11 @@ export function createCodexAttemptTurnWatchController(params: {
|
||||
details?: Record<string, unknown>;
|
||||
attemptProgress?: boolean;
|
||||
attemptTimeoutMs?: number;
|
||||
receivedAtMs?: number;
|
||||
},
|
||||
) => {
|
||||
completionLastActivityAt = Date.now();
|
||||
const now = Date.now();
|
||||
completionLastActivityAt = Math.min(now, options?.receivedAtMs ?? now);
|
||||
completionLastActivityReason = `notification:${method}`;
|
||||
if (options?.details !== undefined) {
|
||||
completionLastActivityDetails = options.details;
|
||||
|
||||
@@ -8,37 +8,56 @@ import {
|
||||
} from "openclaw/plugin-sdk/agent-harness";
|
||||
import { AUTH_PROFILE_RUNTIME_CONTRACT } from "openclaw/plugin-sdk/agent-runtime-test-contracts";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { CodexAppServerClientFactory } from "./client-factory.js";
|
||||
import { runCodexAppServerAttempt as runCodexAppServerAttemptImpl } from "./run-attempt.js";
|
||||
import { readCodexAppServerBinding, writeCodexAppServerBinding } from "./session-binding.js";
|
||||
import { createCodexTestModel } from "./test-support.js";
|
||||
import {
|
||||
readCodexAppServerBinding,
|
||||
registerCodexTestSessionIdentity,
|
||||
resetCodexTestBindingStore,
|
||||
testCodexAppServerBindingStore,
|
||||
writeCodexAppServerBinding,
|
||||
} from "./session-binding.test-helpers.js";
|
||||
import type { CodexAppServerClientLeaseFactory } from "./shared-client.js";
|
||||
import {
|
||||
adaptCodexTestClientFactory,
|
||||
createCodexTestModel,
|
||||
type CodexTestAppServerClientFactory,
|
||||
} from "./test-support.js";
|
||||
|
||||
let codexAppServerClientFactoryForTest: CodexAppServerClientFactory | undefined;
|
||||
let codexAppServerClientLeaseFactoryForTest: CodexAppServerClientLeaseFactory | undefined;
|
||||
|
||||
type RunCodexAppServerAttemptOptions = NonNullable<
|
||||
type RunCodexAppServerAttemptImplOptions = NonNullable<
|
||||
Parameters<typeof runCodexAppServerAttemptImpl>[1]
|
||||
>;
|
||||
type RunCodexAppServerAttemptOptions = Omit<RunCodexAppServerAttemptImplOptions, "bindingStore"> & {
|
||||
bindingStore?: RunCodexAppServerAttemptImplOptions["bindingStore"];
|
||||
};
|
||||
|
||||
function setCodexAppServerClientFactoryForTest(factory: CodexAppServerClientFactory): void {
|
||||
codexAppServerClientFactoryForTest = factory;
|
||||
function setCodexAppServerClientFactoryForTest(factory: CodexTestAppServerClientFactory): void {
|
||||
codexAppServerClientLeaseFactoryForTest = adaptCodexTestClientFactory(factory);
|
||||
}
|
||||
|
||||
function resetCodexAppServerClientFactoryForTest(): void {
|
||||
codexAppServerClientFactoryForTest = undefined;
|
||||
codexAppServerClientLeaseFactoryForTest = undefined;
|
||||
}
|
||||
|
||||
function runCodexAppServerAttempt(
|
||||
params: EmbeddedRunAttemptParams,
|
||||
options: RunCodexAppServerAttemptOptions = {},
|
||||
) {
|
||||
const clientFactory = options.clientFactory ?? codexAppServerClientFactoryForTest;
|
||||
return runCodexAppServerAttemptImpl(
|
||||
params,
|
||||
clientFactory ? { ...options, clientFactory } : options,
|
||||
);
|
||||
const clientLeaseFactory = options.clientLeaseFactory ?? codexAppServerClientLeaseFactoryForTest;
|
||||
return runCodexAppServerAttemptImpl(params, {
|
||||
...options,
|
||||
bindingStore: options.bindingStore ?? testCodexAppServerBindingStore,
|
||||
...(clientLeaseFactory ? { clientLeaseFactory } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
function createParams(sessionFile: string, workspaceDir: string): EmbeddedRunAttemptParams {
|
||||
registerCodexTestSessionIdentity(
|
||||
sessionFile,
|
||||
AUTH_PROFILE_RUNTIME_CONTRACT.sessionId,
|
||||
AUTH_PROFILE_RUNTIME_CONTRACT.sessionKey,
|
||||
);
|
||||
return {
|
||||
prompt: AUTH_PROFILE_RUNTIME_CONTRACT.workspacePrompt,
|
||||
sessionId: AUTH_PROFILE_RUNTIME_CONTRACT.sessionId,
|
||||
@@ -111,7 +130,8 @@ function createCodexAuthProfileHarness(params: { startMethod: "thread/start" | "
|
||||
const seenAuthProfileIds: Array<string | undefined> = [];
|
||||
const seenAgentDirs: Array<string | undefined> = [];
|
||||
const requests: Array<{ method: string; params: unknown }> = [];
|
||||
let notify: (notification: unknown) => Promise<void> = async () => undefined;
|
||||
const notificationHandlers = new Set<(notification: unknown) => Promise<void> | void>();
|
||||
const requestHandlers = new Set<(request: unknown) => unknown>();
|
||||
setCodexAppServerClientFactoryForTest(async (_startOptions, authProfileId, agentDir) => {
|
||||
seenAuthProfileIds.push(authProfileId);
|
||||
seenAgentDirs.push(agentDir);
|
||||
@@ -126,13 +146,22 @@ function createCodexAuthProfileHarness(params: { startMethod: "thread/start" | "
|
||||
}
|
||||
throw new Error(`unexpected method: ${method}`);
|
||||
}),
|
||||
addNotificationHandler: (handler: (notification: unknown) => Promise<void>) => {
|
||||
notify = handler;
|
||||
return () => undefined;
|
||||
addNotificationHandler: (handler: (notification: unknown) => Promise<void> | void) => {
|
||||
notificationHandlers.add(handler);
|
||||
return () => notificationHandlers.delete(handler);
|
||||
},
|
||||
addRequestHandler: () => () => undefined,
|
||||
addRequestHandler: (handler: (request: unknown) => unknown) => {
|
||||
requestHandlers.add(handler);
|
||||
return () => requestHandlers.delete(handler);
|
||||
},
|
||||
addCloseHandler: () => () => undefined,
|
||||
} as never;
|
||||
});
|
||||
const notify = async (notification: unknown) => {
|
||||
await Promise.all(
|
||||
[...notificationHandlers].map((handler) => Promise.resolve(handler(notification))),
|
||||
);
|
||||
};
|
||||
return {
|
||||
seenAuthProfileIds,
|
||||
seenAgentDirs,
|
||||
@@ -158,6 +187,7 @@ describe("Auth profile runtime contract - Codex app-server adapter", () => {
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
resetCodexTestBindingStore();
|
||||
vi.useRealTimers();
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-auth-contract-"));
|
||||
});
|
||||
@@ -193,6 +223,7 @@ describe("Auth profile runtime contract - Codex app-server adapter", () => {
|
||||
it("reuses a bound OpenAI Codex auth profile when resume params omit authProfileId", async () => {
|
||||
const harness = createCodexAuthProfileHarness({ startMethod: "thread/resume" });
|
||||
const sessionFile = path.join(tmpDir, "session.jsonl");
|
||||
const params = createParams(sessionFile, tmpDir);
|
||||
await writeCodexAppServerBinding(sessionFile, {
|
||||
threadId: "thread-auth-contract",
|
||||
cwd: tmpDir,
|
||||
@@ -200,7 +231,6 @@ describe("Auth profile runtime contract - Codex app-server adapter", () => {
|
||||
dynamicToolsFingerprint: "[]",
|
||||
});
|
||||
// authProfileId is intentionally omitted to exercise the resume-bound profile path.
|
||||
const params = createParams(sessionFile, tmpDir);
|
||||
|
||||
const run = runCodexAppServerAttempt(params);
|
||||
await vi.waitFor(
|
||||
@@ -218,13 +248,13 @@ describe("Auth profile runtime contract - Codex app-server adapter", () => {
|
||||
it("prefers an explicit runtime auth profile over a stale persisted binding", async () => {
|
||||
const harness = createCodexAuthProfileHarness({ startMethod: "thread/resume" });
|
||||
const sessionFile = path.join(tmpDir, "session.jsonl");
|
||||
const params = createParams(sessionFile, tmpDir);
|
||||
await writeCodexAppServerBinding(sessionFile, {
|
||||
threadId: "thread-auth-contract",
|
||||
cwd: tmpDir,
|
||||
authProfileId: "openai:stale",
|
||||
dynamicToolsFingerprint: "[]",
|
||||
});
|
||||
const params = createParams(sessionFile, tmpDir);
|
||||
params.authProfileId = AUTH_PROFILE_RUNTIME_CONTRACT.openAiCodexProfileId;
|
||||
|
||||
const run = runCodexAppServerAttempt(params);
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
/**
|
||||
* Lazy factories for shared and leased Codex app-server clients.
|
||||
*/
|
||||
import type { resolveCodexAppServerAuthProfileIdForAgent } from "./auth-bridge.js";
|
||||
import type { CodexAppServerClient } from "./client.js";
|
||||
import type { CodexAppServerStartOptions } from "./config.js";
|
||||
|
||||
type AuthProfileOrderConfig = Parameters<
|
||||
typeof resolveCodexAppServerAuthProfileIdForAgent
|
||||
>[0]["config"];
|
||||
|
||||
/** Factory signature used by Codex attempt startup to acquire a client. */
|
||||
export type CodexAppServerClientFactory = (
|
||||
startOptions?: CodexAppServerStartOptions,
|
||||
authProfileId?: string,
|
||||
agentDir?: string,
|
||||
config?: AuthProfileOrderConfig,
|
||||
options?: {
|
||||
onStartedClient?: (client: CodexAppServerClient) => void;
|
||||
abandonSignal?: AbortSignal;
|
||||
},
|
||||
) => Promise<CodexAppServerClient>;
|
||||
|
||||
let sharedClientModulePromise: Promise<typeof import("./shared-client.js")> | null = null;
|
||||
|
||||
const loadSharedClientModule = async () => {
|
||||
sharedClientModulePromise ??= import("./shared-client.js");
|
||||
return await sharedClientModulePromise;
|
||||
};
|
||||
|
||||
/** Returns the process-shared app-server client for normal attempt reuse. */
|
||||
export const defaultCodexAppServerClientFactory: CodexAppServerClientFactory = (
|
||||
startOptions,
|
||||
authProfileId,
|
||||
agentDir,
|
||||
config,
|
||||
options,
|
||||
) =>
|
||||
loadSharedClientModule().then(({ getSharedCodexAppServerClient }) =>
|
||||
getSharedCodexAppServerClient({
|
||||
startOptions,
|
||||
authProfileId,
|
||||
agentDir,
|
||||
config,
|
||||
onStartedClient: options?.onStartedClient,
|
||||
abandonSignal: options?.abandonSignal,
|
||||
}),
|
||||
);
|
||||
|
||||
/** Returns a leased shared client so startup can release ownership explicitly. */
|
||||
export const defaultLeasedCodexAppServerClientFactory: CodexAppServerClientFactory = (
|
||||
startOptions,
|
||||
authProfileId,
|
||||
agentDir,
|
||||
config,
|
||||
options,
|
||||
) =>
|
||||
loadSharedClientModule().then(({ getLeasedSharedCodexAppServerClient }) =>
|
||||
getLeasedSharedCodexAppServerClient({
|
||||
startOptions,
|
||||
authProfileId,
|
||||
agentDir,
|
||||
config,
|
||||
onStartedClient: options?.onStartedClient,
|
||||
abandonSignal: options?.abandonSignal,
|
||||
}),
|
||||
);
|
||||
78
extensions/codex/src/app-server/client-runtime.test.ts
Normal file
78
extensions/codex/src/app-server/client-runtime.test.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { CodexAppServerClient } from "./client.js";
|
||||
import { createClientHarness } from "./test-support.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
refreshAuth: vi.fn(async () => ({ accessToken: "refreshed", chatgptAccountId: "account" })),
|
||||
mergeRateLimitUpdate: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./auth-bridge.js", () => ({
|
||||
refreshCodexAppServerAuthTokens: mocks.refreshAuth,
|
||||
}));
|
||||
|
||||
vi.mock("./rate-limit-cache.js", () => ({
|
||||
mergeCodexRateLimitsUpdate: mocks.mergeRateLimitUpdate,
|
||||
}));
|
||||
|
||||
const { ensureCodexAppServerClientRuntime } = await import("./client-runtime.js");
|
||||
|
||||
describe("Codex app-server client runtime", () => {
|
||||
const clients: CodexAppServerClient[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const client of clients) {
|
||||
client.close();
|
||||
}
|
||||
clients.length = 0;
|
||||
mocks.refreshAuth.mockClear();
|
||||
mocks.mergeRateLimitUpdate.mockClear();
|
||||
});
|
||||
|
||||
it("installs shared handlers once per physical client", async () => {
|
||||
const harness = createClientHarness();
|
||||
clients.push(harness.client);
|
||||
const context = {
|
||||
agentDir: "/tmp/agent",
|
||||
authProfileId: "openai:default",
|
||||
config: {},
|
||||
};
|
||||
const updatedContext = {
|
||||
...context,
|
||||
authProfileStore: { version: 1 as const, profiles: {} },
|
||||
config: { models: { mode: "merge" as const } },
|
||||
};
|
||||
const addNotificationHandler = vi.spyOn(harness.client, "addNotificationHandler");
|
||||
const addRequestHandler = vi.spyOn(harness.client, "addRequestHandler");
|
||||
const addCloseHandler = vi.spyOn(harness.client, "addCloseHandler");
|
||||
|
||||
ensureCodexAppServerClientRuntime(harness.client, context);
|
||||
ensureCodexAppServerClientRuntime(harness.client, updatedContext);
|
||||
|
||||
expect(addNotificationHandler).toHaveBeenCalledTimes(1);
|
||||
expect(addRequestHandler).toHaveBeenCalledTimes(1);
|
||||
expect(addCloseHandler).not.toHaveBeenCalled();
|
||||
harness.send({
|
||||
method: "account/rateLimits/updated",
|
||||
params: { rateLimits: { primary: { usedPercent: 12 } } },
|
||||
});
|
||||
harness.send({
|
||||
id: "refresh-1",
|
||||
method: "account/chatgptAuthTokens/refresh",
|
||||
params: { reason: "expired" },
|
||||
});
|
||||
|
||||
await vi.waitFor(() => expect(mocks.mergeRateLimitUpdate).toHaveBeenCalledTimes(1));
|
||||
await vi.waitFor(() => expect(mocks.refreshAuth).toHaveBeenCalledTimes(1));
|
||||
expect(mocks.refreshAuth).toHaveBeenCalledWith(updatedContext);
|
||||
expect(mocks.mergeRateLimitUpdate).toHaveBeenCalledWith(harness.client, {
|
||||
rateLimits: { primary: { usedPercent: 12 } },
|
||||
});
|
||||
await vi.waitFor(() =>
|
||||
expect(harness.writes.map((line) => JSON.parse(line) as unknown)).toContainEqual({
|
||||
id: "refresh-1",
|
||||
result: { accessToken: "refreshed", chatgptAccountId: "account" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
50
extensions/codex/src/app-server/client-runtime.ts
Normal file
50
extensions/codex/src/app-server/client-runtime.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
/** Client-scoped Codex auth and account observers. */
|
||||
import { refreshCodexAppServerAuthTokens } from "./auth-bridge.js";
|
||||
import type { CodexAppServerClient } from "./client.js";
|
||||
import type { JsonValue } from "./protocol.js";
|
||||
import { mergeCodexRateLimitsUpdate } from "./rate-limit-cache.js";
|
||||
import type { CodexAppServerAuthProfileLookup } from "./session-binding.js";
|
||||
|
||||
type ClientRuntimeContext = Omit<CodexAppServerAuthProfileLookup, "agentDir"> & {
|
||||
agentDir: string;
|
||||
};
|
||||
|
||||
type ClientRuntime = {
|
||||
context: ClientRuntimeContext;
|
||||
};
|
||||
|
||||
const configuredClients = new WeakMap<CodexAppServerClient, ClientRuntime>();
|
||||
|
||||
/** Installs one auth-refresh handler and one rate-limit observer per physical client. */
|
||||
export function ensureCodexAppServerClientRuntime(
|
||||
client: CodexAppServerClient,
|
||||
context: ClientRuntimeContext,
|
||||
): void {
|
||||
const existing = configuredClients.get(client);
|
||||
if (existing) {
|
||||
// Shared-client keys already isolate agent/auth identity. Keep config fresh
|
||||
// without installing another physical-client handler set.
|
||||
existing.context = context;
|
||||
return;
|
||||
}
|
||||
const runtime: ClientRuntime = { context };
|
||||
configuredClients.set(client, runtime);
|
||||
client.addRequestHandler(async (request) => {
|
||||
if (request.method !== "account/chatgptAuthTokens/refresh") {
|
||||
return undefined;
|
||||
}
|
||||
return (await refreshCodexAppServerAuthTokens({
|
||||
agentDir: runtime.context.agentDir,
|
||||
authProfileId: runtime.context.authProfileId,
|
||||
...(runtime.context.authProfileStore
|
||||
? { authProfileStore: runtime.context.authProfileStore }
|
||||
: {}),
|
||||
config: runtime.context.config,
|
||||
})) as unknown as JsonValue;
|
||||
});
|
||||
client.addNotificationHandler((notification) => {
|
||||
if (notification.method === "account/rateLimits/updated") {
|
||||
mergeCodexRateLimitsUpdate(client, notification.params);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -50,6 +50,78 @@ describe("CodexAppServerClient", () => {
|
||||
expect(outbound.method).toBe("model/list");
|
||||
});
|
||||
|
||||
it("keeps a shared thread subscribed until every local owner releases it", async () => {
|
||||
const harness = createClientHarness();
|
||||
clients.push(harness.client);
|
||||
|
||||
const firstResume = harness.client.request("thread/resume", { threadId: "thread-1" });
|
||||
const secondResume = harness.client.request("thread/resume", { threadId: "thread-1" });
|
||||
const [firstRequest, secondRequest] = harness.writes.map((line) => JSON.parse(line)) as Array<{
|
||||
id: number;
|
||||
}>;
|
||||
const resumeResult = {
|
||||
thread: { id: "thread-1", cwd: "/tmp", status: { type: "idle" } },
|
||||
model: "gpt-5.5",
|
||||
};
|
||||
harness.send({ id: firstRequest?.id, result: resumeResult });
|
||||
harness.send({ id: secondRequest?.id, result: resumeResult });
|
||||
await Promise.all([firstResume, secondResume]);
|
||||
|
||||
await expect(
|
||||
harness.client.request("thread/unsubscribe", { threadId: "thread-1" }),
|
||||
).resolves.toEqual({ status: "unsubscribed" });
|
||||
expect(harness.writes).toHaveLength(2);
|
||||
|
||||
const finalRelease = harness.client.request("thread/unsubscribe", {
|
||||
threadId: "thread-1",
|
||||
});
|
||||
const releaseRequest = JSON.parse(harness.writes[2] ?? "{}") as { id?: number };
|
||||
harness.send({ id: releaseRequest.id, result: { status: "unsubscribed" } });
|
||||
await expect(finalRelease).resolves.toEqual({ status: "unsubscribed" });
|
||||
expect(harness.writes).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("pairs written resume failures without retaining pre-aborted requests", async () => {
|
||||
const harness = createClientHarness();
|
||||
clients.push(harness.client);
|
||||
|
||||
const firstResume = harness.client.request("thread/resume", { threadId: "thread-1" });
|
||||
const firstRequest = JSON.parse(harness.writes[0] ?? "{}") as { id?: number };
|
||||
harness.send({
|
||||
id: firstRequest.id,
|
||||
result: {
|
||||
thread: { id: "thread-1", cwd: "/tmp", status: { type: "idle" } },
|
||||
model: "gpt-5.5",
|
||||
},
|
||||
});
|
||||
await firstResume;
|
||||
|
||||
const failedResume = harness.client.request("thread/resume", { threadId: "thread-1" });
|
||||
const failedRequest = JSON.parse(harness.writes[1] ?? "{}") as { id?: number };
|
||||
harness.send({ id: failedRequest.id, error: { code: -32000, message: "resume failed" } });
|
||||
await expect(failedResume).rejects.toThrow("resume failed");
|
||||
|
||||
await expect(
|
||||
harness.client.request("thread/unsubscribe", { threadId: "thread-1" }),
|
||||
).resolves.toEqual({ status: "unsubscribed" });
|
||||
expect(harness.writes).toHaveLength(2);
|
||||
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
await expect(
|
||||
harness.client.request(
|
||||
"thread/resume",
|
||||
{ threadId: "thread-1" },
|
||||
{ signal: controller.signal },
|
||||
),
|
||||
).rejects.toThrow("thread/resume aborted");
|
||||
const unsubscribe = harness.client.request("thread/unsubscribe", { threadId: "thread-1" });
|
||||
expect(harness.writes).toHaveLength(3);
|
||||
const unsubscribeRequest = JSON.parse(harness.writes[2] ?? "{}") as { id?: number };
|
||||
harness.send({ id: unsubscribeRequest.id, result: { status: "unsubscribed" } });
|
||||
await expect(unsubscribe).resolves.toEqual({ status: "unsubscribed" });
|
||||
});
|
||||
|
||||
it("removes unpaired surrogate code units from outbound JSON-RPC strings", async () => {
|
||||
const harness = createClientHarness();
|
||||
clients.push(harness.client);
|
||||
@@ -70,9 +142,9 @@ describe("CodexAppServerClient", () => {
|
||||
expect(outbound.params?.nested).toEqual(["lowend", "emoji 🙈 ok"]);
|
||||
harness.send({
|
||||
id: JSON.parse(harness.writes[0] ?? "{}").id,
|
||||
result: { threadId: "thread-1" },
|
||||
result: { thread: { id: "thread-1" } },
|
||||
});
|
||||
await expect(request).resolves.toEqual({ threadId: "thread-1" });
|
||||
await expect(request).resolves.toEqual({ thread: { id: "thread-1" } });
|
||||
});
|
||||
|
||||
it("logs a redacted preview for malformed app-server messages", async () => {
|
||||
@@ -140,6 +212,30 @@ describe("CodexAppServerClient", () => {
|
||||
expect(warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("contains synchronous notification handler failures and continues fanout", async () => {
|
||||
const warn = vi.spyOn(embeddedAgentLog, "warn").mockImplementation(() => undefined);
|
||||
const harness = createClientHarness();
|
||||
clients.push(harness.client);
|
||||
const laterHandler = vi.fn();
|
||||
harness.client.addNotificationHandler(() => {
|
||||
throw new Error("handler exploded");
|
||||
});
|
||||
harness.client.addNotificationHandler(laterHandler);
|
||||
|
||||
expect(() =>
|
||||
harness.send({
|
||||
method: "item/commandExecution/outputDelta",
|
||||
params: { delta: "still routed" },
|
||||
}),
|
||||
).not.toThrow();
|
||||
|
||||
await vi.waitFor(() => expect(laterHandler).toHaveBeenCalledTimes(1));
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
"codex app-server notification handler failed",
|
||||
expect.objectContaining({ error: expect.any(Error) }),
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves JSON-RPC error codes", async () => {
|
||||
const harness = createClientHarness();
|
||||
clients.push(harness.client);
|
||||
@@ -220,6 +316,95 @@ describe("CodexAppServerClient", () => {
|
||||
expect(harness.writes).toHaveLength(1);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
method: "thread/start" as const,
|
||||
params: {},
|
||||
abandonment: "timeout" as const,
|
||||
expectedError: "thread/start timed out",
|
||||
},
|
||||
{
|
||||
method: "thread/fork" as const,
|
||||
params: { threadId: "parent-thread" },
|
||||
abandonment: "abort" as const,
|
||||
expectedError: "thread/fork aborted",
|
||||
},
|
||||
])("unsubscribes a late successful $method after local $abandonment", async (testCase) => {
|
||||
vi.useFakeTimers();
|
||||
const harness = createClientHarness();
|
||||
clients.push(harness.client);
|
||||
const controller = new AbortController();
|
||||
const options =
|
||||
testCase.abandonment === "timeout" ? { timeoutMs: 1 } : { signal: controller.signal };
|
||||
const request = harness.client.request(testCase.method, testCase.params, options);
|
||||
const outbound = JSON.parse(harness.writes[0] ?? "{}") as { id?: number };
|
||||
const rejected = expect(request).rejects.toThrow(testCase.expectedError);
|
||||
|
||||
if (testCase.abandonment === "timeout") {
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
} else {
|
||||
controller.abort();
|
||||
}
|
||||
await rejected;
|
||||
|
||||
harness.send({ id: outbound.id, result: { thread: { id: "late-thread" } } });
|
||||
expect(JSON.parse(harness.writes[1] ?? "{}")).toEqual({
|
||||
id: expect.any(Number),
|
||||
method: "thread/unsubscribe",
|
||||
params: { threadId: "late-thread" },
|
||||
});
|
||||
});
|
||||
|
||||
it("closes when a late thread creation subscription cannot be released", async () => {
|
||||
const harness = createClientHarness();
|
||||
clients.push(harness.client);
|
||||
const controller = new AbortController();
|
||||
const request = harness.client.request("thread/start", {}, { signal: controller.signal });
|
||||
const outbound = JSON.parse(harness.writes[0] ?? "{}") as { id?: number };
|
||||
const rejected = expect(request).rejects.toThrow("thread/start aborted");
|
||||
|
||||
controller.abort();
|
||||
await rejected;
|
||||
harness.send({ id: outbound.id, result: { thread: { id: "late-thread" } } });
|
||||
const unsubscribe = JSON.parse(harness.writes[1] ?? "{}") as { id?: number };
|
||||
harness.send({
|
||||
id: unsubscribe.id,
|
||||
error: { code: -32_000, message: "unsubscribe failed" },
|
||||
});
|
||||
|
||||
await vi.waitFor(() => expect(harness.stdinDestroyed).toBe(true));
|
||||
});
|
||||
|
||||
it("does not unsubscribe a late rejected thread creation", async () => {
|
||||
const harness = createClientHarness();
|
||||
clients.push(harness.client);
|
||||
const controller = new AbortController();
|
||||
const request = harness.client.request("thread/start", {}, { signal: controller.signal });
|
||||
const outbound = JSON.parse(harness.writes[0] ?? "{}") as { id?: number };
|
||||
const rejected = expect(request).rejects.toThrow("thread/start aborted");
|
||||
|
||||
controller.abort();
|
||||
await rejected;
|
||||
harness.send({ id: outbound.id, error: { code: -32000, message: "start failed" } });
|
||||
|
||||
expect(harness.writes).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("closes after the bounded late-creation cleanup ledger fills", async () => {
|
||||
const harness = createClientHarness();
|
||||
clients.push(harness.client);
|
||||
|
||||
for (let index = 0; index < 129; index += 1) {
|
||||
const controller = new AbortController();
|
||||
const request = harness.client.request("thread/start", {}, { signal: controller.signal });
|
||||
const rejected = expect(request).rejects.toThrow("thread/start aborted");
|
||||
controller.abort();
|
||||
await rejected;
|
||||
}
|
||||
|
||||
expect(harness.stdinDestroyed).toBe(true);
|
||||
});
|
||||
|
||||
it("initializes with the required client version", async () => {
|
||||
const { harness, initializing, outbound } = startInitialize();
|
||||
harness.send({
|
||||
@@ -516,6 +701,26 @@ describe("CodexAppServerClient", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["execCommandApproval", "applyPatchApproval"])(
|
||||
"fails closed for unhandled legacy %s requests",
|
||||
async (method) => {
|
||||
const harness = createClientHarness();
|
||||
clients.push(harness.client);
|
||||
|
||||
harness.send({
|
||||
id: "legacy-approval-1",
|
||||
method,
|
||||
params: { conversationId: "thread-1" },
|
||||
});
|
||||
await vi.waitFor(() => expect(harness.writes.length).toBe(1));
|
||||
|
||||
expect(JSON.parse(harness.writes[0] ?? "{}")).toEqual({
|
||||
id: "legacy-approval-1",
|
||||
result: { decision: "denied" },
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("fails closed for unhandled native app-server approvals", async () => {
|
||||
const harness = createClientHarness();
|
||||
clients.push(harness.client);
|
||||
@@ -533,6 +738,41 @@ describe("CodexAppServerClient", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
"item/tool/call",
|
||||
{
|
||||
contentItems: [
|
||||
{
|
||||
type: "inputText",
|
||||
text: "OpenClaw did not register a handler for this app-server tool call.",
|
||||
},
|
||||
],
|
||||
success: false,
|
||||
},
|
||||
],
|
||||
["item/permissions/requestApproval", { permissions: {}, scope: "turn" }],
|
||||
["mcpServer/elicitation/request", { action: "decline" }],
|
||||
[
|
||||
"item/future/requestApproval",
|
||||
{
|
||||
decision: "decline",
|
||||
reason: "OpenClaw codex app-server bridge does not grant unknown native approvals.",
|
||||
},
|
||||
],
|
||||
])("fails closed for an unhandled %s request", async (method, expected) => {
|
||||
const harness = createClientHarness();
|
||||
clients.push(harness.client);
|
||||
|
||||
harness.send({ id: "unhandled-1", method, params: { threadId: "thread-1" } });
|
||||
await vi.waitFor(() => expect(harness.writes.length).toBe(1));
|
||||
|
||||
expect(JSON.parse(harness.writes[0] ?? "{}")).toEqual({
|
||||
id: "unhandled-1",
|
||||
result: expected,
|
||||
});
|
||||
});
|
||||
|
||||
it("only treats known Codex app-server approval methods as approvals", () => {
|
||||
expect(isCodexAppServerApprovalRequest("item/commandExecution/requestApproval")).toBe(true);
|
||||
expect(isCodexAppServerApprovalRequest("item/fileChange/requestApproval")).toBe(true);
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
type CodexInitializeParams,
|
||||
type CodexInitializeResponse,
|
||||
isRpcResponse,
|
||||
readCodexThreadCreationResponseId,
|
||||
type CodexServerNotification,
|
||||
type JsonValue,
|
||||
type RpcMessage,
|
||||
@@ -34,6 +35,8 @@ const CODEX_APP_SERVER_PARSE_BUFFER_MAX = 1_000_000;
|
||||
const CODEX_APP_SERVER_PARSE_BUFFER_MAX_LINES = 1_000;
|
||||
const CODEX_DYNAMIC_TOOL_SERVER_REQUEST_TIMEOUT_MS = 600_000;
|
||||
const CODEX_APP_SERVER_STDERR_TAIL_MAX = 2_000;
|
||||
const CODEX_APP_SERVER_ABANDONED_THREAD_CREATION_MAX = 128;
|
||||
const CODEX_APP_SERVER_LATE_THREAD_CLEANUP_TIMEOUT_MS = 5_000;
|
||||
const UNPAIRED_SURROGATE_RE =
|
||||
/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g;
|
||||
|
||||
@@ -111,7 +114,10 @@ export class CodexAppServerClient {
|
||||
private readonly requestHandlers = new Set<CodexServerRequestHandler>();
|
||||
private readonly notificationHandlers = new Set<CodexServerNotificationHandler>();
|
||||
private readonly closeHandlers = new Set<(client: CodexAppServerClient) => void>();
|
||||
private activeSharedLeaseCountProvider: (() => number | undefined) | undefined;
|
||||
private readonly threadSubscriptionOwners = new Map<string, number>();
|
||||
// Codex may finish a locally abandoned create request. Remember its RPC id
|
||||
// until response/close so the unknown thread subscription can be released.
|
||||
private readonly abandonedThreadCreationRequestIds = new Set<number | string>();
|
||||
private nextId = 1;
|
||||
private initialized = false;
|
||||
private closed = false;
|
||||
@@ -225,11 +231,27 @@ export class CodexAppServerClient {
|
||||
if (options.signal?.aborted) {
|
||||
return Promise.reject(new Error(`${method} aborted`));
|
||||
}
|
||||
const requestedThreadId = readRequestThreadId(params);
|
||||
if (
|
||||
method === "thread/unsubscribe" &&
|
||||
requestedThreadId &&
|
||||
this.releaseThreadSubscriptionOwner(requestedThreadId)
|
||||
) {
|
||||
// Codex subscriptions are connection-wide sets. A logical owner can
|
||||
// release without silencing another turn on the same physical client.
|
||||
return Promise.resolve({ status: "unsubscribed" } as unknown as T);
|
||||
}
|
||||
if (method === "thread/resume" && requestedThreadId) {
|
||||
// Every resume attempt owns one release, even if the response times out
|
||||
// or aborts: Codex may have subscribed before OpenClaw saw the outcome.
|
||||
this.retainThreadSubscriptionOwner(requestedThreadId);
|
||||
}
|
||||
const id = this.nextId++;
|
||||
const message: RpcRequest = { id, method, params: params as JsonValue | undefined };
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
let cleanupAbort: (() => void) | undefined;
|
||||
let requestWritten = false;
|
||||
const cleanup = () => {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
@@ -238,23 +260,37 @@ export class CodexAppServerClient {
|
||||
cleanupAbort?.();
|
||||
cleanupAbort = undefined;
|
||||
};
|
||||
const rejectPending = (error: Error) => {
|
||||
const rejectPending = (error: Error, rememberLateThreadCreation = false) => {
|
||||
if (!this.pending.has(id)) {
|
||||
return;
|
||||
}
|
||||
this.pending.delete(id);
|
||||
if (rememberLateThreadCreation && isThreadCreationRequest(method)) {
|
||||
if (
|
||||
this.abandonedThreadCreationRequestIds.size >=
|
||||
CODEX_APP_SERVER_ABANDONED_THREAD_CREATION_MAX
|
||||
) {
|
||||
// Lost create responses can hide server subscriptions. Once the
|
||||
// bounded cleanup ledger fills, closing is the only safe release.
|
||||
this.closeWithError(
|
||||
new Error("codex app-server abandoned thread creation limit exceeded"),
|
||||
);
|
||||
} else {
|
||||
this.abandonedThreadCreationRequestIds.add(id);
|
||||
}
|
||||
}
|
||||
cleanup();
|
||||
reject(error);
|
||||
};
|
||||
if (options.timeoutMs && Number.isFinite(options.timeoutMs) && options.timeoutMs > 0) {
|
||||
timeout = setTimeout(
|
||||
() => rejectPending(new Error(`${method} timed out`)),
|
||||
() => rejectPending(new Error(`${method} timed out`), true),
|
||||
Math.max(100, options.timeoutMs),
|
||||
);
|
||||
timeout.unref?.();
|
||||
}
|
||||
if (options.signal) {
|
||||
const abortListener = () => rejectPending(new Error(`${method} aborted`));
|
||||
const abortListener = () => rejectPending(new Error(`${method} aborted`), requestWritten);
|
||||
options.signal.addEventListener("abort", abortListener, { once: true });
|
||||
cleanupAbort = () => options.signal?.removeEventListener("abort", abortListener);
|
||||
}
|
||||
@@ -262,6 +298,12 @@ export class CodexAppServerClient {
|
||||
method,
|
||||
resolve: (value) => {
|
||||
cleanup();
|
||||
if (method === "thread/start" || method === "thread/fork") {
|
||||
const threadId = readCodexThreadCreationResponseId(value);
|
||||
if (threadId) {
|
||||
this.retainThreadSubscriptionOwner(threadId);
|
||||
}
|
||||
}
|
||||
resolve(value as T);
|
||||
},
|
||||
reject: (error) => {
|
||||
@@ -275,6 +317,7 @@ export class CodexAppServerClient {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
requestWritten = true;
|
||||
this.writeMessage(message, (error) => rejectPending(error));
|
||||
} catch (error) {
|
||||
rejectPending(error instanceof Error ? error : new Error(String(error)));
|
||||
@@ -299,18 +342,6 @@ export class CodexAppServerClient {
|
||||
return () => this.notificationHandlers.delete(handler);
|
||||
}
|
||||
|
||||
/** Installs a lease-count provider used to route unscoped notifications. */
|
||||
setActiveSharedLeaseCountProviderForUnscopedNotifications(
|
||||
provider: (() => number | undefined) | undefined,
|
||||
): void {
|
||||
this.activeSharedLeaseCountProvider = provider;
|
||||
}
|
||||
|
||||
/** Reads the active shared-client lease count when available. */
|
||||
getActiveSharedLeaseCountForUnscopedNotifications(): number | undefined {
|
||||
return this.activeSharedLeaseCountProvider?.();
|
||||
}
|
||||
|
||||
/** Registers a close handler and returns its disposer. */
|
||||
addCloseHandler(handler: (client: CodexAppServerClient) => void): () => void {
|
||||
this.closeHandlers.add(handler);
|
||||
@@ -429,6 +460,15 @@ export class CodexAppServerClient {
|
||||
}
|
||||
|
||||
private handleResponse(response: RpcResponse): void {
|
||||
if (this.abandonedThreadCreationRequestIds.delete(response.id)) {
|
||||
if (!response.error) {
|
||||
const threadId = readCodexThreadCreationResponseId(response.result);
|
||||
if (threadId) {
|
||||
this.unsubscribeLateThreadCreation(threadId);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
const pending = this.pending.get(response.id);
|
||||
if (!pending) {
|
||||
return;
|
||||
@@ -506,7 +546,14 @@ export class CodexAppServerClient {
|
||||
|
||||
private handleNotification(notification: CodexServerNotification): void {
|
||||
for (const handler of this.notificationHandlers) {
|
||||
Promise.resolve(handler(notification)).catch((error: unknown) => {
|
||||
let result: Promise<void> | void;
|
||||
try {
|
||||
result = handler(notification);
|
||||
} catch (error) {
|
||||
embeddedAgentLog.warn("codex app-server notification handler failed", { error });
|
||||
continue;
|
||||
}
|
||||
Promise.resolve(result).catch((error: unknown) => {
|
||||
embeddedAgentLog.warn("codex app-server notification handler failed", { error });
|
||||
});
|
||||
}
|
||||
@@ -524,11 +571,54 @@ export class CodexAppServerClient {
|
||||
}
|
||||
this.closed = true;
|
||||
this.closeError = error;
|
||||
this.threadSubscriptionOwners.clear();
|
||||
this.abandonedThreadCreationRequestIds.clear();
|
||||
this.lines.close();
|
||||
this.rejectPendingRequests(error);
|
||||
return true;
|
||||
}
|
||||
|
||||
private unsubscribeLateThreadCreation(threadId: string): void {
|
||||
// This late response never registered a local owner. Track the wire
|
||||
// release anyway; an unconfirmed cleanup makes this client unsafe to pool.
|
||||
void this.request(
|
||||
"thread/unsubscribe",
|
||||
{ threadId },
|
||||
{ timeoutMs: CODEX_APP_SERVER_LATE_THREAD_CLEANUP_TIMEOUT_MS },
|
||||
).catch((error: unknown) => {
|
||||
embeddedAgentLog.debug("codex app-server late thread unsubscribe failed", {
|
||||
threadId,
|
||||
error,
|
||||
});
|
||||
this.closeWithError(
|
||||
new Error(`Codex late thread subscription could not be released: ${threadId}`, {
|
||||
cause: error,
|
||||
}),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private retainThreadSubscriptionOwner(threadId: string): void {
|
||||
this.threadSubscriptionOwners.set(
|
||||
threadId,
|
||||
(this.threadSubscriptionOwners.get(threadId) ?? 0) + 1,
|
||||
);
|
||||
}
|
||||
|
||||
/** Returns true when another local owner still needs the wire subscription. */
|
||||
private releaseThreadSubscriptionOwner(threadId: string): boolean {
|
||||
const owners = this.threadSubscriptionOwners.get(threadId);
|
||||
if (owners === undefined) {
|
||||
return false;
|
||||
}
|
||||
if (owners > 1) {
|
||||
this.threadSubscriptionOwners.set(threadId, owners - 1);
|
||||
return true;
|
||||
}
|
||||
this.threadSubscriptionOwners.delete(threadId);
|
||||
return false;
|
||||
}
|
||||
|
||||
private rejectPendingRequests(error: Error): void {
|
||||
for (const pending of this.pending.values()) {
|
||||
pending.cleanup();
|
||||
@@ -541,6 +631,17 @@ export class CodexAppServerClient {
|
||||
}
|
||||
}
|
||||
|
||||
function readRequestThreadId(value: unknown): string | undefined {
|
||||
if (!isJsonObject(value) || typeof value.threadId !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
return value.threadId.trim() || undefined;
|
||||
}
|
||||
|
||||
function isThreadCreationRequest(method: string): boolean {
|
||||
return method === "thread/start" || method === "thread/fork";
|
||||
}
|
||||
|
||||
function defaultServerRequestResponse(
|
||||
request: Required<Pick<RpcRequest, "id" | "method">> & { params?: JsonValue },
|
||||
): JsonValue {
|
||||
@@ -555,6 +656,9 @@ function defaultServerRequestResponse(
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
if (request.method === "execCommandApproval" || request.method === "applyPatchApproval") {
|
||||
return { decision: "denied" };
|
||||
}
|
||||
if (
|
||||
request.method === "item/commandExecution/requestApproval" ||
|
||||
request.method === "item/fileChange/requestApproval"
|
||||
@@ -570,6 +674,12 @@ function defaultServerRequestResponse(
|
||||
reason: "OpenClaw codex app-server bridge does not grant native approvals yet.",
|
||||
};
|
||||
}
|
||||
if (request.method.includes("requestApproval")) {
|
||||
return {
|
||||
decision: "decline",
|
||||
reason: "OpenClaw codex app-server bridge does not grant unknown native approvals.",
|
||||
};
|
||||
}
|
||||
if (request.method === "item/tool/requestUserInput") {
|
||||
return {
|
||||
answers: {},
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,145 +7,396 @@ import {
|
||||
type EmbeddedAgentCompactResult,
|
||||
} from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import {
|
||||
defaultLeasedCodexAppServerClientFactory,
|
||||
type CodexAppServerClientFactory,
|
||||
} from "./client-factory.js";
|
||||
CODEX_APP_SERVER_UNSUBSCRIBE_TIMEOUT_MS,
|
||||
isCodexAppServerUnsafeSubscriptionError,
|
||||
settleCodexAppServerClientLease,
|
||||
} from "./attempt-client-cleanup.js";
|
||||
import { readCodexNotificationItem } from "./attempt-notifications.js";
|
||||
import { resolveCodexTurnTerminalIdleTimeoutMs } from "./attempt-timeouts.js";
|
||||
import { CodexAppServerRpcError } from "./client.js";
|
||||
import { resolveCodexAppServerRuntimeOptions } from "./config.js";
|
||||
import type { JsonObject } from "./protocol.js";
|
||||
import { isJsonObject, type JsonObject, type JsonValue } from "./protocol.js";
|
||||
import { resolveCodexNativeExecutionBlock } from "./sandbox-guard.js";
|
||||
import {
|
||||
CODEX_APP_SERVER_BINDING_GUARDED_REQUEST_TIMEOUT_MS,
|
||||
readCodexAppServerBinding,
|
||||
withCodexAppServerBindingLock,
|
||||
writeCodexAppServerBinding,
|
||||
sessionBindingIdentity,
|
||||
type CodexAppServerBindingIdentity,
|
||||
type CodexAppServerBindingStore,
|
||||
type CodexAppServerThreadBinding,
|
||||
} from "./session-binding.js";
|
||||
import { releaseLeasedSharedCodexAppServerClient } from "./shared-client.js";
|
||||
import {
|
||||
leaseSharedCodexAppServerClient,
|
||||
type CodexAppServerClientLease,
|
||||
type CodexAppServerClientLeaseFactory,
|
||||
type CodexAppServerClientOptions,
|
||||
} from "./shared-client.js";
|
||||
import { resumeCodexAppServerThread } from "./thread-resume.js";
|
||||
import { withTimeout } from "./timeout.js";
|
||||
import {
|
||||
getCodexAppServerTurnRouter,
|
||||
isCodexTerminalTurnNotification,
|
||||
type CodexNativeTurnCompletionWatch,
|
||||
type CodexThreadRouteReservation,
|
||||
} from "./turn-router.js";
|
||||
|
||||
const warnedIgnoredCompactionOverrides = new Set<string>();
|
||||
type CodexAppServerCompactOptions = {
|
||||
bindingStore: CodexAppServerBindingStore;
|
||||
pluginConfig?: unknown;
|
||||
clientFactory?: CodexAppServerClientFactory;
|
||||
clientLeaseFactory?: CodexAppServerClientLeaseFactory;
|
||||
allowNonManualNativeRequest?: boolean;
|
||||
};
|
||||
|
||||
class CodexNativeTurnBindingChangedError extends Error {}
|
||||
|
||||
type CodexNativeTurnRequest = {
|
||||
bindingStore: CodexAppServerBindingStore;
|
||||
bindingIdentity: CodexAppServerBindingIdentity;
|
||||
expectedBinding: CodexAppServerThreadBinding;
|
||||
pluginConfig?: unknown;
|
||||
authProfileId?: string;
|
||||
agentDir?: string;
|
||||
config?: CodexAppServerClientOptions["config"];
|
||||
abortSignal?: AbortSignal;
|
||||
clientLeaseFactory?: CodexAppServerClientLeaseFactory;
|
||||
};
|
||||
|
||||
export type CodexNativeTurnKind = "compact" | "review";
|
||||
|
||||
/** Starts one native Codex turn and retains its app-server owner through completion. */
|
||||
export async function requestCodexNativeTurnForBinding(
|
||||
params: CodexNativeTurnRequest,
|
||||
kind: CodexNativeTurnKind,
|
||||
): Promise<void> {
|
||||
const isCompaction = kind === "compact";
|
||||
const label = isCompaction ? "compaction" : "review";
|
||||
const appServer = resolveCodexAppServerRuntimeOptions({ pluginConfig: params.pluginConfig });
|
||||
const requestTimeoutMs = Math.min(
|
||||
appServer.requestTimeoutMs,
|
||||
CODEX_APP_SERVER_BINDING_GUARDED_REQUEST_TIMEOUT_MS,
|
||||
);
|
||||
await params.bindingStore.withLease(params.bindingIdentity, async () => {
|
||||
const currentBinding = await params.bindingStore.read(params.bindingIdentity);
|
||||
if (!currentBinding || !isSameNativeTurnBinding(currentBinding, params.expectedBinding)) {
|
||||
throw new CodexNativeTurnBindingChangedError(
|
||||
`Codex thread binding changed before native ${label}`,
|
||||
);
|
||||
}
|
||||
const clientLease = await (params.clientLeaseFactory ?? leaseSharedCodexAppServerClient)({
|
||||
startOptions: appServer.start,
|
||||
authProfileId: params.authProfileId ?? currentBinding.authProfileId,
|
||||
agentDir: params.agentDir,
|
||||
config: params.config,
|
||||
abandonSignal: params.abortSignal,
|
||||
timeoutMs: appServer.requestTimeoutMs,
|
||||
});
|
||||
const client = clientLease.client;
|
||||
let subscribedThreadId: string | undefined;
|
||||
let abandonClient = false;
|
||||
let lifecycleTransferred = false;
|
||||
let awaitingNativeTurnStart = false;
|
||||
const terminalTurnsBeforeWatch = new Set<string>();
|
||||
let route: CodexThreadRouteReservation | undefined;
|
||||
let completionWatch: CodexNativeTurnCompletionWatch | undefined;
|
||||
let observedContextCompaction = false;
|
||||
let bindingInvalidated = false;
|
||||
let resolveNativeTurnStarted!: () => void;
|
||||
const nativeTurnStarted = new Promise<void>((resolve) => {
|
||||
resolveNativeTurnStarted = resolve;
|
||||
});
|
||||
try {
|
||||
const router = getCodexAppServerTurnRouter(client);
|
||||
route = router.reserveThread({
|
||||
threadId: currentBinding.threadId,
|
||||
onNotificationReceived: (notification, scope) => {
|
||||
const contextCompactionStarted =
|
||||
isCompaction &&
|
||||
Boolean(scope.turnId) &&
|
||||
notification.method === "item/started" &&
|
||||
readCodexNotificationItem(notification.params)?.type === "contextCompaction";
|
||||
if (contextCompactionStarted) {
|
||||
observedContextCompaction = true;
|
||||
}
|
||||
if (!awaitingNativeTurnStart || !scope.turnId) {
|
||||
return;
|
||||
}
|
||||
if (isCodexTerminalTurnNotification(notification)) {
|
||||
terminalTurnsBeforeWatch.add(scope.turnId);
|
||||
}
|
||||
if (contextCompactionStarted) {
|
||||
completionWatch ??= router.watchNativeTurnCompletion({
|
||||
threadId: currentBinding.threadId,
|
||||
turnId: scope.turnId,
|
||||
timeoutMs: resolveCodexTurnTerminalIdleTimeoutMs(undefined),
|
||||
});
|
||||
resolveNativeTurnStarted();
|
||||
}
|
||||
},
|
||||
onNotification: () => undefined,
|
||||
});
|
||||
throwIfCodexNativeTurnAborted(params.abortSignal, kind);
|
||||
let resumed;
|
||||
try {
|
||||
subscribedThreadId = currentBinding.threadId;
|
||||
resumed = await resumeCodexAppServerThread({
|
||||
client,
|
||||
abandonClient: clientLease.abandon,
|
||||
request: {
|
||||
threadId: currentBinding.threadId,
|
||||
excludeTurns: true,
|
||||
persistExtendedHistory: true,
|
||||
},
|
||||
timeoutMs: requestTimeoutMs,
|
||||
signal: params.abortSignal,
|
||||
});
|
||||
} catch (error) {
|
||||
abandonClient = isCodexAppServerUnsafeSubscriptionError(error);
|
||||
throw error;
|
||||
}
|
||||
const invalidateNativeContextBinding = async () => {
|
||||
if (bindingInvalidated) {
|
||||
return;
|
||||
}
|
||||
const invalidated = await params.bindingStore.mutate(params.bindingIdentity, {
|
||||
kind: "invalidate-native-context",
|
||||
threadId: currentBinding.threadId,
|
||||
...(isCompaction ? { invalidateContextEngineProjection: true as const } : {}),
|
||||
});
|
||||
if (!invalidated) {
|
||||
throw new CodexNativeTurnBindingChangedError(
|
||||
`Codex thread binding changed before native ${label}`,
|
||||
);
|
||||
}
|
||||
bindingInvalidated = true;
|
||||
};
|
||||
if (isCompaction && observedContextCompaction) {
|
||||
await invalidateNativeContextBinding();
|
||||
}
|
||||
if (resumed.thread.status?.type === "active") {
|
||||
throw new Error(
|
||||
`Codex thread already has an active turn; retry ${label} after it finishes`,
|
||||
);
|
||||
}
|
||||
throwIfCodexNativeTurnAborted(params.abortSignal, kind);
|
||||
await invalidateNativeContextBinding();
|
||||
awaitingNativeTurnStart = true;
|
||||
let requestResult: JsonValue | undefined;
|
||||
try {
|
||||
requestResult = await client.request(
|
||||
isCompaction ? "thread/compact/start" : "review/start",
|
||||
isCompaction
|
||||
? { threadId: currentBinding.threadId }
|
||||
: { threadId: currentBinding.threadId, target: { type: "uncommittedChanges" } },
|
||||
{ timeoutMs: requestTimeoutMs },
|
||||
);
|
||||
} catch (error) {
|
||||
const requestRejected = error instanceof CodexAppServerRpcError;
|
||||
if (requestRejected) {
|
||||
// A structured rejection proves this request did not start a native
|
||||
// turn. Preserve only compaction already observed on the same thread.
|
||||
completionWatch?.cancel();
|
||||
completionWatch = undefined;
|
||||
if (!isCompaction || !observedContextCompaction) {
|
||||
const restored = await params.bindingStore.mutate(params.bindingIdentity, {
|
||||
kind: "set",
|
||||
binding: currentBinding,
|
||||
});
|
||||
if (!restored) {
|
||||
throw new Error(`Codex thread binding changed after native ${label} was rejected`, {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
if (completionWatch) {
|
||||
embeddedAgentLog.debug(`codex app-server ${kind} request failed after startup`, {
|
||||
threadId: currentBinding.threadId,
|
||||
error,
|
||||
});
|
||||
} else {
|
||||
abandonClient = true;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
if (!isCompaction) {
|
||||
try {
|
||||
const review = assertCodexReviewStartResponse(requestResult);
|
||||
if (review.reviewThreadId !== currentBinding.threadId) {
|
||||
throw new Error(
|
||||
`Codex review/start returned ${review.reviewThreadId} for inline review on ${currentBinding.threadId}`,
|
||||
);
|
||||
}
|
||||
completionWatch = terminalTurnsBeforeWatch.has(review.turnId)
|
||||
? { completion: Promise.resolve(true), cancel: () => undefined }
|
||||
: router.watchNativeTurnCompletion({
|
||||
threadId: currentBinding.threadId,
|
||||
turnId: review.turnId,
|
||||
timeoutMs: resolveCodexTurnTerminalIdleTimeoutMs(undefined),
|
||||
});
|
||||
} catch (error) {
|
||||
abandonClient = true;
|
||||
throw error;
|
||||
}
|
||||
} else if (!completionWatch) {
|
||||
try {
|
||||
await waitForCodexNativeTurnStart({
|
||||
started: nativeTurnStarted,
|
||||
routeSignal: route.signal,
|
||||
timeoutMs: requestTimeoutMs,
|
||||
threadId: currentBinding.threadId,
|
||||
kind,
|
||||
});
|
||||
} catch (error) {
|
||||
// Codex accepted Op::Compact, so missing startup confirmation is
|
||||
// ambiguous. Keep facts invalidated and retire this connection.
|
||||
abandonClient = true;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
awaitingNativeTurnStart = false;
|
||||
route.release();
|
||||
route = undefined;
|
||||
const transferredWatch = completionWatch;
|
||||
if (!transferredWatch) {
|
||||
abandonClient = true;
|
||||
throw new Error(
|
||||
`codex app-server ${kind} turn started without a turn id for thread ${currentBinding.threadId}`,
|
||||
);
|
||||
}
|
||||
completionWatch = undefined;
|
||||
lifecycleTransferred = true;
|
||||
monitorCodexNativeTurn({
|
||||
completionWatch: transferredWatch,
|
||||
clientLease,
|
||||
subscribedThreadId,
|
||||
threadId: currentBinding.threadId,
|
||||
kind,
|
||||
});
|
||||
} finally {
|
||||
if (!lifecycleTransferred) {
|
||||
completionWatch?.cancel();
|
||||
route?.release();
|
||||
await settleCodexAppServerClientLease(clientLease, {
|
||||
threadId: subscribedThreadId,
|
||||
timeoutMs: CODEX_APP_SERVER_UNSUBSCRIBE_TIMEOUT_MS,
|
||||
abandon: abandonClient,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function assertCodexReviewStartResponse(value: JsonValue | undefined): {
|
||||
turnId: string;
|
||||
reviewThreadId: string;
|
||||
} {
|
||||
if (
|
||||
!isJsonObject(value) ||
|
||||
!isJsonObject(value.turn) ||
|
||||
typeof value.turn.id !== "string" ||
|
||||
!value.turn.id.trim() ||
|
||||
typeof value.reviewThreadId !== "string" ||
|
||||
!value.reviewThreadId.trim()
|
||||
) {
|
||||
throw new Error("invalid Codex review/start response");
|
||||
}
|
||||
return { turnId: value.turn.id, reviewThreadId: value.reviewThreadId };
|
||||
}
|
||||
|
||||
function monitorCodexNativeTurn(params: {
|
||||
completionWatch: CodexNativeTurnCompletionWatch;
|
||||
clientLease: CodexAppServerClientLease;
|
||||
subscribedThreadId?: string;
|
||||
threadId: string;
|
||||
kind: CodexNativeTurnKind;
|
||||
}): void {
|
||||
void (async () => {
|
||||
const completed = await params.completionWatch.completion;
|
||||
await settleCodexAppServerClientLease(params.clientLease, {
|
||||
threadId: params.subscribedThreadId,
|
||||
timeoutMs: CODEX_APP_SERVER_UNSUBSCRIBE_TIMEOUT_MS,
|
||||
abandon: !completed,
|
||||
});
|
||||
if (!completed) {
|
||||
embeddedAgentLog.warn(`codex app-server ${params.kind} turn lost terminal confirmation`, {
|
||||
threadId: params.threadId,
|
||||
});
|
||||
}
|
||||
})().catch(async (error: unknown) => {
|
||||
await params.clientLease.abandon().catch(() => undefined);
|
||||
embeddedAgentLog.warn(`codex app-server ${params.kind} turn cleanup failed`, {
|
||||
threadId: params.threadId,
|
||||
error,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function throwIfCodexNativeTurnAborted(
|
||||
signal: AbortSignal | undefined,
|
||||
kind: CodexNativeTurnKind,
|
||||
): void {
|
||||
if (!signal?.aborted) {
|
||||
return;
|
||||
}
|
||||
if (signal.reason instanceof Error) {
|
||||
throw signal.reason;
|
||||
}
|
||||
throw new Error(`codex app-server ${kind} aborted before native turn startup`, {
|
||||
cause: signal.reason,
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForCodexNativeTurnStart(params: {
|
||||
started: Promise<void>;
|
||||
routeSignal: AbortSignal;
|
||||
timeoutMs: number;
|
||||
threadId: string;
|
||||
kind: CodexNativeTurnKind;
|
||||
}): Promise<void> {
|
||||
const signal = params.routeSignal;
|
||||
let removeAbort: (() => void) | undefined;
|
||||
const aborted = new Promise<never>((_resolve, reject) => {
|
||||
const onAbort = () => reject(asNativeTurnAbortError(signal));
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
removeAbort = () => signal.removeEventListener("abort", onAbort);
|
||||
if (signal.aborted) {
|
||||
onAbort();
|
||||
}
|
||||
});
|
||||
try {
|
||||
await withTimeout(
|
||||
Promise.race([params.started, aborted]),
|
||||
params.timeoutMs,
|
||||
`codex app-server ${params.kind} turn did not start for thread ${params.threadId}`,
|
||||
);
|
||||
} finally {
|
||||
removeAbort?.();
|
||||
}
|
||||
}
|
||||
|
||||
function asNativeTurnAbortError(signal: AbortSignal): Error {
|
||||
return signal.reason instanceof Error
|
||||
? signal.reason
|
||||
: new Error("codex app-server native turn startup aborted", { cause: signal.reason });
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts native Codex compaction for a manually requested bound session, or
|
||||
* reports why Codex-owned automatic compaction should handle the trigger.
|
||||
*/
|
||||
export async function maybeCompactCodexAppServerSession(
|
||||
params: CompactEmbeddedAgentSessionParams,
|
||||
options: CodexAppServerCompactOptions = {},
|
||||
options: CodexAppServerCompactOptions,
|
||||
): Promise<EmbeddedAgentCompactResult | undefined> {
|
||||
warnIfIgnoringOpenClawCompactionOverrides(params);
|
||||
// Codex owns automatic context-pressure compaction for Codex runtime sessions.
|
||||
// This entry point starts native Codex compaction for the bound thread and
|
||||
// returns immediately; Codex applies the compaction inside its app-server.
|
||||
return compactCodexNativeThread(params, options);
|
||||
}
|
||||
|
||||
function warnIfIgnoringOpenClawCompactionOverrides(
|
||||
params: CompactEmbeddedAgentSessionParams,
|
||||
): void {
|
||||
const ignoredConfig = readIgnoredCompactionOverridePaths(params);
|
||||
if (ignoredConfig.length === 0) {
|
||||
return;
|
||||
}
|
||||
const warningKey = ignoredConfig.join("\0");
|
||||
if (warnedIgnoredCompactionOverrides.has(warningKey)) {
|
||||
return;
|
||||
}
|
||||
warnedIgnoredCompactionOverrides.add(warningKey);
|
||||
embeddedAgentLog.warn(
|
||||
"ignoring OpenClaw compaction overrides for Codex app-server compaction; Codex uses native server-side compaction",
|
||||
{
|
||||
sessionId: params.sessionId,
|
||||
sessionKey: params.sessionKey,
|
||||
ignoredConfig,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function readIgnoredCompactionOverridePaths(params: CompactEmbeddedAgentSessionParams): string[] {
|
||||
const ignored = new Set<string>();
|
||||
for (const entry of readCompactionOverrideEntries(params)) {
|
||||
const localProvider =
|
||||
typeof entry.record.provider === "string" ? entry.record.provider.trim() : "";
|
||||
const inheritedProvider =
|
||||
!localProvider && typeof entry.inheritedRecord?.provider === "string"
|
||||
? entry.inheritedRecord.provider.trim()
|
||||
: "";
|
||||
const providerPath = localProvider
|
||||
? `${entry.path}.compaction.provider`
|
||||
: inheritedProvider && entry.inheritedPath
|
||||
? `${entry.inheritedPath}.compaction.provider`
|
||||
: undefined;
|
||||
if (typeof entry.record.model === "string" && entry.record.model.trim()) {
|
||||
ignored.add(`${entry.path}.compaction.model`);
|
||||
}
|
||||
if (providerPath) {
|
||||
ignored.add(providerPath);
|
||||
}
|
||||
}
|
||||
return [...ignored];
|
||||
}
|
||||
|
||||
function readCompactionOverrideEntries(params: CompactEmbeddedAgentSessionParams): Array<{
|
||||
path: string;
|
||||
record: Record<string, unknown>;
|
||||
inheritedRecord?: Record<string, unknown>;
|
||||
inheritedPath?: string;
|
||||
}> {
|
||||
const entries: Array<{
|
||||
path: string;
|
||||
record: Record<string, unknown>;
|
||||
inheritedRecord?: Record<string, unknown>;
|
||||
inheritedPath?: string;
|
||||
}> = [];
|
||||
const defaultCompaction = readRecord(readRecord(params.config?.agents)?.defaults)?.compaction;
|
||||
const defaultRecord = readRecord(defaultCompaction);
|
||||
if (defaultRecord) {
|
||||
entries.push({ path: "agents.defaults", record: defaultRecord });
|
||||
}
|
||||
const agentId = readAgentIdFromSessionKey(params.sessionKey ?? params.sandboxSessionKey);
|
||||
if (!agentId) {
|
||||
return entries;
|
||||
}
|
||||
const agents = Array.isArray(params.config?.agents?.list) ? params.config.agents.list : [];
|
||||
const activeAgent = agents.find((agent) => {
|
||||
const id = typeof agent?.id === "string" ? agent.id.trim().toLowerCase() : "";
|
||||
return id === agentId;
|
||||
});
|
||||
const agentCompaction = readRecord(activeAgent)?.compaction;
|
||||
const agentRecord = readRecord(agentCompaction);
|
||||
if (agentRecord) {
|
||||
entries.push({
|
||||
path: `agents.list.${agentId}`,
|
||||
record: agentRecord,
|
||||
inheritedRecord: defaultRecord,
|
||||
inheritedPath: "agents.defaults",
|
||||
});
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
function readAgentIdFromSessionKey(sessionKey: string | undefined): string | undefined {
|
||||
const parts = sessionKey?.trim().toLowerCase().split(":").filter(Boolean) ?? [];
|
||||
if (parts.length < 3 || parts[0] !== "agent") {
|
||||
return undefined;
|
||||
}
|
||||
return parts[1]?.trim() || undefined;
|
||||
}
|
||||
|
||||
function readRecord(value: unknown): Record<string, unknown> | undefined {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
async function compactCodexNativeThread(
|
||||
params: CompactEmbeddedAgentSessionParams,
|
||||
options: CodexAppServerCompactOptions = {},
|
||||
options: CodexAppServerCompactOptions,
|
||||
): Promise<EmbeddedAgentCompactResult | undefined> {
|
||||
if (params.trigger !== "manual" && !options.allowNonManualNativeRequest) {
|
||||
embeddedAgentLog.info("skipping codex app-server compaction for non-manual trigger", {
|
||||
@@ -172,6 +423,7 @@ async function compactCodexNativeThread(
|
||||
}
|
||||
const nativeExecutionBlock = resolveCodexNativeExecutionBlock({
|
||||
config: params.config,
|
||||
agentId: params.agentId,
|
||||
sessionKey: params.sandboxSessionKey ?? params.sessionKey,
|
||||
sessionId: params.sessionId,
|
||||
surface: "native compaction",
|
||||
@@ -179,17 +431,20 @@ async function compactCodexNativeThread(
|
||||
if (nativeExecutionBlock) {
|
||||
return { ok: false, compacted: false, reason: nativeExecutionBlock };
|
||||
}
|
||||
const appServer = resolveCodexAppServerRuntimeOptions({ pluginConfig: options.pluginConfig });
|
||||
const initialBinding = await readCodexAppServerBinding(params.sessionFile, {
|
||||
const bindingIdentity: CodexAppServerBindingIdentity = sessionBindingIdentity({
|
||||
sessionId: params.sessionId,
|
||||
sessionKey: params.sessionKey,
|
||||
agentId: params.agentId,
|
||||
config: params.config,
|
||||
});
|
||||
const initialBinding = await options.bindingStore.read(bindingIdentity);
|
||||
if (!initialBinding?.threadId) {
|
||||
return failedCodexThreadBindingCompactionResult(params, {
|
||||
reason: "no codex app-server thread binding",
|
||||
recovery: "missing_thread_binding",
|
||||
});
|
||||
}
|
||||
let binding = initialBinding;
|
||||
const binding = initialBinding;
|
||||
const requestedAuthProfileId = params.authProfileId?.trim() || undefined;
|
||||
if (
|
||||
requestedAuthProfileId &&
|
||||
@@ -200,85 +455,42 @@ async function compactCodexNativeThread(
|
||||
// with another profile risks operating on a different Codex account.
|
||||
return { ok: false, compacted: false, reason: "auth profile mismatch for session binding" };
|
||||
}
|
||||
const shouldReleaseDefaultLease = !options.clientFactory;
|
||||
const clientFactory = options.clientFactory ?? defaultLeasedCodexAppServerClientFactory;
|
||||
const client = await clientFactory(
|
||||
appServer.start,
|
||||
requestedAuthProfileId ?? binding.authProfileId,
|
||||
params.agentDir,
|
||||
params.config,
|
||||
);
|
||||
if (options.allowNonManualNativeRequest && params.abortSignal?.aborted) {
|
||||
const currentBinding = await options.bindingStore.read(bindingIdentity);
|
||||
return skippedCodexNativeCompactionResult(params, {
|
||||
reason: "codex app-server compaction aborted before native compaction",
|
||||
code: "aborted_before_native_compaction",
|
||||
expectedThreadId: binding.threadId,
|
||||
currentThreadId: currentBinding?.threadId,
|
||||
});
|
||||
}
|
||||
try {
|
||||
if (options.allowNonManualNativeRequest) {
|
||||
const guardedResult = await withCodexAppServerBindingLock(params.sessionFile, async () => {
|
||||
const currentBinding = await readCodexAppServerBinding(params.sessionFile, {
|
||||
config: params.config,
|
||||
});
|
||||
if (params.abortSignal?.aborted) {
|
||||
return {
|
||||
started: false as const,
|
||||
result: skippedCodexNativeCompactionResult(params, {
|
||||
reason: "codex app-server compaction aborted before native compaction",
|
||||
code: "aborted_before_native_compaction",
|
||||
expectedThreadId: binding.threadId,
|
||||
currentThreadId: currentBinding?.threadId,
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (!currentBinding || !isSameNativeCompactionBinding(currentBinding, binding)) {
|
||||
embeddedAgentLog.warn(
|
||||
"skipping codex app-server compaction because the thread binding changed",
|
||||
{
|
||||
sessionId: params.sessionId,
|
||||
sessionKey: params.sessionKey,
|
||||
expectedThreadId: binding.threadId,
|
||||
currentThreadId: currentBinding?.threadId,
|
||||
},
|
||||
);
|
||||
return {
|
||||
started: false as const,
|
||||
result: skippedCodexNativeCompactionResult(params, {
|
||||
reason: "codex app-server binding changed before native compaction",
|
||||
code: "binding_changed_before_native_compaction",
|
||||
expectedThreadId: binding.threadId,
|
||||
currentThreadId: currentBinding?.threadId,
|
||||
}),
|
||||
};
|
||||
}
|
||||
binding = currentBinding;
|
||||
await clearContextEngineProjectionBeforeNativeCompaction({
|
||||
sessionId: params.sessionId,
|
||||
sessionFile: params.sessionFile,
|
||||
binding,
|
||||
config: params.config,
|
||||
});
|
||||
await client.request(
|
||||
"thread/compact/start",
|
||||
{
|
||||
threadId: binding.threadId,
|
||||
},
|
||||
{
|
||||
timeoutMs: Math.min(
|
||||
appServer.requestTimeoutMs,
|
||||
CODEX_APP_SERVER_BINDING_GUARDED_REQUEST_TIMEOUT_MS,
|
||||
),
|
||||
},
|
||||
);
|
||||
return { started: true as const };
|
||||
});
|
||||
if (!guardedResult.started) {
|
||||
return guardedResult.result;
|
||||
}
|
||||
} else {
|
||||
await client.request("thread/compact/start", {
|
||||
threadId: binding.threadId,
|
||||
});
|
||||
}
|
||||
await requestCodexNativeTurnForBinding(
|
||||
{
|
||||
bindingIdentity,
|
||||
bindingStore: options.bindingStore,
|
||||
expectedBinding: binding,
|
||||
pluginConfig: options.pluginConfig,
|
||||
authProfileId: requestedAuthProfileId,
|
||||
agentDir: params.agentDir,
|
||||
config: params.config,
|
||||
abortSignal: params.abortSignal,
|
||||
clientLeaseFactory: options.clientLeaseFactory,
|
||||
},
|
||||
"compact",
|
||||
);
|
||||
embeddedAgentLog.info("started codex app-server compaction", {
|
||||
sessionId: params.sessionId,
|
||||
threadId: binding.threadId,
|
||||
});
|
||||
} catch (error) {
|
||||
if (
|
||||
options.allowNonManualNativeRequest &&
|
||||
error instanceof CodexNativeTurnBindingChangedError
|
||||
) {
|
||||
const latestBinding = await options.bindingStore.read(bindingIdentity);
|
||||
return skippedBindingChangeResult(params, binding.threadId, latestBinding?.threadId);
|
||||
}
|
||||
if (isCodexThreadNotFoundError(error)) {
|
||||
return failedCodexThreadBindingCompactionResult(params, {
|
||||
threadId: binding.threadId,
|
||||
@@ -297,10 +509,6 @@ async function compactCodexNativeThread(
|
||||
compacted: false,
|
||||
reason: formatCompactionError(error),
|
||||
};
|
||||
} finally {
|
||||
if (shouldReleaseDefaultLease) {
|
||||
releaseLeasedSharedCodexAppServerClient(client);
|
||||
}
|
||||
}
|
||||
const resultDetails: JsonObject = {
|
||||
backend: "codex-app-server",
|
||||
@@ -326,6 +534,25 @@ async function compactCodexNativeThread(
|
||||
};
|
||||
}
|
||||
|
||||
function skippedBindingChangeResult(
|
||||
params: CompactEmbeddedAgentSessionParams,
|
||||
expectedThreadId: string,
|
||||
currentThreadId: string | undefined,
|
||||
): EmbeddedAgentCompactResult {
|
||||
embeddedAgentLog.warn("skipping codex app-server compaction because the thread binding changed", {
|
||||
sessionId: params.sessionId,
|
||||
sessionKey: params.sessionKey,
|
||||
expectedThreadId,
|
||||
currentThreadId,
|
||||
});
|
||||
return skippedCodexNativeCompactionResult(params, {
|
||||
reason: "codex app-server binding changed before native compaction",
|
||||
code: "binding_changed_before_native_compaction",
|
||||
expectedThreadId,
|
||||
currentThreadId,
|
||||
});
|
||||
}
|
||||
|
||||
function skippedCodexNativeCompactionResult(
|
||||
params: CompactEmbeddedAgentSessionParams,
|
||||
skipped: {
|
||||
@@ -382,39 +609,7 @@ function failedCodexThreadBindingCompactionResult(
|
||||
};
|
||||
}
|
||||
|
||||
async function clearContextEngineProjectionBeforeNativeCompaction(params: {
|
||||
sessionId: string;
|
||||
sessionFile: string;
|
||||
binding: CodexAppServerThreadBinding;
|
||||
config: CompactEmbeddedAgentSessionParams["config"];
|
||||
}): Promise<void> {
|
||||
const contextEngineBinding = params.binding.contextEngine;
|
||||
if (!contextEngineBinding?.projection) {
|
||||
return;
|
||||
}
|
||||
// Native Codex compaction mutates the thread history outside the projection
|
||||
// guard. Clear only the projection marker so the next turn reprojects context.
|
||||
await writeCodexAppServerBinding(
|
||||
params.sessionFile,
|
||||
{
|
||||
...params.binding,
|
||||
contextEngine: {
|
||||
...contextEngineBinding,
|
||||
projection: undefined,
|
||||
},
|
||||
createdAt: params.binding.createdAt,
|
||||
},
|
||||
{ config: params.config },
|
||||
);
|
||||
embeddedAgentLog.info("cleared codex context-engine projection before native compaction", {
|
||||
sessionId: params.sessionId,
|
||||
threadId: params.binding.threadId,
|
||||
previousEpoch: contextEngineBinding.projection.epoch,
|
||||
previousFingerprint: contextEngineBinding.projection.fingerprint,
|
||||
});
|
||||
}
|
||||
|
||||
function isSameNativeCompactionBinding(
|
||||
function isSameNativeTurnBinding(
|
||||
current: CodexAppServerThreadBinding,
|
||||
expected: CodexAppServerThreadBinding,
|
||||
): boolean {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
// Codex tests cover config plugin behavior.
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
@@ -306,7 +308,6 @@ describe("Codex app-server config", () => {
|
||||
const switchedLocalModel = resolveCodexModelBackedReviewerPolicyContext({
|
||||
model: "lmstudio/local-model",
|
||||
bindingModel: "gpt-5.5",
|
||||
nativeAuthProfile: true,
|
||||
});
|
||||
expect(switchedLocalModel).toEqual({
|
||||
modelProvider: "lmstudio",
|
||||
@@ -493,6 +494,39 @@ describe("Codex app-server config", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("reloads Codex config.toml policy when Codex can reload it", async () => {
|
||||
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-config-"));
|
||||
const codexHome = path.join(agentDir, "codex-home");
|
||||
const configPath = path.join(codexHome, "config.toml");
|
||||
await fs.mkdir(codexHome);
|
||||
try {
|
||||
await fs.writeFile(configPath, 'openai_base_url = "http://localhost:8080/v1"\n');
|
||||
const context = { modelProvider: "openai", model: "gpt-5.5", agentDir };
|
||||
expect(canUseCodexModelBackedApprovalsReviewerForModel(context)).toBe(false);
|
||||
|
||||
await fs.writeFile(configPath, 'openai_base_url = "https://api.openai.com/v1"\n');
|
||||
expect(canUseCodexModelBackedApprovalsReviewerForModel(context)).toBe(true);
|
||||
} finally {
|
||||
await fs.rm(agentDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("observes a Codex config.toml created after the first policy check", async () => {
|
||||
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-config-"));
|
||||
const codexHome = path.join(agentDir, "codex-home");
|
||||
const configPath = path.join(codexHome, "config.toml");
|
||||
await fs.mkdir(codexHome);
|
||||
try {
|
||||
const context = { modelProvider: "openai", model: "gpt-5.5", agentDir };
|
||||
expect(canUseCodexModelBackedApprovalsReviewerForModel(context)).toBe(true);
|
||||
|
||||
await fs.writeFile(configPath, 'openai_base_url = "http://localhost:8080/v1"\n');
|
||||
expect(canUseCodexModelBackedApprovalsReviewerForModel(context)).toBe(false);
|
||||
} finally {
|
||||
await fs.rm(agentDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("forces prompting when explicit no-prompt config cannot use model-backed review", () => {
|
||||
const runtime = resolveRuntimeForTest({
|
||||
pluginConfig: {
|
||||
@@ -690,8 +724,8 @@ allowed_sandbox_modes = ["read-only", "workspace-write"]
|
||||
env: {},
|
||||
modelProvider: "openai",
|
||||
requirementsPath: "/custom/codex/requirements.toml",
|
||||
readRequirementsFile: (path) => {
|
||||
readPaths.push(path);
|
||||
readRequirementsFile: (requirementsPath) => {
|
||||
readPaths.push(requirementsPath);
|
||||
return 'allowed_sandbox_modes = ["read-only", "workspace-write"]\n';
|
||||
},
|
||||
});
|
||||
@@ -711,8 +745,8 @@ allowed_sandbox_modes = ["read-only", "workspace-write"]
|
||||
env: { ProgramData: "D:\\ManagedData" },
|
||||
modelProvider: "openai",
|
||||
platform: "win32",
|
||||
readRequirementsFile: (path) => {
|
||||
readPaths.push(path);
|
||||
readRequirementsFile: (requirementsPath) => {
|
||||
readPaths.push(requirementsPath);
|
||||
return 'allowed_sandbox_modes = ["read-only", "workspace-write"]\n';
|
||||
},
|
||||
});
|
||||
|
||||
@@ -150,6 +150,11 @@ export type CodexAppServerRuntimeOptions = {
|
||||
serviceTier?: CodexServiceTier;
|
||||
};
|
||||
|
||||
export type CodexAppServerRuntimeResolution = {
|
||||
appServer: CodexAppServerRuntimeOptions;
|
||||
modelBackedReviewerAvailable: boolean;
|
||||
};
|
||||
|
||||
export type CodexModelBackedReviewerContext = {
|
||||
modelProvider?: string;
|
||||
model?: string;
|
||||
@@ -405,25 +410,34 @@ export function resolveCodexPluginsPolicy(pluginConfig?: unknown): ResolvedCodex
|
||||
};
|
||||
}
|
||||
|
||||
type CodexAppServerRuntimeParams = {
|
||||
pluginConfig?: unknown;
|
||||
execMode?: OpenClawExecMode;
|
||||
execPolicy?: OpenClawExecPolicyForCodexAppServer;
|
||||
modelProvider?: string;
|
||||
model?: string;
|
||||
config?: ProviderAuthAliasConfig;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
agentDir?: string;
|
||||
codexConfigToml?: string | null;
|
||||
requirementsToml?: string | null;
|
||||
requirementsPath?: string;
|
||||
readRequirementsFile?: (path: string) => string | undefined;
|
||||
platform?: NodeJS.Platform;
|
||||
hostName?: string;
|
||||
openClawSandboxActive?: boolean;
|
||||
};
|
||||
|
||||
export function resolveCodexAppServerRuntimeOptions(
|
||||
params: {
|
||||
pluginConfig?: unknown;
|
||||
execMode?: OpenClawExecMode;
|
||||
execPolicy?: OpenClawExecPolicyForCodexAppServer;
|
||||
modelProvider?: string;
|
||||
model?: string;
|
||||
config?: ProviderAuthAliasConfig;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
agentDir?: string;
|
||||
codexConfigToml?: string | null;
|
||||
requirementsToml?: string | null;
|
||||
requirementsPath?: string;
|
||||
readRequirementsFile?: (path: string) => string | undefined;
|
||||
platform?: NodeJS.Platform;
|
||||
hostName?: string;
|
||||
openClawSandboxActive?: boolean;
|
||||
} = {},
|
||||
params: CodexAppServerRuntimeParams = {},
|
||||
): CodexAppServerRuntimeOptions {
|
||||
return resolveCodexAppServerRuntime(params).appServer;
|
||||
}
|
||||
|
||||
/** Resolves runtime options and the model-policy fact computed with them. */
|
||||
export function resolveCodexAppServerRuntime(
|
||||
params: CodexAppServerRuntimeParams = {},
|
||||
): CodexAppServerRuntimeResolution {
|
||||
const env = params.env ?? process.env;
|
||||
const config = readCodexPluginConfig(params.pluginConfig).appServer ?? {};
|
||||
const transport = resolveTransport(config.transport);
|
||||
@@ -547,43 +561,46 @@ export function resolveCodexAppServerRuntimeOptions(
|
||||
: "implicit";
|
||||
|
||||
return {
|
||||
start: {
|
||||
transport,
|
||||
command,
|
||||
commandSource,
|
||||
args: args.length > 0 ? args : ["app-server", "--listen", "stdio://"],
|
||||
...(url ? { url } : {}),
|
||||
...(authToken ? { authToken } : {}),
|
||||
headers,
|
||||
...(transport === "stdio" && clearEnv.length > 0 ? { clearEnv } : {}),
|
||||
modelBackedReviewerAvailable: canUseModelBackedReviewer,
|
||||
appServer: {
|
||||
start: {
|
||||
transport,
|
||||
command,
|
||||
commandSource,
|
||||
args: args.length > 0 ? args : ["app-server", "--listen", "stdio://"],
|
||||
...(url ? { url } : {}),
|
||||
...(authToken ? { authToken } : {}),
|
||||
headers,
|
||||
...(transport === "stdio" && clearEnv.length > 0 ? { clearEnv } : {}),
|
||||
},
|
||||
codeModeOnly: config.codeModeOnly === true,
|
||||
requestTimeoutMs: normalizePositiveNumber(config.requestTimeoutMs, 60_000),
|
||||
turnCompletionIdleTimeoutMs: normalizePositiveNumber(
|
||||
config.turnCompletionIdleTimeoutMs,
|
||||
60_000,
|
||||
),
|
||||
...(config.postToolRawAssistantCompletionIdleTimeoutMs !== undefined
|
||||
? {
|
||||
postToolRawAssistantCompletionIdleTimeoutMs: normalizePositiveNumber(
|
||||
config.postToolRawAssistantCompletionIdleTimeoutMs,
|
||||
60_000,
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
approvalPolicy: forcedPolicy?.approvalPolicy ?? approvalPolicy,
|
||||
approvalPolicySource,
|
||||
sandbox:
|
||||
forcedPolicy?.sandbox ??
|
||||
configuredSandbox ??
|
||||
defaultPolicy?.sandbox ??
|
||||
(policyMode === "guardian" ? "workspace-write" : "danger-full-access"),
|
||||
approvalsReviewer:
|
||||
forcedPolicy?.approvalsReviewer ??
|
||||
explicitApprovalsReviewer ??
|
||||
defaultPolicy?.approvalsReviewer ??
|
||||
(policyMode === "guardian" ? "auto_review" : "user"),
|
||||
...(serviceTier ? { serviceTier } : {}),
|
||||
},
|
||||
codeModeOnly: config.codeModeOnly === true,
|
||||
requestTimeoutMs: normalizePositiveNumber(config.requestTimeoutMs, 60_000),
|
||||
turnCompletionIdleTimeoutMs: normalizePositiveNumber(
|
||||
config.turnCompletionIdleTimeoutMs,
|
||||
60_000,
|
||||
),
|
||||
...(config.postToolRawAssistantCompletionIdleTimeoutMs !== undefined
|
||||
? {
|
||||
postToolRawAssistantCompletionIdleTimeoutMs: normalizePositiveNumber(
|
||||
config.postToolRawAssistantCompletionIdleTimeoutMs,
|
||||
60_000,
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
approvalPolicy: forcedPolicy?.approvalPolicy ?? approvalPolicy,
|
||||
approvalPolicySource,
|
||||
sandbox:
|
||||
forcedPolicy?.sandbox ??
|
||||
configuredSandbox ??
|
||||
defaultPolicy?.sandbox ??
|
||||
(policyMode === "guardian" ? "workspace-write" : "danger-full-access"),
|
||||
approvalsReviewer:
|
||||
forcedPolicy?.approvalsReviewer ??
|
||||
explicitApprovalsReviewer ??
|
||||
defaultPolicy?.approvalsReviewer ??
|
||||
(policyMode === "guardian" ? "auto_review" : "user"),
|
||||
...(serviceTier ? { serviceTier } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -655,7 +672,6 @@ export function resolveCodexModelBackedReviewerPolicyContext(params: {
|
||||
model?: string;
|
||||
bindingModelProvider?: string;
|
||||
bindingModel?: string;
|
||||
nativeAuthProfile?: boolean;
|
||||
}): CodexModelBackedReviewerContext {
|
||||
const provider = params.provider?.trim();
|
||||
if (provider && provider.toLowerCase() !== "codex") {
|
||||
@@ -687,7 +703,7 @@ export function resolveCodexModelBackedReviewerPolicyContext(params: {
|
||||
};
|
||||
}
|
||||
return {
|
||||
modelProvider: params.nativeAuthProfile === true ? "openai" : undefined,
|
||||
modelProvider: undefined,
|
||||
model: params.model ?? params.bindingModel,
|
||||
};
|
||||
}
|
||||
@@ -754,6 +770,7 @@ export function codexAppServerStartOptionsKey(
|
||||
options: CodexAppServerStartOptions,
|
||||
params: {
|
||||
authProfileId?: string;
|
||||
authAccountCacheKey?: string;
|
||||
agentDir?: string;
|
||||
fallbackApiKeyCacheKey?: string;
|
||||
} = {},
|
||||
@@ -773,6 +790,7 @@ export function codexAppServerStartOptionsKey(
|
||||
.map(([key, value]) => [key, hashSecretForKey(value, `env:${key}`)]),
|
||||
clearEnv: [...(options.clearEnv ?? [])].toSorted(),
|
||||
authProfileId: params.authProfileId ?? null,
|
||||
authAccountCacheKey: params.authAccountCacheKey ?? null,
|
||||
agentDir: params.agentDir ?? null,
|
||||
fallbackApiKeyCacheKey: params.fallbackApiKeyCacheKey ?? null,
|
||||
});
|
||||
|
||||
@@ -9,15 +9,17 @@ import {
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
addSandboxShellDynamicToolsIfAvailable,
|
||||
buildDynamicTools,
|
||||
filterCodexDynamicToolsForAllowlist,
|
||||
hasWildcardCodexToolsAllow,
|
||||
includeForcedCodexDynamicToolAllow,
|
||||
prepareDynamicToolCatalog,
|
||||
resetOpenClawCodingToolsFactoryForTests,
|
||||
resolveOpenClawCodingToolsSessionKeys,
|
||||
resolveCodexMessageToolProvider,
|
||||
setOpenClawCodingToolsFactoryForTests,
|
||||
shouldEnableCodexAppServerNativeToolSurface,
|
||||
shouldForceMessageTool,
|
||||
type OpenClawCodingToolsFactory,
|
||||
} from "./dynamic-tool-build.js";
|
||||
import {
|
||||
filterCodexDynamicTools,
|
||||
@@ -99,13 +101,13 @@ function createRuntimeDynamicTool(name: string): RuntimeDynamicToolForTest {
|
||||
async function buildDynamicToolsForTest(
|
||||
params: EmbeddedRunAttemptParams,
|
||||
workspaceDir: string,
|
||||
options: Partial<Parameters<typeof buildDynamicTools>[0]> = {},
|
||||
options: Partial<Parameters<typeof prepareDynamicToolCatalog>[0]> = {},
|
||||
) {
|
||||
const sandboxSessionKey = params.sessionKey;
|
||||
if (!sandboxSessionKey) {
|
||||
throw new Error("createParams must provide a sessionKey for Codex dynamic tool tests.");
|
||||
}
|
||||
return buildDynamicTools({
|
||||
const catalog = await prepareDynamicToolCatalog({
|
||||
params,
|
||||
resolvedWorkspace: workspaceDir,
|
||||
effectiveWorkspace: workspaceDir,
|
||||
@@ -118,6 +120,7 @@ async function buildDynamicToolsForTest(
|
||||
onYieldDetected: () => undefined,
|
||||
...options,
|
||||
});
|
||||
return catalog.tools;
|
||||
}
|
||||
|
||||
describe("Codex app-server dynamic tool build", () => {
|
||||
@@ -132,6 +135,15 @@ describe("Codex app-server dynamic tool build", () => {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("uses the message tool channel before a differing ingress provider", () => {
|
||||
expect(
|
||||
resolveCodexMessageToolProvider({
|
||||
messageChannel: "discord",
|
||||
messageProvider: "discord-voice",
|
||||
}),
|
||||
).toBe("discord");
|
||||
});
|
||||
|
||||
it("filters Codex-native dynamic tools from app-server tool exposure", () => {
|
||||
const tools = [
|
||||
"read",
|
||||
@@ -159,6 +171,53 @@ describe("Codex app-server dynamic tool build", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("prepares runtime and durable tool views from one OpenClaw catalog", async () => {
|
||||
const messageTool = createRuntimeDynamicTool("message");
|
||||
const webSearchTool = createRuntimeDynamicTool("web_search");
|
||||
const heartbeatTool = createRuntimeDynamicTool("heartbeat_respond");
|
||||
const factory = vi.fn<OpenClawCodingToolsFactory>((options) => [
|
||||
messageTool,
|
||||
webSearchTool,
|
||||
...(options?.enableHeartbeatTool ? [heartbeatTool] : []),
|
||||
]);
|
||||
setOpenClawCodingToolsFactoryForTests(factory);
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const params = createParams(sessionFile, workspaceDir);
|
||||
params.disableTools = false;
|
||||
const runtimePlan = createCodexRuntimePlanFixture();
|
||||
params.runtimePlan = {
|
||||
...runtimePlan,
|
||||
tools: {
|
||||
normalize: (tools: Array<{ name: string }>) =>
|
||||
tools.filter((tool) => tool.name === "message"),
|
||||
logDiagnostics: () => undefined,
|
||||
},
|
||||
} as unknown as NonNullable<EmbeddedRunAttemptParams["runtimePlan"]>;
|
||||
|
||||
const catalog = await prepareDynamicToolCatalog({
|
||||
params,
|
||||
resolvedWorkspace: workspaceDir,
|
||||
effectiveWorkspace: workspaceDir,
|
||||
sandboxSessionKey: params.sessionKey ?? "agent:main:session-1",
|
||||
sandbox: { enabled: false, backendId: "docker" } as never,
|
||||
nativeToolSurfaceEnabled: true,
|
||||
runAbortController: new AbortController(),
|
||||
sessionAgentId: "main",
|
||||
pluginConfig: {},
|
||||
onYieldDetected: () => undefined,
|
||||
});
|
||||
|
||||
expect(factory).toHaveBeenCalledTimes(1);
|
||||
expect(factory.mock.calls[0]?.[0]?.enableHeartbeatTool).toBe(true);
|
||||
expect(catalog.tools.map((tool) => tool.name)).toEqual(["message"]);
|
||||
expect(catalog.registeredTools.map((tool) => tool.name)).toEqual([
|
||||
"message",
|
||||
"web_search",
|
||||
"heartbeat_respond",
|
||||
]);
|
||||
});
|
||||
|
||||
it("applies additional Codex dynamic tool excludes without exposing Codex-native tools", () => {
|
||||
const tools = ["read", "exec", "message", "custom_tool"].map((name) => ({ name }));
|
||||
|
||||
@@ -549,6 +608,28 @@ describe("Codex app-server dynamic tool build", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("passes native and routable channel targets into Codex dynamic tools", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const params = createParams(sessionFile, workspaceDir);
|
||||
params.disableTools = false;
|
||||
params.currentChannelId = "D123";
|
||||
params.currentMessagingTarget = "user:U123";
|
||||
params.runtimePlan = createCodexRuntimePlanFixture();
|
||||
const factoryOptions: unknown[] = [];
|
||||
setOpenClawCodingToolsFactoryForTests((options) => {
|
||||
factoryOptions.push(options);
|
||||
return [];
|
||||
});
|
||||
|
||||
await buildDynamicToolsForTest(params, workspaceDir, { sandbox: null as never });
|
||||
|
||||
expect(factoryOptions[0]).toMatchObject({
|
||||
currentChannelId: "D123",
|
||||
currentMessagingTarget: "user:U123",
|
||||
});
|
||||
});
|
||||
|
||||
it("passes runtime config into Codex exec dynamic tool construction", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
|
||||
@@ -38,6 +38,9 @@ type OpenClawCodingToolsOptions = NonNullable<
|
||||
export type OpenClawCodingToolsFactory =
|
||||
(typeof import("openclaw/plugin-sdk/agent-harness"))["createOpenClawCodingTools"];
|
||||
type OpenClawDynamicTool = ReturnType<OpenClawCodingToolsFactory>[number];
|
||||
type OpenClawDynamicToolProjection = ReturnType<
|
||||
typeof filterProviderNormalizableTools<OpenClawDynamicTool>
|
||||
>;
|
||||
type OpenClawSandboxContext = Awaited<ReturnType<typeof resolveSandboxContext>>;
|
||||
type CodexDynamicToolBuildEvent = Parameters<
|
||||
NonNullable<EmbeddedRunAttemptParams["onAgentEvent"]>
|
||||
@@ -52,6 +55,7 @@ const CODEX_NATIVE_SANDBOX_TOOL_REQUIREMENTS = [
|
||||
"apply_patch",
|
||||
] as const;
|
||||
const CODEX_MEMORY_FLUSH_DYNAMIC_TOOL_ALLOW = new Set(["read", "write"]);
|
||||
const CODEX_HEARTBEAT_DYNAMIC_TOOL_NAME = "heartbeat_respond";
|
||||
|
||||
/** Runtime inputs needed to derive the exact Codex dynamic tool surface for a turn. */
|
||||
export type DynamicToolBuildParams = {
|
||||
@@ -66,8 +70,6 @@ export type DynamicToolBuildParams = {
|
||||
sessionAgentId: string;
|
||||
pluginConfig: CodexPluginConfig;
|
||||
profilerEnabled?: boolean;
|
||||
forceHeartbeatTool?: boolean;
|
||||
ignoreRuntimePlan?: boolean;
|
||||
onYieldDetected: () => void;
|
||||
onCodexAppServerEvent?: (event: CodexDynamicToolBuildEvent) => void;
|
||||
};
|
||||
@@ -96,6 +98,13 @@ export function resolveOpenClawCodingToolsSessionKeys(
|
||||
};
|
||||
}
|
||||
|
||||
/** Returns the canonical channel used for Codex message routing and receipts. */
|
||||
export function resolveCodexMessageToolProvider(
|
||||
params: Pick<EmbeddedRunAttemptParams, "messageChannel" | "messageProvider">,
|
||||
): string | undefined {
|
||||
return params.messageChannel ?? params.messageProvider;
|
||||
}
|
||||
|
||||
/** Resolves the channel id that hook events should target for this Codex app-server turn. */
|
||||
export function resolveCodexAppServerHookChannelId(
|
||||
params: EmbeddedRunAttemptParams,
|
||||
@@ -121,6 +130,11 @@ type CodexDynamicToolBuildStageSummary = {
|
||||
stages: CodexDynamicToolBuildStageTiming[];
|
||||
};
|
||||
|
||||
type CodexDynamicToolBuildStageTracker = {
|
||||
mark: (name: string) => void;
|
||||
snapshot: () => CodexDynamicToolBuildStageSummary;
|
||||
};
|
||||
|
||||
const CODEX_DYNAMIC_TOOL_BUILD_WARN_TOTAL_MS = 1_000;
|
||||
const CODEX_DYNAMIC_TOOL_BUILD_WARN_STAGE_MS = 500;
|
||||
|
||||
@@ -182,17 +196,42 @@ export function formatCodexDynamicToolBuildStageSummary(
|
||||
: "none";
|
||||
}
|
||||
|
||||
/** Builds, filters, and normalizes Codex-compatible runtime tools for a single turn. */
|
||||
export async function buildDynamicTools(input: DynamicToolBuildParams) {
|
||||
/** Builds the turn-visible and durable registration views from one OpenClaw tool catalog. */
|
||||
export async function prepareDynamicToolCatalog(input: DynamicToolBuildParams): Promise<{
|
||||
tools: OpenClawDynamicTool[];
|
||||
registeredTools: OpenClawDynamicTool[];
|
||||
}> {
|
||||
const { params } = input;
|
||||
if (params.disableTools || !supportsModelTools(params.model)) {
|
||||
return [];
|
||||
return { tools: [], registeredTools: [] };
|
||||
}
|
||||
// Dynamic tool construction is on the reply hot path, so per-stage
|
||||
// Date.now/span bookkeeping runs only when the Codex profiler flag is set.
|
||||
const toolBuildStages = createCodexDynamicToolBuildStageTracker({
|
||||
enabled: input.profilerEnabled,
|
||||
});
|
||||
// The durable schema must include heartbeat_respond across normal and heartbeat
|
||||
// turns. Build that superset once, then hide it only from normal turn exposure.
|
||||
const allTools = await buildOpenClawDynamicToolSource(input, toolBuildStages);
|
||||
const readableTools = filterProviderNormalizableTools(allTools);
|
||||
toolBuildStages.mark("provider-normalization");
|
||||
const tools = projectDynamicTools(input, readableTools, toolBuildStages, {
|
||||
excludeHeartbeatTool: params.trigger !== "heartbeat",
|
||||
phase: "runtime-tools",
|
||||
stagePrefix: "runtime",
|
||||
});
|
||||
const registeredTools = projectDynamicTools(input, readableTools, toolBuildStages, {
|
||||
ignoreRuntimePlan: true,
|
||||
phase: "registered-tools",
|
||||
reportDiagnostics: false,
|
||||
stagePrefix: "registered",
|
||||
});
|
||||
return { tools, registeredTools };
|
||||
}
|
||||
|
||||
async function buildOpenClawDynamicToolSource(
|
||||
input: DynamicToolBuildParams,
|
||||
toolBuildStages: CodexDynamicToolBuildStageTracker,
|
||||
): Promise<OpenClawDynamicTool[]> {
|
||||
const { params } = input;
|
||||
const modelHasVision = params.model.input?.includes("image") ?? false;
|
||||
const agentDir = params.agentDir ?? resolveAgentDir(params.config ?? {}, input.sessionAgentId);
|
||||
const createOpenClawCodingTools =
|
||||
@@ -209,7 +248,8 @@ export async function buildDynamicTools(input: DynamicToolBuildParams) {
|
||||
elevated: params.bashElevated,
|
||||
},
|
||||
sandbox: input.sandbox,
|
||||
messageProvider: params.messageChannel ?? params.messageProvider,
|
||||
messageProvider: resolveCodexMessageToolProvider(params),
|
||||
toolPolicyMessageProvider: params.messageProvider ?? params.messageChannel,
|
||||
agentAccountId: params.agentAccountId,
|
||||
messageTo: params.messageTo,
|
||||
messageThreadId: params.messageThreadId,
|
||||
@@ -258,6 +298,7 @@ export async function buildDynamicTools(input: DynamicToolBuildParams) {
|
||||
),
|
||||
suppressManagedWebSearch: false,
|
||||
currentChannelId: params.currentChannelId,
|
||||
currentMessagingTarget: params.currentMessagingTarget,
|
||||
hookChannelId: resolveCodexAppServerHookChannelId(params, input.sandboxSessionKey),
|
||||
currentThreadTs: params.currentThreadTs,
|
||||
currentMessageId: params.currentMessageId,
|
||||
@@ -269,8 +310,8 @@ export async function buildDynamicTools(input: DynamicToolBuildParams) {
|
||||
sourceReplyDeliveryMode: params.sourceReplyDeliveryMode,
|
||||
disableMessageTool: params.disableMessageTool,
|
||||
forceMessageTool: shouldForceMessageTool(params),
|
||||
enableHeartbeatTool: params.trigger === "heartbeat" || input.forceHeartbeatTool === true,
|
||||
forceHeartbeatTool: params.trigger === "heartbeat" || input.forceHeartbeatTool === true,
|
||||
enableHeartbeatTool: true,
|
||||
forceHeartbeatTool: true,
|
||||
onYield: (message) => {
|
||||
input.onYieldDetected();
|
||||
input.onCodexAppServerEvent?.({
|
||||
@@ -283,10 +324,30 @@ export async function buildDynamicTools(input: DynamicToolBuildParams) {
|
||||
},
|
||||
});
|
||||
toolBuildStages.mark("create-openclaw-coding-tools");
|
||||
const preNormalizationDiagnostics: RuntimeToolSchemaDiagnostic[] = [];
|
||||
const readableAllToolProjection = filterProviderNormalizableTools(allTools);
|
||||
preNormalizationDiagnostics.push(...readableAllToolProjection.diagnostics);
|
||||
const readableAllTools = [...readableAllToolProjection.tools];
|
||||
return allTools;
|
||||
}
|
||||
|
||||
function projectDynamicTools(
|
||||
input: DynamicToolBuildParams,
|
||||
source: OpenClawDynamicToolProjection,
|
||||
toolBuildStages: CodexDynamicToolBuildStageTracker,
|
||||
options: {
|
||||
excludeHeartbeatTool?: boolean;
|
||||
ignoreRuntimePlan?: boolean;
|
||||
phase?: "runtime-tools" | "registered-tools";
|
||||
reportDiagnostics?: boolean;
|
||||
stagePrefix?: string;
|
||||
} = {},
|
||||
): OpenClawDynamicTool[] {
|
||||
const { params } = input;
|
||||
const markStage = (name: string) =>
|
||||
toolBuildStages.mark(options.stagePrefix ? `${options.stagePrefix}-${name}` : name);
|
||||
const preNormalizationDiagnostics: RuntimeToolSchemaDiagnostic[] = [...source.diagnostics];
|
||||
const readableAllTools = [...source.tools].filter(
|
||||
(tool) =>
|
||||
!options.excludeHeartbeatTool ||
|
||||
normalizeCodexDynamicToolName(tool.name) !== CODEX_HEARTBEAT_DYNAMIC_TOOL_NAME,
|
||||
);
|
||||
const codexFilteredTools = addNodeShellDynamicToolsIfNeeded(
|
||||
addSandboxShellDynamicToolsIfAvailable(
|
||||
isCodexMemoryFlushRun(params)
|
||||
@@ -298,17 +359,18 @@ export async function buildDynamicTools(input: DynamicToolBuildParams) {
|
||||
readableAllTools,
|
||||
input,
|
||||
);
|
||||
toolBuildStages.mark("codex-filtering");
|
||||
markStage("codex-filtering");
|
||||
const modelHasVision = params.model.input?.includes("image") ?? false;
|
||||
const visionFilteredTools = filterToolsForVisionInputs(codexFilteredTools, {
|
||||
modelHasVision,
|
||||
hasInboundImages: (params.images?.length ?? 0) > 0,
|
||||
});
|
||||
toolBuildStages.mark("vision-filtering");
|
||||
markStage("vision-filtering");
|
||||
const toolsAllow = includeForcedCodexDynamicToolAllow(params.toolsAllow, params);
|
||||
const filteredTools = filterCodexDynamicToolsForAllowlist(visionFilteredTools, toolsAllow);
|
||||
toolBuildStages.mark("allowlist-filter");
|
||||
markStage("allowlist-filter");
|
||||
const normalizedTools = normalizeAgentRuntimeTools({
|
||||
runtimePlan: input.ignoreRuntimePlan ? undefined : params.runtimePlan,
|
||||
runtimePlan: options.ignoreRuntimePlan ? undefined : params.runtimePlan,
|
||||
tools: filteredTools,
|
||||
provider: params.provider,
|
||||
config: params.config,
|
||||
@@ -317,11 +379,14 @@ export async function buildDynamicTools(input: DynamicToolBuildParams) {
|
||||
modelId: params.modelId,
|
||||
modelApi: params.model.api,
|
||||
model: params.model,
|
||||
// Registration is a projection of the already-prepared catalog. Never
|
||||
// activate another provider runtime while constructing its durable schema.
|
||||
allowProviderRuntimePluginLoad: options.ignoreRuntimePlan ? false : undefined,
|
||||
onPreNormalizationSchemaDiagnostics: (diagnostics) =>
|
||||
preNormalizationDiagnostics.push(...diagnostics),
|
||||
});
|
||||
toolBuildStages.mark("runtime-normalization");
|
||||
if (preNormalizationDiagnostics.length > 0) {
|
||||
markStage("runtime-normalization");
|
||||
if (options.reportDiagnostics !== false && preNormalizationDiagnostics.length > 0) {
|
||||
embeddedAgentLog.warn(
|
||||
`codex app-server quarantined ${preNormalizationDiagnostics.length} unsupported runtime tool schema${preNormalizationDiagnostics.length === 1 ? "" : "s"} before dynamic tool registration`,
|
||||
{
|
||||
@@ -338,7 +403,7 @@ export async function buildDynamicTools(input: DynamicToolBuildParams) {
|
||||
}
|
||||
const summary = toolBuildStages.snapshot();
|
||||
if (shouldWarnCodexDynamicToolBuildStageSummary(summary)) {
|
||||
const phase = input.forceHeartbeatTool ? "registered-tools" : "runtime-tools";
|
||||
const phase = options.phase ?? "runtime-tools";
|
||||
embeddedAgentLog.warn(
|
||||
`codex app-server dynamic tool build timings runId=${params.runId} sessionId=${params.sessionId} phase=${phase} totalMs=${summary.totalMs} stages=${formatCodexDynamicToolBuildStageSummary(summary)}`,
|
||||
{
|
||||
@@ -352,8 +417,7 @@ export async function buildDynamicTools(input: DynamicToolBuildParams) {
|
||||
visionFilteredToolCount: visionFilteredTools.length,
|
||||
filteredToolCount: filteredTools.length,
|
||||
normalizedToolCount: normalizedTools.length,
|
||||
forceHeartbeatTool: input.forceHeartbeatTool === true,
|
||||
ignoreRuntimePlan: input.ignoreRuntimePlan === true,
|
||||
ignoreRuntimePlan: options.ignoreRuntimePlan === true,
|
||||
nativeToolSurfaceEnabled: input.nativeToolSurfaceEnabled === true,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
import {
|
||||
createEmptyPluginRegistry,
|
||||
createMockPluginRegistry,
|
||||
createTestRegistry,
|
||||
setActivePluginRegistry,
|
||||
} from "openclaw/plugin-sdk/plugin-test-runtime";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
@@ -798,6 +799,163 @@ describe("createCodexDynamicToolBridge", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("records the current provider and transport thread for implicit message sends", async () => {
|
||||
const hasRepliedRef = { value: false };
|
||||
setActivePluginRegistry(
|
||||
createTestRegistry([
|
||||
{
|
||||
pluginId: "slack",
|
||||
plugin: {
|
||||
id: "slack",
|
||||
messaging: { normalizeTarget: (raw: string) => raw.trim().toLowerCase() },
|
||||
threading: {
|
||||
resolveAutoThreadId: ({
|
||||
to,
|
||||
toolContext,
|
||||
}: {
|
||||
to: string;
|
||||
toolContext?: {
|
||||
currentChannelId?: string;
|
||||
currentMessagingTarget?: string;
|
||||
currentThreadTs?: string;
|
||||
replyToMode?: "off" | "first" | "all" | "batched";
|
||||
hasRepliedRef?: { value: boolean };
|
||||
};
|
||||
}) => {
|
||||
if (
|
||||
to !== toolContext?.currentMessagingTarget &&
|
||||
to !== toolContext?.currentChannelId
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
if (
|
||||
(toolContext?.replyToMode === "first" ||
|
||||
toolContext?.replyToMode === "batched") &&
|
||||
!toolContext.hasRepliedRef?.value
|
||||
) {
|
||||
return toolContext.currentThreadTs;
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
},
|
||||
},
|
||||
source: "test",
|
||||
},
|
||||
]),
|
||||
);
|
||||
const bridge = createCodexDynamicToolBridge({
|
||||
tools: [
|
||||
createTool({
|
||||
name: "message",
|
||||
execute: vi.fn(async () => {
|
||||
hasRepliedRef.value = true;
|
||||
return textToolResult("Sent.");
|
||||
}),
|
||||
}),
|
||||
],
|
||||
signal: new AbortController().signal,
|
||||
hookContext: {
|
||||
currentChannelProvider: "slack",
|
||||
currentChannelId: "D1",
|
||||
currentMessagingTarget: "user:u1",
|
||||
currentThreadId: "171.222",
|
||||
replyToMode: "first",
|
||||
hasRepliedRef,
|
||||
},
|
||||
});
|
||||
|
||||
await handleMessageToolCall(bridge, {
|
||||
action: "send",
|
||||
to: "user:U1",
|
||||
text: "hello from Codex",
|
||||
});
|
||||
|
||||
expect(bridge.telemetry.messagingToolSentTargets).toEqual([
|
||||
{
|
||||
tool: "message",
|
||||
provider: "slack",
|
||||
to: "user:u1",
|
||||
threadId: "171.222",
|
||||
threadImplicit: true,
|
||||
text: "hello from Codex",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("records the provider-confirmed route for successful message sends", async () => {
|
||||
const registry = createTestRegistry([
|
||||
{
|
||||
pluginId: "mattermost",
|
||||
plugin: {
|
||||
id: "mattermost",
|
||||
messaging: { normalizeTarget: (raw: string) => raw.trim().toLowerCase() },
|
||||
actions: {
|
||||
extractToolSend: ({ args }: { args: Record<string, unknown> }) =>
|
||||
args.action === "send" && typeof args.to === "string"
|
||||
? { to: args.to, threadImplicit: true }
|
||||
: null,
|
||||
extractToolSendResult: ({ result }: { result: unknown }) => {
|
||||
const details = requireRecord(
|
||||
requireRecord(result, "message result").details,
|
||||
"message details",
|
||||
);
|
||||
const toolSend = requireRecord(details.toolSend, "tool send details");
|
||||
return {
|
||||
to: String(toolSend.to),
|
||||
threadId: String(toolSend.threadId),
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
source: "test",
|
||||
},
|
||||
]);
|
||||
const middleware = vi.fn(async (event: { result: AgentToolResult<unknown> }) => {
|
||||
const details = requireRecord(event.result.details, "middleware details");
|
||||
const toolSend = requireRecord(details.toolSend, "middleware tool send");
|
||||
toolSend.to = "channel:corrupted";
|
||||
toolSend.threadId = "corrupted-root";
|
||||
return undefined;
|
||||
});
|
||||
registry.agentToolResultMiddlewares.push({
|
||||
pluginId: "route-details-stripper",
|
||||
pluginName: "Route details stripper",
|
||||
rawHandler: middleware,
|
||||
handler: middleware,
|
||||
runtimes: ["codex"],
|
||||
source: "test",
|
||||
});
|
||||
setActivePluginRegistry(registry);
|
||||
const bridge = createBridgeWithToolResult(
|
||||
"message",
|
||||
textToolResult("Sent.", {
|
||||
toolSend: {
|
||||
to: "channel:resolved-id",
|
||||
threadId: "root-post-id",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await handleMessageToolCall(bridge, {
|
||||
action: "send",
|
||||
provider: "mattermost",
|
||||
to: "town-square",
|
||||
text: "hello from Codex",
|
||||
});
|
||||
|
||||
expect(bridge.telemetry.messagingToolSentTargets).toEqual([
|
||||
{
|
||||
tool: "message",
|
||||
provider: "mattermost",
|
||||
to: "channel:resolved-id",
|
||||
threadId: "root-post-id",
|
||||
threadImplicit: undefined,
|
||||
threadSuppressed: undefined,
|
||||
text: "hello from Codex",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("records message tool media attachment aliases as delivery evidence", async () => {
|
||||
const toolResult = {
|
||||
content: [{ type: "text", text: "Sent." }],
|
||||
|
||||
@@ -6,6 +6,8 @@ import type { AgentToolResult } from "openclaw/plugin-sdk/agent-core";
|
||||
import {
|
||||
createAgentToolResultMiddlewareRunner,
|
||||
createCodexAppServerToolResultExtensionRunner,
|
||||
extractMessagingToolSend,
|
||||
extractMessagingToolSendResult,
|
||||
extractToolResultMediaArtifact,
|
||||
filterToolResultMediaUrls,
|
||||
HEARTBEAT_RESPONSE_TOOL_NAME,
|
||||
@@ -51,10 +53,22 @@ type CodexDynamicToolHookContext = {
|
||||
sessionKey?: string;
|
||||
runId?: string;
|
||||
channelId?: string;
|
||||
currentChannelProvider?: string;
|
||||
currentChannelId?: string;
|
||||
currentMessagingTarget?: string;
|
||||
currentThreadId?: string;
|
||||
replyToMode?: "off" | "first" | "all" | "batched";
|
||||
hasRepliedRef?: { value: boolean };
|
||||
};
|
||||
|
||||
type CodexToolResultHookContext = Omit<CodexDynamicToolHookContext, "config">;
|
||||
|
||||
type AgentToolResultObserver = (event: {
|
||||
toolName: string;
|
||||
result: unknown;
|
||||
isError: boolean;
|
||||
}) => void;
|
||||
|
||||
type ProjectedCodexDynamicTool = {
|
||||
tool: AnyAgentTool;
|
||||
name: string;
|
||||
@@ -67,6 +81,22 @@ type CodexDynamicToolSchemaQuarantine = {
|
||||
violations: readonly string[];
|
||||
};
|
||||
|
||||
function applyCurrentMessageProvider(
|
||||
toolName: string,
|
||||
args: Record<string, unknown>,
|
||||
currentProvider: string | undefined,
|
||||
): Record<string, unknown> {
|
||||
const hasProvider =
|
||||
typeof args.provider === "string" && args.provider.trim().length > 0
|
||||
? true
|
||||
: typeof args.channel === "string" && args.channel.trim().length > 0;
|
||||
const provider = currentProvider?.trim();
|
||||
if (toolName !== "message" || hasProvider || !provider) {
|
||||
return args;
|
||||
}
|
||||
return { ...args, provider };
|
||||
}
|
||||
|
||||
/** Runtime bridge returned to Codex app-server attempt code. */
|
||||
export type CodexDynamicToolBridge = {
|
||||
availableSpecs: CodexDynamicToolSpec[];
|
||||
@@ -75,7 +105,7 @@ export type CodexDynamicToolBridge = {
|
||||
params: CodexDynamicToolCallParams,
|
||||
options?: {
|
||||
signal?: AbortSignal;
|
||||
onAgentToolResult?: EmbeddedRunAttemptParams["onAgentToolResult"];
|
||||
onAgentToolResult?: AgentToolResultObserver;
|
||||
},
|
||||
) => Promise<CodexDynamicToolCallResponse>;
|
||||
telemetry: {
|
||||
@@ -213,9 +243,30 @@ export function createCodexDynamicToolBridge(params: {
|
||||
// Prepare before marking side-effect evidence; argument preparation can
|
||||
// fail without the target tool actually starting.
|
||||
const preparedArgs = tool.prepareArguments ? tool.prepareArguments(args) : args;
|
||||
const telemetryArgs = isRecord(preparedArgs) ? preparedArgs : args;
|
||||
const messagingTelemetryArgs = applyCurrentMessageProvider(
|
||||
toolName,
|
||||
telemetryArgs,
|
||||
params.hookContext?.currentChannelProvider,
|
||||
);
|
||||
const messagingTarget =
|
||||
isMessagingTool(toolName) && isMessagingToolSendAction(toolName, telemetryArgs)
|
||||
? extractMessagingToolSend(toolName, messagingTelemetryArgs, {
|
||||
config: params.hookContext?.config,
|
||||
currentChannelId: params.hookContext?.currentChannelId,
|
||||
currentMessagingTarget: params.hookContext?.currentMessagingTarget,
|
||||
currentThreadId: params.hookContext?.currentThreadId,
|
||||
replyToMode: params.hookContext?.replyToMode,
|
||||
hasRepliedRef: params.hookContext?.hasRepliedRef,
|
||||
})
|
||||
: undefined;
|
||||
didStartExecution = true;
|
||||
const rawResult = await tool.execute(call.callId, preparedArgs, signal);
|
||||
const rawIsError = isCodexToolResultError(rawResult);
|
||||
const confirmedMessagingTarget =
|
||||
!rawIsError && messagingTarget
|
||||
? extractMessagingToolSendResult(messagingTarget, rawResult)
|
||||
: messagingTarget;
|
||||
const middlewareResult = await middlewareRunner.applyToolResultMiddleware({
|
||||
threadId: call.threadId,
|
||||
turnId: call.turnId,
|
||||
@@ -237,11 +288,12 @@ export function createCodexDynamicToolBridge(params: {
|
||||
notifyAgentToolResult(options?.onAgentToolResult, toolName, result, resultIsError);
|
||||
collectToolTelemetry({
|
||||
toolName,
|
||||
args,
|
||||
args: telemetryArgs,
|
||||
result,
|
||||
mediaTrustResult: rawResult,
|
||||
telemetry,
|
||||
isError: resultIsError,
|
||||
messagingTarget: confirmedMessagingTarget,
|
||||
});
|
||||
void runAgentHarnessAfterToolCallHook({
|
||||
toolName,
|
||||
@@ -323,7 +375,7 @@ export function createCodexDynamicToolBridge(params: {
|
||||
}
|
||||
|
||||
function notifyAgentToolResult(
|
||||
observer: EmbeddedRunAttemptParams["onAgentToolResult"] | undefined,
|
||||
observer: AgentToolResultObserver | undefined,
|
||||
toolName: string,
|
||||
result: unknown,
|
||||
isError: boolean,
|
||||
@@ -631,6 +683,7 @@ function collectToolTelemetry(params: {
|
||||
mediaTrustResult?: AgentToolResult<unknown>;
|
||||
telemetry: CodexDynamicToolBridge["telemetry"];
|
||||
isError: boolean;
|
||||
messagingTarget?: MessagingToolSend;
|
||||
}): void {
|
||||
if (params.isError) {
|
||||
return;
|
||||
@@ -683,11 +736,13 @@ function collectToolTelemetry(params: {
|
||||
const mediaUrls = collectMediaUrls(params.args);
|
||||
params.telemetry.messagingToolSentMediaUrls.push(...mediaUrls);
|
||||
params.telemetry.messagingToolSentTargets.push({
|
||||
tool: params.toolName,
|
||||
provider: readFirstString(params.args, ["provider", "channel"]) ?? params.toolName,
|
||||
accountId: readFirstString(params.args, ["accountId", "account_id"]),
|
||||
to: readFirstString(params.args, ["to", "target", "recipient"]),
|
||||
threadId: readFirstString(params.args, ["threadId", "thread_id", "messageThreadId"]),
|
||||
...(params.messagingTarget ?? {
|
||||
tool: params.toolName,
|
||||
provider: readFirstString(params.args, ["provider", "channel"]) ?? params.toolName,
|
||||
accountId: readFirstString(params.args, ["accountId", "account_id"]),
|
||||
to: readFirstString(params.args, ["to", "target", "recipient"]),
|
||||
threadId: readFirstString(params.args, ["threadId", "thread_id", "messageThreadId"]),
|
||||
}),
|
||||
...(text ? { text } : {}),
|
||||
...(mediaUrls.length > 0 ? { mediaUrls } : {}),
|
||||
});
|
||||
|
||||
@@ -24,7 +24,6 @@ import {
|
||||
type CodexAppServerEventProjectorOptions,
|
||||
type CodexAppServerToolTelemetry,
|
||||
} from "./event-projector.js";
|
||||
import { rememberCodexRateLimits, resetCodexRateLimitCacheForTests } from "./rate-limit-cache.js";
|
||||
import { createCodexTestModel } from "./test-support.js";
|
||||
|
||||
const THREAD_ID = "thread-1";
|
||||
@@ -108,7 +107,6 @@ afterEach(async () => {
|
||||
resetAgentEventsForTest();
|
||||
resetDiagnosticEventsForTest();
|
||||
resetGlobalHookRunner();
|
||||
resetCodexRateLimitCacheForTests();
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllEnvs();
|
||||
for (const tempDir of tempDirs) {
|
||||
@@ -861,10 +859,11 @@ describe("CodexAppServerEventProjector", () => {
|
||||
});
|
||||
|
||||
it("uses Codex rate-limit resets for usage-limit app-server errors", async () => {
|
||||
const projector = await createProjector();
|
||||
const resetsAt = Math.ceil(Date.now() / 1000) + 120;
|
||||
const projector = await createProjector(undefined, {
|
||||
readRecentRateLimits: () => rateLimitsUpdated(resetsAt).params,
|
||||
});
|
||||
|
||||
await projector.handleNotification(rateLimitsUpdated(resetsAt));
|
||||
await projector.handleNotification(
|
||||
forCurrentTurn("error", {
|
||||
error: {
|
||||
@@ -885,10 +884,11 @@ describe("CodexAppServerEventProjector", () => {
|
||||
});
|
||||
|
||||
it("uses Codex rate-limit resets for failed turns", async () => {
|
||||
const projector = await createProjector();
|
||||
const resetsAt = Math.ceil(Date.now() / 1000) + 120;
|
||||
const projector = await createProjector(undefined, {
|
||||
readRecentRateLimits: () => rateLimitsUpdated(resetsAt).params,
|
||||
});
|
||||
|
||||
await projector.handleNotification(rateLimitsUpdated(resetsAt));
|
||||
await projector.handleNotification(
|
||||
forCurrentTurn("turn/completed", {
|
||||
turn: {
|
||||
@@ -912,9 +912,8 @@ describe("CodexAppServerEventProjector", () => {
|
||||
});
|
||||
|
||||
it("uses a recent Codex rate-limit snapshot when failed turns omit reset details", async () => {
|
||||
const projector = await createProjector();
|
||||
const resetsAt = Math.ceil(Date.now() / 1000) + 120;
|
||||
rememberCodexRateLimits({
|
||||
const rateLimits = {
|
||||
rateLimits: {
|
||||
limitId: "codex",
|
||||
limitName: "Codex",
|
||||
@@ -925,6 +924,9 @@ describe("CodexAppServerEventProjector", () => {
|
||||
rateLimitReachedType: "rate_limit_reached",
|
||||
},
|
||||
rateLimitsByLimitId: null,
|
||||
};
|
||||
const projector = await createProjector(undefined, {
|
||||
readRecentRateLimits: () => rateLimits,
|
||||
});
|
||||
|
||||
await projector.handleNotification(
|
||||
@@ -976,19 +978,19 @@ describe("CodexAppServerEventProjector", () => {
|
||||
expect(result.promptErrorSource).toBe("prompt");
|
||||
});
|
||||
|
||||
it("normalizes snake_case current token usage fields", async () => {
|
||||
it("normalizes current app-server token usage", async () => {
|
||||
const projector = await createProjector();
|
||||
|
||||
await projector.handleNotification(agentMessageDelta("done"));
|
||||
await projector.handleNotification(
|
||||
forCurrentTurn("thread/tokenUsage/updated", {
|
||||
tokenUsage: {
|
||||
total: { total_tokens: 1_000_000 },
|
||||
last_token_usage: {
|
||||
total_tokens: 17,
|
||||
input_tokens: 8,
|
||||
cached_input_tokens: 3,
|
||||
output_tokens: 9,
|
||||
total: { totalTokens: 1_000_000 },
|
||||
last: {
|
||||
totalTokens: 17,
|
||||
inputTokens: 8,
|
||||
cachedInputTokens: 3,
|
||||
outputTokens: 9,
|
||||
},
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -26,10 +26,7 @@ import type { AssistantMessage, Usage } from "openclaw/plugin-sdk/llm";
|
||||
import { saveMediaBuffer } from "openclaw/plugin-sdk/media-store";
|
||||
import { asDateTimestampMs } from "openclaw/plugin-sdk/number-runtime";
|
||||
import { resolveCodexLocalRuntimeAttribution } from "./local-runtime-attribution.js";
|
||||
import {
|
||||
readCodexNotificationThreadId,
|
||||
readCodexNotificationTurnId,
|
||||
} from "./notification-correlation.js";
|
||||
import { isCodexNotificationForTurn } from "./notification-correlation.js";
|
||||
import { readCodexTurn } from "./protocol-validators.js";
|
||||
import {
|
||||
isJsonObject,
|
||||
@@ -40,7 +37,6 @@ import {
|
||||
type JsonObject,
|
||||
type JsonValue,
|
||||
} from "./protocol.js";
|
||||
import { readRecentCodexRateLimits, rememberCodexRateLimits } from "./rate-limit-cache.js";
|
||||
import { formatCodexUsageLimitErrorMessage } from "./rate-limits.js";
|
||||
import { readCodexMirroredSessionHistoryMessages } from "./session-history.js";
|
||||
import {
|
||||
@@ -65,6 +61,7 @@ export type CodexAppServerToolTelemetry = {
|
||||
|
||||
export type CodexAppServerEventProjectorOptions = {
|
||||
nativePostToolUseRelayEnabled?: boolean;
|
||||
readRecentRateLimits?: () => JsonValue | undefined;
|
||||
trajectoryRecorder?: CodexTrajectoryRecorder | null;
|
||||
};
|
||||
|
||||
@@ -92,22 +89,6 @@ const ZERO_USAGE: Usage = {
|
||||
},
|
||||
};
|
||||
|
||||
const CURRENT_TOKEN_USAGE_KEYS = [
|
||||
"last",
|
||||
"current",
|
||||
"lastCall",
|
||||
"lastCallUsage",
|
||||
"lastTokenUsage",
|
||||
"last_token_usage",
|
||||
] as const;
|
||||
|
||||
const CODEX_PROMPT_TOTAL_INPUT_KEYS = [
|
||||
"inputTokens",
|
||||
"input_tokens",
|
||||
"promptTokens",
|
||||
"prompt_tokens",
|
||||
] as const;
|
||||
|
||||
const MAX_TOOL_OUTPUT_DELTA_MESSAGES_PER_ITEM = 20;
|
||||
const TOOL_TRANSCRIPT_OUTPUT_MAX_CHARS = 12_000;
|
||||
const MISSING_TOOL_RESULT_ERROR =
|
||||
@@ -198,8 +179,6 @@ export class CodexAppServerEventProjector {
|
||||
private tokenUsage: ReturnType<typeof normalizeUsage>;
|
||||
private guardianReviewCount = 0;
|
||||
private completedCompactionCount = 0;
|
||||
private latestRateLimits: JsonValue | undefined;
|
||||
|
||||
constructor(
|
||||
private readonly params: EmbeddedRunAttemptParams,
|
||||
private readonly threadId: string,
|
||||
@@ -221,11 +200,6 @@ export class CodexAppServerEventProjector {
|
||||
if (!params) {
|
||||
return;
|
||||
}
|
||||
if (notification.method === "account/rateLimits/updated") {
|
||||
this.latestRateLimits = params;
|
||||
rememberCodexRateLimits(params);
|
||||
return;
|
||||
}
|
||||
if (isHookNotificationMethod(notification.method)) {
|
||||
if (!this.isHookNotificationForCurrentThread(params)) {
|
||||
return;
|
||||
@@ -278,7 +252,7 @@ export class CodexAppServerEventProjector {
|
||||
await this.handleRawResponseItemCompleted(params);
|
||||
break;
|
||||
case "error":
|
||||
if (readBooleanAlias(params, ["willRetry", "will_retry"]) === true) {
|
||||
if (params.willRetry === true) {
|
||||
break;
|
||||
}
|
||||
this.promptError = this.formatCodexErrorMessage(params) ?? "codex app-server error";
|
||||
@@ -671,9 +645,7 @@ export class CodexAppServerEventProjector {
|
||||
|
||||
private handleTokenUsage(params: JsonObject): void {
|
||||
const tokenUsage = isJsonObject(params.tokenUsage) ? params.tokenUsage : undefined;
|
||||
const current =
|
||||
(tokenUsage ? readFirstJsonObject(tokenUsage, CURRENT_TOKEN_USAGE_KEYS) : undefined) ??
|
||||
readFirstJsonObject(params, CURRENT_TOKEN_USAGE_KEYS);
|
||||
const current = tokenUsage && isJsonObject(tokenUsage.last) ? tokenUsage.last : undefined;
|
||||
if (!current) {
|
||||
return;
|
||||
}
|
||||
@@ -744,7 +716,7 @@ export class CodexAppServerEventProjector {
|
||||
formatCodexUsageLimitErrorMessage({
|
||||
message: turn.error?.message,
|
||||
codexErrorInfo: turn.error?.codexErrorInfo as JsonValue | null | undefined,
|
||||
rateLimits: this.latestRateLimits ?? readRecentCodexRateLimits(),
|
||||
rateLimits: this.options.readRecentRateLimits?.(),
|
||||
}) ??
|
||||
turn.error?.message ??
|
||||
"codex app-server turn failed";
|
||||
@@ -1611,7 +1583,7 @@ export class CodexAppServerEventProjector {
|
||||
formatCodexUsageLimitErrorMessage({
|
||||
message: error ? readString(error, "message") : undefined,
|
||||
codexErrorInfo: error?.codexErrorInfo,
|
||||
rateLimits: this.latestRateLimits ?? readRecentCodexRateLimits(),
|
||||
rateLimits: this.options.readRecentRateLimits?.(),
|
||||
}) ?? readCodexErrorNotificationMessage(params)
|
||||
);
|
||||
}
|
||||
@@ -1786,9 +1758,7 @@ export class CodexAppServerEventProjector {
|
||||
}
|
||||
|
||||
private isNotificationForTurn(params: JsonObject): boolean {
|
||||
const threadId = readCodexNotificationThreadId(params);
|
||||
const turnId = readNotificationTurnId(params);
|
||||
return threadId === this.threadId && turnId === this.turnId;
|
||||
return isCodexNotificationForTurn(params, this.threadId, this.turnId);
|
||||
}
|
||||
|
||||
private isHookNotificationForCurrentThread(params: JsonObject): boolean {
|
||||
@@ -1802,10 +1772,6 @@ function isHookNotificationMethod(method: string): method is "hook/started" | "h
|
||||
return method === "hook/started" || method === "hook/completed";
|
||||
}
|
||||
|
||||
function readNotificationTurnId(record: JsonObject): string | undefined {
|
||||
return readCodexNotificationTurnId(record);
|
||||
}
|
||||
|
||||
function readString(record: JsonObject, key: string): string | undefined {
|
||||
const value = record[key];
|
||||
return typeof value === "string" ? value : undefined;
|
||||
@@ -1895,21 +1861,6 @@ function readNonNegativeInteger(record: JsonObject, key: string): number | undef
|
||||
return value !== undefined && Number.isInteger(value) && value >= 0 ? value : undefined;
|
||||
}
|
||||
|
||||
function readBoolean(record: JsonObject, key: string): boolean | undefined {
|
||||
const value = record[key];
|
||||
return typeof value === "boolean" ? value : undefined;
|
||||
}
|
||||
|
||||
function readBooleanAlias(record: JsonObject, keys: readonly string[]): boolean | undefined {
|
||||
for (const key of keys) {
|
||||
const value = readBoolean(record, key);
|
||||
if (value !== undefined) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function readCodexErrorNotificationMessage(record: JsonObject): string | undefined {
|
||||
const error = record.error;
|
||||
if (isJsonObject(error)) {
|
||||
@@ -1937,52 +1888,19 @@ function readHookOutputEntries(
|
||||
});
|
||||
}
|
||||
|
||||
function readFirstJsonObject(record: JsonObject, keys: readonly string[]): JsonObject | undefined {
|
||||
for (const key of keys) {
|
||||
const value = record[key];
|
||||
if (isJsonObject(value)) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function readNumberAlias(record: JsonObject, keys: readonly string[]): number | undefined {
|
||||
for (const key of keys) {
|
||||
const value = readNumber(record, key);
|
||||
if (value !== undefined) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function normalizeCodexTokenUsage(record: JsonObject): ReturnType<typeof normalizeUsage> {
|
||||
const promptTotalInput = readNumberAlias(record, CODEX_PROMPT_TOTAL_INPUT_KEYS);
|
||||
const cacheRead = readNumberAlias(record, [
|
||||
"cachedInputTokens",
|
||||
"cached_input_tokens",
|
||||
"cacheRead",
|
||||
"cache_read",
|
||||
"cache_read_input_tokens",
|
||||
"cached_tokens",
|
||||
]);
|
||||
const promptTotalInput = readNumber(record, "inputTokens");
|
||||
const cacheRead = readNumber(record, "cachedInputTokens");
|
||||
const input =
|
||||
promptTotalInput !== undefined && cacheRead !== undefined
|
||||
? Math.max(0, promptTotalInput - cacheRead)
|
||||
: (promptTotalInput ?? readNumber(record, "input"));
|
||||
: promptTotalInput;
|
||||
|
||||
return normalizeUsage({
|
||||
input,
|
||||
output: readNumberAlias(record, ["outputTokens", "output_tokens", "output"]),
|
||||
output: readNumber(record, "outputTokens"),
|
||||
cacheRead,
|
||||
cacheWrite: readNumberAlias(record, [
|
||||
"cacheWrite",
|
||||
"cache_write",
|
||||
"cacheCreationInputTokens",
|
||||
"cache_creation_input_tokens",
|
||||
]),
|
||||
total: readNumberAlias(record, ["totalTokens", "total_tokens", "total"]),
|
||||
total: readNumber(record, "totalTokens"),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,10 @@ import type { CodexAppServerClient } from "./client.js";
|
||||
import type { CodexAppServerStartOptions } from "./config.js";
|
||||
import { readCodexModelListResponse } from "./protocol-validators.js";
|
||||
import type { CodexModel, CodexReasoningEffortOption } from "./protocol.js";
|
||||
import {
|
||||
createIsolatedCodexAppServerClient,
|
||||
leaseSharedCodexAppServerClient,
|
||||
} from "./shared-client.js";
|
||||
|
||||
/** Normalized model metadata returned by the Codex app-server model listing helper. */
|
||||
export type CodexAppServerModel = {
|
||||
@@ -36,10 +40,11 @@ export type CodexAppServerListModelsOptions = {
|
||||
includeHidden?: boolean;
|
||||
timeoutMs?: number;
|
||||
startOptions?: CodexAppServerStartOptions;
|
||||
authProfileId?: string;
|
||||
authProfileId?: string | null;
|
||||
agentDir?: string;
|
||||
config?: Parameters<typeof resolveCodexAppServerAuthProfileIdForAgent>[0]["config"];
|
||||
sharedClient?: boolean;
|
||||
signal?: AbortSignal;
|
||||
};
|
||||
|
||||
/** Lists one Codex app-server model page using the configured auth/client options. */
|
||||
@@ -54,27 +59,37 @@ export async function listCodexAppServerModels(
|
||||
/** Walks Codex app-server model pages until exhaustion or the max-page guard. */
|
||||
export async function listAllCodexAppServerModels(
|
||||
options: CodexAppServerListModelsOptions & { maxPages?: number } = {},
|
||||
): Promise<CodexAppServerModelListResult> {
|
||||
return await withCodexAppServerModelClient(options, async ({ client, timeoutMs }) =>
|
||||
listAllCodexAppServerModelsWithClient(client, { ...options, timeoutMs }),
|
||||
);
|
||||
}
|
||||
|
||||
/** Walks all model pages on an already-owned physical app-server client. */
|
||||
export async function listAllCodexAppServerModelsWithClient(
|
||||
client: CodexAppServerClient,
|
||||
options: CodexAppServerListModelsOptions & { maxPages?: number } = {},
|
||||
): Promise<CodexAppServerModelListResult> {
|
||||
const maxPages = normalizeMaxPages(options.maxPages);
|
||||
return await withCodexAppServerModelClient(options, async ({ client, timeoutMs }) => {
|
||||
const models: CodexAppServerModel[] = [];
|
||||
let cursor = options.cursor;
|
||||
let nextCursor: string | undefined;
|
||||
for (let page = 0; page < maxPages; page += 1) {
|
||||
const result = await requestModelListPage(client, {
|
||||
...options,
|
||||
timeoutMs,
|
||||
cursor,
|
||||
});
|
||||
models.push(...result.models);
|
||||
nextCursor = result.nextCursor;
|
||||
if (!nextCursor) {
|
||||
return { models };
|
||||
}
|
||||
cursor = nextCursor;
|
||||
const timeoutMs = options.timeoutMs ?? 2500;
|
||||
const models: CodexAppServerModel[] = [];
|
||||
let cursor = options.cursor;
|
||||
let nextCursor: string | undefined;
|
||||
for (let page = 0; page < maxPages; page += 1) {
|
||||
options.signal?.throwIfAborted();
|
||||
const result = await requestModelListPage(client, {
|
||||
...options,
|
||||
timeoutMs,
|
||||
cursor,
|
||||
});
|
||||
models.push(...result.models);
|
||||
nextCursor = result.nextCursor;
|
||||
if (!nextCursor) {
|
||||
return { models };
|
||||
}
|
||||
return { models, nextCursor, truncated: true };
|
||||
});
|
||||
cursor = nextCursor;
|
||||
}
|
||||
return { models, nextCursor, truncated: true };
|
||||
}
|
||||
|
||||
async function withCodexAppServerModelClient<T>(
|
||||
@@ -83,33 +98,32 @@ async function withCodexAppServerModelClient<T>(
|
||||
): Promise<T> {
|
||||
const timeoutMs = options.timeoutMs ?? 2500;
|
||||
const useSharedClient = options.sharedClient !== false;
|
||||
const {
|
||||
createIsolatedCodexAppServerClient,
|
||||
getLeasedSharedCodexAppServerClient,
|
||||
releaseLeasedSharedCodexAppServerClient,
|
||||
} = await import("./shared-client.js");
|
||||
const client = useSharedClient
|
||||
? await getLeasedSharedCodexAppServerClient({
|
||||
const clientLease = useSharedClient
|
||||
? await leaseSharedCodexAppServerClient({
|
||||
startOptions: options.startOptions,
|
||||
timeoutMs,
|
||||
authProfileId: options.authProfileId,
|
||||
agentDir: options.agentDir,
|
||||
config: options.config,
|
||||
abandonSignal: options.signal,
|
||||
})
|
||||
: await createIsolatedCodexAppServerClient({
|
||||
startOptions: options.startOptions,
|
||||
timeoutMs,
|
||||
authProfileId: options.authProfileId,
|
||||
agentDir: options.agentDir,
|
||||
config: options.config,
|
||||
});
|
||||
: undefined;
|
||||
const client =
|
||||
clientLease?.client ??
|
||||
(await createIsolatedCodexAppServerClient({
|
||||
startOptions: options.startOptions,
|
||||
timeoutMs,
|
||||
authProfileId: options.authProfileId,
|
||||
agentDir: options.agentDir,
|
||||
config: options.config,
|
||||
}));
|
||||
try {
|
||||
return await run({ client, timeoutMs });
|
||||
} finally {
|
||||
if (useSharedClient) {
|
||||
releaseLeasedSharedCodexAppServerClient(client);
|
||||
clientLease?.release();
|
||||
} else {
|
||||
client.close();
|
||||
await client.closeAndWait({ exitTimeoutMs: 2_000, forceKillDelayMs: 250 });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -125,7 +139,7 @@ async function requestModelListPage(
|
||||
cursor: options.cursor ?? null,
|
||||
includeHidden: options.includeHidden ?? null,
|
||||
},
|
||||
{ timeoutMs: options.timeoutMs },
|
||||
{ timeoutMs: options.timeoutMs, signal: options.signal },
|
||||
);
|
||||
return readModelListResult(response);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,12 @@
|
||||
*/
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { resolveSandboxRuntimeStatus } from "openclaw/plugin-sdk/sandbox";
|
||||
import { getSessionEntry, type SessionEntry } from "openclaw/plugin-sdk/session-store-runtime";
|
||||
import {
|
||||
loadSessionStore,
|
||||
resolveSessionStoreEntry,
|
||||
resolveStorePath,
|
||||
type SessionEntry,
|
||||
} from "openclaw/plugin-sdk/session-store-runtime";
|
||||
|
||||
type ExecHost = "sandbox" | "gateway" | "node";
|
||||
type ExecTarget = "auto" | ExecHost;
|
||||
@@ -44,20 +49,21 @@ export function resolveCodexNativeExecutionPolicy(params: {
|
||||
}): CodexNativeExecutionPolicy {
|
||||
const config = params.config ?? {};
|
||||
const sessionKey = params.sessionKey?.trim() || params.sessionId?.trim() || undefined;
|
||||
const agentId = resolvePolicyAgentId({ config, sessionKey, agentId: params.agentId });
|
||||
const sessionEntry =
|
||||
params.sessionEntry ??
|
||||
(params.readRuntimeSessionEntry && sessionKey
|
||||
? readRuntimeSessionEntryBestEffort(sessionKey)
|
||||
? readRuntimeSessionEntryBestEffort(config, sessionKey, agentId)
|
||||
: undefined);
|
||||
const sandboxAvailable =
|
||||
params.sandboxAvailable ??
|
||||
(sessionKey
|
||||
? resolveSandboxRuntimeStatus({
|
||||
cfg: config,
|
||||
agentId,
|
||||
sessionKey,
|
||||
}).sandboxed
|
||||
: false);
|
||||
const agentId = resolvePolicyAgentId({ config, sessionKey, agentId: params.agentId });
|
||||
const agentExec = resolvePolicyAgentExec({ config, agentId });
|
||||
const globalExec = config.tools?.exec;
|
||||
const requestedExecHost =
|
||||
@@ -194,9 +200,17 @@ function resolveEffectiveExecHost(params: {
|
||||
return params.requestedExecHost;
|
||||
}
|
||||
|
||||
function readRuntimeSessionEntryBestEffort(sessionKey: string): SessionEntry | undefined {
|
||||
function readRuntimeSessionEntryBestEffort(
|
||||
config: OpenClawConfig,
|
||||
sessionKey: string,
|
||||
agentId: string,
|
||||
): SessionEntry | undefined {
|
||||
try {
|
||||
return getSessionEntry({ sessionKey, hydrateSkillPromptRefs: false });
|
||||
const storePath = resolveStorePath(config.session?.store, { agentId });
|
||||
return resolveSessionStoreEntry({
|
||||
store: loadSessionStore(storePath, { skipCache: true }),
|
||||
sessionKey,
|
||||
}).existing;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
addTimerTimeoutGraceMs,
|
||||
finiteSecondsToTimerSafeMilliseconds,
|
||||
} from "openclaw/plugin-sdk/number-runtime";
|
||||
import type { CodexAppServerRuntimeOptions } from "./config.js";
|
||||
import type { JsonObject, JsonValue } from "./protocol.js";
|
||||
|
||||
/** Codex hook events that can be registered through OpenClaw's native relay. */
|
||||
@@ -24,8 +23,6 @@ export const CODEX_NATIVE_HOOK_RELAY_EVENTS: readonly NativeHookRelayEvent[] = [
|
||||
"before_agent_finalize",
|
||||
] as const;
|
||||
|
||||
const CODEX_NATIVE_HOOK_RELAY_EVENTS_WITH_APP_SERVER_APPROVALS =
|
||||
CODEX_NATIVE_HOOK_RELAY_EVENTS.filter((event) => event !== "permission_request");
|
||||
const CODEX_NATIVE_HOOK_RELAY_MIN_TTL_MS = 30 * 60_000;
|
||||
/** Extra relay lifetime after the expected turn budget, preventing late hook drops. */
|
||||
export const CODEX_NATIVE_HOOK_RELAY_TTL_GRACE_MS = 5 * 60_000;
|
||||
@@ -149,9 +146,8 @@ export function createCodexNativeHookRelay(params: {
|
||||
allowedEvents: params.events,
|
||||
ttlMs: resolveCodexNativeHookRelayTtlMs({
|
||||
explicitTtlMs: params.options?.ttlMs,
|
||||
attemptTimeoutMs: params.attemptTimeoutMs,
|
||||
startupTimeoutMs: params.startupTimeoutMs,
|
||||
turnStartTimeoutMs: params.turnStartTimeoutMs,
|
||||
operationBudgetMs:
|
||||
params.attemptTimeoutMs + params.startupTimeoutMs + params.turnStartTimeoutMs,
|
||||
}),
|
||||
signal: params.signal,
|
||||
command: {
|
||||
@@ -163,38 +159,27 @@ export function createCodexNativeHookRelay(params: {
|
||||
});
|
||||
}
|
||||
|
||||
/** Selects the native hook events Codex should install for the current approval mode. */
|
||||
/** Selects the native hook events Codex should install for this thread. */
|
||||
export function resolveCodexNativeHookRelayEvents(params: {
|
||||
configuredEvents?: readonly NativeHookRelayEvent[];
|
||||
appServer: Pick<CodexAppServerRuntimeOptions, "approvalPolicy">;
|
||||
}): readonly NativeHookRelayEvent[] {
|
||||
if (params.configuredEvents?.length) {
|
||||
return params.configuredEvents;
|
||||
}
|
||||
// Codex emits PermissionRequest before the app-server approval reviewer has
|
||||
// resolved the command. In native approval modes, let Codex's app-server
|
||||
// approval bridge own the real escalation instead of surfacing a stale
|
||||
// pre-guardian OpenClaw plugin approval prompt.
|
||||
return params.appServer.approvalPolicy === "never"
|
||||
? CODEX_NATIVE_HOOK_RELAY_EVENTS
|
||||
: CODEX_NATIVE_HOOK_RELAY_EVENTS_WITH_APP_SERVER_APPROVALS;
|
||||
// Thread config is fixed before Codex reports the authoritative provider.
|
||||
// Install the stable superset; the relay defers permission prompts from guarded turns.
|
||||
return CODEX_NATIVE_HOOK_RELAY_EVENTS;
|
||||
}
|
||||
|
||||
/** Derives the native hook relay TTL from the turn budget unless explicitly configured. */
|
||||
export function resolveCodexNativeHookRelayTtlMs(params: {
|
||||
explicitTtlMs: number | undefined;
|
||||
attemptTimeoutMs: number;
|
||||
startupTimeoutMs: number;
|
||||
turnStartTimeoutMs: number;
|
||||
operationBudgetMs: number;
|
||||
}): number {
|
||||
if (params.explicitTtlMs !== undefined) {
|
||||
return params.explicitTtlMs;
|
||||
}
|
||||
const relayBudgetMs =
|
||||
params.attemptTimeoutMs +
|
||||
params.startupTimeoutMs +
|
||||
params.turnStartTimeoutMs +
|
||||
CODEX_NATIVE_HOOK_RELAY_TTL_GRACE_MS;
|
||||
const relayBudgetMs = params.operationBudgetMs + CODEX_NATIVE_HOOK_RELAY_TTL_GRACE_MS;
|
||||
return Math.max(CODEX_NATIVE_HOOK_RELAY_MIN_TTL_MS, Math.floor(relayBudgetMs));
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,7 @@ import {
|
||||
extractCodexNativeSubagentCompletions,
|
||||
extractCodexNativeSubagentCompletionsFromText,
|
||||
} from "./native-subagent-notification.js";
|
||||
import type { CodexServerNotification } from "./protocol.js";
|
||||
|
||||
function trustedInterAgentNotification(params: {
|
||||
agentPath: string;
|
||||
@@ -35,6 +36,29 @@ function trustedInterAgentNotification(params: {
|
||||
};
|
||||
}
|
||||
|
||||
function trustedAgentMessageNotification(params: {
|
||||
agentPath: string;
|
||||
text?: string;
|
||||
encryptedContent?: string;
|
||||
}): CodexServerNotification {
|
||||
return {
|
||||
method: "rawResponseItem/completed",
|
||||
params: {
|
||||
threadId: "parent-thread",
|
||||
item: {
|
||||
type: "agent_message",
|
||||
author: params.agentPath,
|
||||
recipient: "/root",
|
||||
content: [
|
||||
params.encryptedContent
|
||||
? { type: "encrypted_content", encrypted_content: params.encryptedContent }
|
||||
: { type: "input_text", text: params.text ?? "" },
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("Codex native subagent notifications", () => {
|
||||
it("parses completed child results from Codex notification XML", () => {
|
||||
expect(
|
||||
@@ -136,6 +160,26 @@ describe("Codex native subagent notifications", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("extracts completions from the current Codex agent-message item", () => {
|
||||
expect(
|
||||
extractCodexNativeSubagentCompletions(
|
||||
trustedAgentMessageNotification({
|
||||
agentPath: "child-thread",
|
||||
text:
|
||||
'<subagent_notification>{"agent_path":"child-thread","status":{"completed":"done"}}' +
|
||||
"</subagent_notification>",
|
||||
}),
|
||||
),
|
||||
).toEqual([
|
||||
{
|
||||
agentPath: "child-thread",
|
||||
status: "succeeded",
|
||||
statusLabel: "completed",
|
||||
result: "done",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("ignores visible user text that looks like a native completion", () => {
|
||||
expect(
|
||||
extractCodexNativeSubagentCompletions({
|
||||
@@ -170,6 +214,27 @@ describe("Codex native subagent notifications", () => {
|
||||
}),
|
||||
),
|
||||
).toEqual([]);
|
||||
expect(
|
||||
extractCodexNativeSubagentCompletions(
|
||||
trustedAgentMessageNotification({
|
||||
agentPath: "other-child",
|
||||
text:
|
||||
'<subagent_notification>{"agent_path":"child-thread","status":{"success":"spoof"}}' +
|
||||
"</subagent_notification>",
|
||||
}),
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("ignores encrypted agent messages that cannot be authenticated", () => {
|
||||
expect(
|
||||
extractCodexNativeSubagentCompletions(
|
||||
trustedAgentMessageNotification({
|
||||
agentPath: "child-thread",
|
||||
encryptedContent: "opaque",
|
||||
}),
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("ignores malformed payloads and non-user messages", () => {
|
||||
|
||||
@@ -39,13 +39,12 @@ export function extractCodexNativeSubagentCompletions(
|
||||
if (!item) {
|
||||
return [];
|
||||
}
|
||||
const text = readTrustedInterAgentCommunicationContent(item);
|
||||
if (!text) {
|
||||
const communication = readTrustedInterAgentCommunication(item);
|
||||
if (!communication) {
|
||||
return [];
|
||||
}
|
||||
const author = readTrustedInterAgentCommunicationAuthor(item);
|
||||
return extractCodexNativeSubagentCompletionsFromText(text).filter(
|
||||
(completion) => completion.agentPath === author,
|
||||
return extractCodexNativeSubagentCompletionsFromText(communication.content).filter(
|
||||
(completion) => completion.agentPath === communication.author,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -190,17 +189,21 @@ function completedWithoutFinalAssistantMessage(): {
|
||||
};
|
||||
}
|
||||
|
||||
function readTrustedInterAgentCommunicationContent(item: JsonObject): string | undefined {
|
||||
const communication = readTrustedInterAgentCommunication(item);
|
||||
return typeof communication?.content === "string" ? communication.content : undefined;
|
||||
}
|
||||
type TrustedInterAgentCommunication = {
|
||||
author: string;
|
||||
recipient: string;
|
||||
content: string;
|
||||
};
|
||||
|
||||
function readTrustedInterAgentCommunicationAuthor(item: JsonObject): string | undefined {
|
||||
const communication = readTrustedInterAgentCommunication(item);
|
||||
return typeof communication?.author === "string" ? communication.author : undefined;
|
||||
}
|
||||
|
||||
function readTrustedInterAgentCommunication(item: JsonObject): JsonObject | undefined {
|
||||
function readTrustedInterAgentCommunication(
|
||||
item: JsonObject,
|
||||
): TrustedInterAgentCommunication | undefined {
|
||||
if (readString(item, "type") === "agent_message") {
|
||||
const author = readString(item, "author")?.trim();
|
||||
const recipient = readString(item, "recipient")?.trim();
|
||||
const content = extractSingleTextPart(item, "input_text");
|
||||
return author && recipient && content ? { author, recipient, content } : undefined;
|
||||
}
|
||||
if (
|
||||
readString(item, "type") !== "message" ||
|
||||
readString(item, "role") !== "assistant" ||
|
||||
@@ -208,7 +211,7 @@ function readTrustedInterAgentCommunication(item: JsonObject): JsonObject | unde
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const text = extractSingleTextPart(item);
|
||||
const text = extractSingleTextPart(item, "output_text", "text");
|
||||
if (!text) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -221,18 +224,20 @@ function readTrustedInterAgentCommunication(item: JsonObject): JsonObject | unde
|
||||
if (!isJsonObject(parsed)) {
|
||||
return undefined;
|
||||
}
|
||||
const author = typeof parsed.author === "string" ? parsed.author.trim() : "";
|
||||
const recipient = typeof parsed.recipient === "string" ? parsed.recipient.trim() : "";
|
||||
if (
|
||||
typeof parsed.author !== "string" ||
|
||||
typeof parsed.recipient !== "string" ||
|
||||
!author ||
|
||||
!recipient ||
|
||||
typeof parsed.content !== "string" ||
|
||||
parsed.trigger_turn !== false
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return parsed;
|
||||
return { author, recipient, content: parsed.content };
|
||||
}
|
||||
|
||||
function extractSingleTextPart(item: JsonObject): string | undefined {
|
||||
function extractSingleTextPart(item: JsonObject, ...acceptedTypes: string[]): string | undefined {
|
||||
const content = item.content;
|
||||
if (!Array.isArray(content) || content.length !== 1) {
|
||||
return undefined;
|
||||
@@ -242,7 +247,7 @@ function extractSingleTextPart(item: JsonObject): string | undefined {
|
||||
return undefined;
|
||||
}
|
||||
const type = readString(entry, "type");
|
||||
if (type !== "output_text" && type !== "text") {
|
||||
if (!type || !acceptedTypes.includes(type)) {
|
||||
return undefined;
|
||||
}
|
||||
return readString(entry, "text")?.trim();
|
||||
|
||||
@@ -26,7 +26,6 @@ describe("CodexNativeSubagentTaskMirror", () => {
|
||||
},
|
||||
runtime,
|
||||
);
|
||||
|
||||
mirror.handleNotification({
|
||||
method: "thread/started",
|
||||
params: {
|
||||
@@ -82,7 +81,6 @@ describe("CodexNativeSubagentTaskMirror", () => {
|
||||
},
|
||||
runtime,
|
||||
);
|
||||
|
||||
mirror.handleNotification({
|
||||
method: "thread/started",
|
||||
params: {
|
||||
@@ -105,6 +103,45 @@ describe("CodexNativeSubagentTaskMirror", () => {
|
||||
expect(runtime.finalizeTaskRunByRunId).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("finalizes collab completion when no authoritative result path is available", () => {
|
||||
const runtime = createRuntime();
|
||||
const mirror = new CodexNativeSubagentTaskMirror(
|
||||
{
|
||||
parentThreadId: "parent-thread",
|
||||
requesterSessionKey: "agent:main:main",
|
||||
now: () => 44_000,
|
||||
},
|
||||
runtime,
|
||||
);
|
||||
|
||||
mirror.handleNotification({
|
||||
method: "item/completed",
|
||||
params: {
|
||||
threadId: "parent-thread",
|
||||
item: {
|
||||
type: "collabAgentToolCall",
|
||||
tool: "spawn_agent",
|
||||
prompt: "inspect one thing",
|
||||
agentsStates: {
|
||||
"child-thread": {
|
||||
status: "completed",
|
||||
message: "done",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(runtime.finalizeTaskRunByRunId).toHaveBeenCalledWith({
|
||||
runId: "codex-thread:child-thread",
|
||||
status: "succeeded",
|
||||
endedAt: 44_000,
|
||||
lastEventAt: 44_000,
|
||||
progressSummary: "done",
|
||||
terminalSummary: "done",
|
||||
});
|
||||
});
|
||||
|
||||
it("deduplicates repeated thread-started notifications for the same child thread", () => {
|
||||
const runtime = createRuntime();
|
||||
const mirror = new CodexNativeSubagentTaskMirror(
|
||||
@@ -147,7 +184,6 @@ describe("CodexNativeSubagentTaskMirror", () => {
|
||||
},
|
||||
runtime,
|
||||
);
|
||||
|
||||
mirror.handleNotification({
|
||||
method: "thread/status/changed",
|
||||
params: {
|
||||
@@ -163,15 +199,13 @@ describe("CodexNativeSubagentTaskMirror", () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(runtime.finalizeTaskRunByRunId).toHaveBeenNthCalledWith(1, {
|
||||
expect(runtime.recordTaskRunProgressByRunId).toHaveBeenCalledWith({
|
||||
runId: codexNativeSubagentRunId("child-thread"),
|
||||
status: "succeeded",
|
||||
endedAt: 30_000,
|
||||
lastEventAt: 30_000,
|
||||
progressSummary: "Codex native subagent is idle.",
|
||||
terminalSummary: "Codex native subagent finished.",
|
||||
});
|
||||
expect(runtime.finalizeTaskRunByRunId).toHaveBeenNthCalledWith(2, {
|
||||
expect(runtime.finalizeTaskRunByRunId).toHaveBeenCalledTimes(1);
|
||||
expect(runtime.finalizeTaskRunByRunId).toHaveBeenCalledWith({
|
||||
runId: codexNativeSubagentRunId("failed-child"),
|
||||
status: "failed",
|
||||
endedAt: 30_000,
|
||||
@@ -192,7 +226,7 @@ describe("CodexNativeSubagentTaskMirror", () => {
|
||||
},
|
||||
runtime,
|
||||
);
|
||||
|
||||
mirror.markAuthoritativeCompletionExpected("child-thread");
|
||||
mirror.handleNotification({
|
||||
method: "item/completed",
|
||||
params: {
|
||||
@@ -249,14 +283,12 @@ describe("CodexNativeSubagentTaskMirror", () => {
|
||||
lastEventAt: 40_000,
|
||||
progressSummary: "Codex native subagent is initializing.",
|
||||
});
|
||||
expect(runtime.finalizeTaskRunByRunId).toHaveBeenCalledWith({
|
||||
expect(runtime.recordTaskRunProgressByRunId).toHaveBeenCalledWith({
|
||||
runId: "codex-thread:child-thread",
|
||||
status: "succeeded",
|
||||
endedAt: 40_000,
|
||||
lastEventAt: 40_000,
|
||||
progressSummary: "done",
|
||||
terminalSummary: "done",
|
||||
});
|
||||
expect(runtime.finalizeTaskRunByRunId).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses the notification thread id when collab agent items omit sender thread id", () => {
|
||||
@@ -269,7 +301,6 @@ describe("CodexNativeSubagentTaskMirror", () => {
|
||||
},
|
||||
runtime,
|
||||
);
|
||||
|
||||
mirror.handleNotification({
|
||||
method: "item/started",
|
||||
params: {
|
||||
@@ -301,6 +332,7 @@ describe("CodexNativeSubagentTaskMirror", () => {
|
||||
},
|
||||
runtime,
|
||||
);
|
||||
mirror.markAuthoritativeCompletionExpected("child-thread");
|
||||
|
||||
mirror.handleNotification({
|
||||
method: "item/completed",
|
||||
@@ -326,13 +358,13 @@ describe("CodexNativeSubagentTaskMirror", () => {
|
||||
task: "inspect one thing",
|
||||
}),
|
||||
);
|
||||
expect(runtime.finalizeTaskRunByRunId).toHaveBeenCalledWith(
|
||||
expect(runtime.recordTaskRunProgressByRunId).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
runId: "codex-thread:child-thread",
|
||||
status: "succeeded",
|
||||
terminalSummary: "done",
|
||||
progressSummary: "done",
|
||||
}),
|
||||
);
|
||||
expect(runtime.finalizeTaskRunByRunId).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("finalizes stale collab agent state from the blocked tool call status", () => {
|
||||
@@ -459,7 +491,7 @@ describe("CodexNativeSubagentTaskMirror", () => {
|
||||
expect(runtime.finalizeTaskRunByRunId).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("preserves a completed collab agent message when the thread later goes idle", () => {
|
||||
it("records completed collab agent and idle thread states as progress only", () => {
|
||||
const runtime = createRuntime();
|
||||
const mirror = new CodexNativeSubagentTaskMirror(
|
||||
{
|
||||
@@ -469,6 +501,7 @@ describe("CodexNativeSubagentTaskMirror", () => {
|
||||
},
|
||||
runtime,
|
||||
);
|
||||
mirror.markAuthoritativeCompletionExpected("child-thread");
|
||||
|
||||
mirror.handleNotification({
|
||||
method: "item/completed",
|
||||
@@ -496,18 +529,60 @@ describe("CodexNativeSubagentTaskMirror", () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(runtime.finalizeTaskRunByRunId).toHaveBeenCalledTimes(1);
|
||||
expect(runtime.finalizeTaskRunByRunId).toHaveBeenCalledWith({
|
||||
expect(runtime.recordTaskRunProgressByRunId).toHaveBeenCalledTimes(1);
|
||||
expect(runtime.recordTaskRunProgressByRunId).toHaveBeenCalledWith({
|
||||
runId: "codex-thread:child-thread",
|
||||
status: "succeeded",
|
||||
endedAt: 50_000,
|
||||
lastEventAt: 50_000,
|
||||
progressSummary: "No user task is specified.",
|
||||
terminalSummary: "No user task is specified.",
|
||||
});
|
||||
expect(runtime.finalizeTaskRunByRunId).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("lets terminal collab agent state correct an earlier idle thread status", () => {
|
||||
it("keeps terminal collab failures from rewriting authoritative completion", () => {
|
||||
const runtime = createRuntime();
|
||||
const mirror = new CodexNativeSubagentTaskMirror(
|
||||
{
|
||||
parentThreadId: "parent-thread",
|
||||
requesterSessionKey: "agent:main:main",
|
||||
now: () => 52_000,
|
||||
},
|
||||
runtime,
|
||||
);
|
||||
|
||||
mirror.handleNotification({
|
||||
method: "item/completed",
|
||||
params: {
|
||||
item: {
|
||||
type: "collabAgentToolCall",
|
||||
tool: "spawnAgent",
|
||||
senderThreadId: "parent-thread",
|
||||
receiverThreadIds: ["child-thread"],
|
||||
prompt: "write the proof file",
|
||||
},
|
||||
},
|
||||
});
|
||||
mirror.markAuthoritativeCompletion("child-thread");
|
||||
mirror.handleNotification({
|
||||
method: "item/completed",
|
||||
params: {
|
||||
item: {
|
||||
type: "collabAgentToolCall",
|
||||
tool: "wait",
|
||||
senderThreadId: "parent-thread",
|
||||
agentsStates: {
|
||||
"child-thread": {
|
||||
status: "errored",
|
||||
message: "later turn failed",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(runtime.finalizeTaskRunByRunId).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("lets terminal collab agent state finalize after an earlier idle thread status", () => {
|
||||
const runtime = createRuntime();
|
||||
const mirror = new CodexNativeSubagentTaskMirror(
|
||||
{
|
||||
@@ -545,15 +620,13 @@ describe("CodexNativeSubagentTaskMirror", () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(runtime.finalizeTaskRunByRunId).toHaveBeenNthCalledWith(1, {
|
||||
expect(runtime.recordTaskRunProgressByRunId).toHaveBeenCalledWith({
|
||||
runId: "codex-thread:child-thread",
|
||||
status: "succeeded",
|
||||
endedAt: 55_000,
|
||||
lastEventAt: 55_000,
|
||||
progressSummary: "Codex native subagent is idle.",
|
||||
terminalSummary: "Codex native subagent finished.",
|
||||
});
|
||||
expect(runtime.finalizeTaskRunByRunId).toHaveBeenNthCalledWith(2, {
|
||||
expect(runtime.finalizeTaskRunByRunId).toHaveBeenCalledTimes(1);
|
||||
expect(runtime.finalizeTaskRunByRunId).toHaveBeenCalledWith({
|
||||
runId: "codex-thread:child-thread",
|
||||
status: "failed",
|
||||
endedAt: 55_000,
|
||||
@@ -574,6 +647,7 @@ describe("CodexNativeSubagentTaskMirror", () => {
|
||||
},
|
||||
runtime,
|
||||
);
|
||||
mirror.markAuthoritativeCompletionExpected("child-thread");
|
||||
|
||||
mirror.handleNotification({
|
||||
method: "item/completed",
|
||||
@@ -614,13 +688,11 @@ describe("CodexNativeSubagentTaskMirror", () => {
|
||||
lastEventAt: 60_000,
|
||||
progressSummary: "Codex native subagent is initializing.",
|
||||
});
|
||||
expect(runtime.finalizeTaskRunByRunId).toHaveBeenCalledWith({
|
||||
expect(runtime.recordTaskRunProgressByRunId).toHaveBeenCalledWith({
|
||||
runId: "codex-thread:child-thread",
|
||||
status: "succeeded",
|
||||
endedAt: 60_000,
|
||||
lastEventAt: 60_000,
|
||||
progressSummary: "done",
|
||||
terminalSummary: "done",
|
||||
});
|
||||
expect(runtime.finalizeTaskRunByRunId).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -36,6 +36,8 @@ export class CodexNativeSubagentTaskMirror {
|
||||
private readonly mirroredThreadIds = new Set<string>();
|
||||
private readonly failedMirrorThreadIds = new Set<string>();
|
||||
private readonly terminalRunIds = new Set<string>();
|
||||
private readonly authoritativeRunIds = new Set<string>();
|
||||
private readonly expectedAuthoritativeRunIds = new Set<string>();
|
||||
private readonly now: () => number;
|
||||
|
||||
constructor(
|
||||
@@ -45,6 +47,20 @@ export class CodexNativeSubagentTaskMirror {
|
||||
this.now = params.now ?? Date.now;
|
||||
}
|
||||
|
||||
markAuthoritativeCompletion(childThreadId: string): void {
|
||||
const runId = codexNativeSubagentRunId(childThreadId);
|
||||
// Run identity is per child thread, not per resumed turn. Once the monitor
|
||||
// finalizes and delivers this task, later mirror events must not rewrite it.
|
||||
this.authoritativeRunIds.add(runId);
|
||||
this.terminalRunIds.add(runId);
|
||||
}
|
||||
|
||||
markAuthoritativeCompletionExpected(childThreadId: string): void {
|
||||
// The monitor recovers the authoritative result through app-server history.
|
||||
// Keep collab completion as progress so it cannot finalize stale text first.
|
||||
this.expectedAuthoritativeRunIds.add(codexNativeSubagentRunId(childThreadId));
|
||||
}
|
||||
|
||||
handleNotification(notification: CodexServerNotification): void {
|
||||
const params = isJsonObject(notification.params) ? notification.params : undefined;
|
||||
if (!params) {
|
||||
@@ -109,6 +125,7 @@ export class CodexNativeSubagentTaskMirror {
|
||||
}
|
||||
this.failedMirrorThreadIds.delete(threadId);
|
||||
this.terminalRunIds.delete(runId);
|
||||
this.authoritativeRunIds.delete(runId);
|
||||
this.applyStatus(threadId, thread.status);
|
||||
}
|
||||
|
||||
@@ -129,6 +146,9 @@ export class CodexNativeSubagentTaskMirror {
|
||||
return;
|
||||
}
|
||||
const runId = codexNativeSubagentRunId(threadId);
|
||||
if (this.authoritativeRunIds.has(runId)) {
|
||||
return;
|
||||
}
|
||||
if (this.terminalRunIds.has(runId) && statusType !== "systemError") {
|
||||
return;
|
||||
}
|
||||
@@ -143,13 +163,10 @@ export class CodexNativeSubagentTaskMirror {
|
||||
}
|
||||
if (statusType === "idle") {
|
||||
this.terminalRunIds.add(runId);
|
||||
this.runtime.finalizeTaskRunByRunId({
|
||||
this.runtime.recordTaskRunProgressByRunId({
|
||||
runId,
|
||||
status: "succeeded",
|
||||
endedAt: eventAt,
|
||||
lastEventAt: eventAt,
|
||||
progressSummary: "Codex native subagent is idle.",
|
||||
terminalSummary: "Codex native subagent finished.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -257,6 +274,7 @@ export class CodexNativeSubagentTaskMirror {
|
||||
}
|
||||
this.failedMirrorThreadIds.delete(normalizedThreadId);
|
||||
this.terminalRunIds.delete(runId);
|
||||
this.authoritativeRunIds.delete(runId);
|
||||
}
|
||||
|
||||
private applyCollabAgentStatus(
|
||||
@@ -272,6 +290,9 @@ export class CodexNativeSubagentTaskMirror {
|
||||
return;
|
||||
}
|
||||
const runId = codexNativeSubagentRunId(threadId);
|
||||
if (this.authoritativeRunIds.has(runId)) {
|
||||
return;
|
||||
}
|
||||
if (this.terminalRunIds.has(runId) && isNonTerminalAgentStateStatus(normalizedStatus)) {
|
||||
return;
|
||||
}
|
||||
@@ -290,14 +311,25 @@ export class CodexNativeSubagentTaskMirror {
|
||||
}
|
||||
if (normalizedStatus === "completed") {
|
||||
this.terminalRunIds.add(runId);
|
||||
this.runtime.finalizeTaskRunByRunId({
|
||||
runId,
|
||||
status: "succeeded",
|
||||
endedAt: eventAt,
|
||||
lastEventAt: eventAt,
|
||||
progressSummary: trimOptional(message) ?? "Codex native subagent completed.",
|
||||
terminalSummary: trimOptional(message) ?? "Codex native subagent finished.",
|
||||
});
|
||||
const summary = trimOptional(message) ?? "Codex native subagent completed.";
|
||||
if (this.expectedAuthoritativeRunIds.has(runId)) {
|
||||
this.runtime.recordTaskRunProgressByRunId({
|
||||
runId,
|
||||
lastEventAt: eventAt,
|
||||
progressSummary: summary,
|
||||
});
|
||||
} else {
|
||||
// Remote V1 has no trusted completion envelope or local transcript.
|
||||
// Its collab-completed state is therefore the terminal fallback.
|
||||
this.runtime.finalizeTaskRunByRunId({
|
||||
runId,
|
||||
status: "succeeded",
|
||||
endedAt: eventAt,
|
||||
lastEventAt: eventAt,
|
||||
progressSummary: summary,
|
||||
terminalSummary: summary,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (normalizedStatus === "blocked") {
|
||||
|
||||
@@ -2,28 +2,7 @@
|
||||
* Correlates Codex app-server notifications with the active thread/turn so
|
||||
* projectors can ignore global or stale events without losing diagnostics.
|
||||
*/
|
||||
import {
|
||||
isJsonObject,
|
||||
type CodexServerNotification,
|
||||
type JsonObject,
|
||||
type JsonValue,
|
||||
} from "./protocol.js";
|
||||
|
||||
/** Debug-friendly correlation summary for a Codex app-server notification. */
|
||||
export type CodexNotificationCorrelation = {
|
||||
method: string;
|
||||
paramsKeys?: string[];
|
||||
activeThreadId: string;
|
||||
activeTurnId?: string;
|
||||
threadId?: string;
|
||||
turnId?: string;
|
||||
nestedTurnThreadId?: string;
|
||||
nestedTurnId?: string;
|
||||
turnStatus?: string;
|
||||
turnItemCount?: number;
|
||||
matchesActiveThread: boolean;
|
||||
matchesActiveTurn?: boolean;
|
||||
};
|
||||
import { isJsonObject, type JsonObject, type JsonValue } from "./protocol.js";
|
||||
|
||||
/** Returns true when a notification payload belongs to the exact active thread and turn. */
|
||||
export function isCodexNotificationForTurn(
|
||||
@@ -40,9 +19,10 @@ export function isCodexNotificationForTurn(
|
||||
);
|
||||
}
|
||||
|
||||
/** Reads a thread id from either top-level notification params or nested turn payloads. */
|
||||
/** Reads a thread id from canonical top-level or nested thread payloads. */
|
||||
export function readCodexNotificationThreadId(record: JsonObject): string | undefined {
|
||||
return readNestedTurnThreadId(record) ?? readString(record, "threadId");
|
||||
const thread = isJsonObject(record.thread) ? record.thread : undefined;
|
||||
return readString(record, "threadId") ?? (thread ? readString(thread, "id") : undefined);
|
||||
}
|
||||
|
||||
/** Reads a turn id from either top-level notification params or nested turn payloads. */
|
||||
@@ -50,50 +30,11 @@ export function readCodexNotificationTurnId(record: JsonObject): string | undefi
|
||||
return readNestedTurnId(record) ?? readString(record, "turnId");
|
||||
}
|
||||
|
||||
/** Builds structured correlation details for logs when notification routing is ambiguous. */
|
||||
export function describeCodexNotificationCorrelation(
|
||||
notification: CodexServerNotification,
|
||||
active: { threadId: string; turnId?: string },
|
||||
): CodexNotificationCorrelation {
|
||||
const params = isJsonObject(notification.params) ? notification.params : undefined;
|
||||
const turn = params && isJsonObject(params.turn) ? params.turn : undefined;
|
||||
const threadId = params ? readString(params, "threadId") : undefined;
|
||||
const turnId = params ? readString(params, "turnId") : undefined;
|
||||
const nestedTurnThreadId = turn ? readString(turn, "threadId") : undefined;
|
||||
const nestedTurnId = turn ? readString(turn, "id") : undefined;
|
||||
const resolvedThreadId = params ? readCodexNotificationThreadId(params) : undefined;
|
||||
const resolvedTurnId = params ? readCodexNotificationTurnId(params) : undefined;
|
||||
const matchesActiveThread = resolvedThreadId === active.threadId;
|
||||
const matchesActiveTurn = active.turnId
|
||||
? matchesActiveThread && resolvedTurnId === active.turnId
|
||||
: undefined;
|
||||
const items = turn?.items;
|
||||
return {
|
||||
method: notification.method,
|
||||
...(params ? { paramsKeys: Object.keys(params).toSorted() } : {}),
|
||||
activeThreadId: active.threadId,
|
||||
...(active.turnId ? { activeTurnId: active.turnId } : {}),
|
||||
...(threadId ? { threadId } : {}),
|
||||
...(turnId ? { turnId } : {}),
|
||||
...(nestedTurnThreadId ? { nestedTurnThreadId } : {}),
|
||||
...(nestedTurnId ? { nestedTurnId } : {}),
|
||||
...(turn ? { turnStatus: readString(turn, "status") } : {}),
|
||||
...(Array.isArray(items) ? { turnItemCount: items.length } : {}),
|
||||
matchesActiveThread,
|
||||
...(matchesActiveTurn === undefined ? {} : { matchesActiveTurn }),
|
||||
};
|
||||
}
|
||||
|
||||
function readNestedTurnId(record: JsonObject): string | undefined {
|
||||
const turn = record.turn;
|
||||
return isJsonObject(turn) ? readString(turn, "id") : undefined;
|
||||
}
|
||||
|
||||
function readNestedTurnThreadId(record: JsonObject): string | undefined {
|
||||
const turn = record.turn;
|
||||
return isJsonObject(turn) ? readString(turn, "threadId") : undefined;
|
||||
}
|
||||
|
||||
function readString(record: JsonObject, key: string): string | undefined {
|
||||
const value = record[key];
|
||||
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||
|
||||
4
extensions/codex/src/app-server/prompt-sections.ts
Normal file
4
extensions/codex/src/app-server/prompt-sections.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
/** Joins non-empty Codex prompt sections with stable paragraph spacing. */
|
||||
export function joinCodexPromptSections(...sections: Array<string | undefined>): string {
|
||||
return sections.filter((section): section is string => Boolean(section?.trim())).join("\n\n");
|
||||
}
|
||||
@@ -60,14 +60,6 @@ describe("assertCodexThreadStartResponse", () => {
|
||||
expect(result.thread.sessionId).toBe("session-1");
|
||||
});
|
||||
|
||||
it("normalizes missing id from sessionId", () => {
|
||||
const response = makeMinimalResponse({ id: undefined, sessionId: "session-1" });
|
||||
delete (response.thread as Record<string, unknown>).id;
|
||||
const result = assertCodexThreadStartResponse(response);
|
||||
expect(result.thread.id).toBe("session-1");
|
||||
expect(result.thread.sessionId).toBe("session-1");
|
||||
});
|
||||
|
||||
it("throws on invalid response", () => {
|
||||
expect(() => assertCodexThreadStartResponse({})).toThrow("Invalid Codex app-server");
|
||||
});
|
||||
|
||||
@@ -8,7 +8,6 @@ import errorNotificationSchema from "./protocol-generated/json/v2/ErrorNotificat
|
||||
import modelListResponseSchema from "./protocol-generated/json/v2/ModelListResponse.json" with { type: "json" };
|
||||
import threadResumeResponseSchema from "./protocol-generated/json/v2/ThreadResumeResponse.json" with { type: "json" };
|
||||
import threadStartResponseSchema from "./protocol-generated/json/v2/ThreadStartResponse.json" with { type: "json" };
|
||||
import turnCompletedNotificationSchema from "./protocol-generated/json/v2/TurnCompletedNotification.json" with { type: "json" };
|
||||
import turnStartResponseSchema from "./protocol-generated/json/v2/TurnStartResponse.json" with { type: "json" };
|
||||
import type {
|
||||
CodexDynamicToolCallParams,
|
||||
@@ -18,7 +17,6 @@ import type {
|
||||
CodexThreadResumeResponse,
|
||||
CodexThreadStartResponse,
|
||||
CodexTurn,
|
||||
CodexTurnCompletedNotification,
|
||||
CodexTurnStartResponse,
|
||||
} from "./protocol.js";
|
||||
|
||||
@@ -221,9 +219,6 @@ const validateThreadResumeResponse = compileCodexSchema<CodexThreadResumeRespons
|
||||
);
|
||||
const validateThreadStartResponse =
|
||||
compileCodexSchema<CodexThreadStartResponse>(threadStartResponseSchema);
|
||||
const validateTurnCompletedNotification = compileCodexSchema<CodexTurnCompletedNotification>(
|
||||
turnCompletedNotificationSchema,
|
||||
);
|
||||
const validateTurnStartResponse =
|
||||
compileCodexSchema<CodexTurnStartResponse>(turnStartResponseSchema);
|
||||
|
||||
@@ -298,19 +293,6 @@ export function readCodexTurn(value: unknown): CodexTurn | undefined {
|
||||
return response?.turn;
|
||||
}
|
||||
|
||||
/** Reads a Codex turn/completed notification payload if it matches the protocol schema. */
|
||||
export function readCodexTurnCompletedNotification(
|
||||
value: unknown,
|
||||
): CodexTurnCompletedNotification | undefined {
|
||||
return readCodexShape(
|
||||
validateTurnCompletedNotification,
|
||||
normalizeWithDefaults(
|
||||
turnCompletedNotificationSchema,
|
||||
normalizeTurnCompletedNotification(value),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function assertCodexShape<T>(validate: CodexValidator<T>, value: unknown, label: string): T {
|
||||
if (validate.check(value)) {
|
||||
return value;
|
||||
@@ -375,9 +357,6 @@ function normalizeThreadResponse(value: unknown): unknown {
|
||||
if (typeof t.id === "string" && typeof t.sessionId !== "string") {
|
||||
return { ...value, thread: { ...thread, sessionId: t.id } };
|
||||
}
|
||||
if (typeof t.sessionId === "string" && typeof t.id !== "string") {
|
||||
return { ...value, thread: { ...thread, id: t.sessionId } };
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
@@ -392,16 +371,6 @@ function normalizeTurnStartResponse(value: unknown): unknown {
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeTurnCompletedNotification(value: unknown): unknown {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value) || !("turn" in value)) {
|
||||
return value;
|
||||
}
|
||||
return {
|
||||
...value,
|
||||
turn: normalizeTurn((value as { turn?: unknown }).turn),
|
||||
};
|
||||
}
|
||||
|
||||
function formatValidationErrors(validate: CodexValidator<unknown>, value: unknown): string {
|
||||
const errors = validate.errors(value);
|
||||
if (!errors || errors.length === 0) {
|
||||
|
||||
@@ -105,6 +105,7 @@ export type CodexThreadResumeParams = JsonObject & {
|
||||
serviceTier?: CodexServiceTier | null;
|
||||
config?: JsonObject;
|
||||
developerInstructions?: string;
|
||||
excludeTurns?: boolean;
|
||||
/** Retired by Codex 0.137, but still sent for supported custom app-server 0.125-0.136. */
|
||||
persistExtendedHistory?: boolean;
|
||||
};
|
||||
@@ -112,7 +113,10 @@ export type CodexThreadResumeParams = JsonObject & {
|
||||
export type CodexThreadStartResponse = {
|
||||
thread: CodexThread;
|
||||
model: string;
|
||||
modelProvider?: string | null;
|
||||
modelProvider: string;
|
||||
approvalPolicy: string | JsonObject;
|
||||
approvalsReviewer: string;
|
||||
sandbox: CodexSandboxPolicy;
|
||||
};
|
||||
|
||||
export type CodexThreadForkParams = CodexThreadStartParams & {
|
||||
@@ -128,7 +132,22 @@ export type CodexThreadForkResponse = CodexThreadStartResponse;
|
||||
export type CodexThreadResumeResponse = {
|
||||
thread: CodexThread;
|
||||
model: string;
|
||||
modelProvider?: string | null;
|
||||
modelProvider: string;
|
||||
approvalPolicy: string | JsonObject;
|
||||
approvalsReviewer: string;
|
||||
sandbox: CodexSandboxPolicy;
|
||||
};
|
||||
|
||||
export type CodexThreadReadParams = JsonObject & {
|
||||
threadId: string;
|
||||
includeTurns?: boolean;
|
||||
};
|
||||
|
||||
export type CodexThreadReadResponse = {
|
||||
thread: CodexThread & {
|
||||
parentThreadId?: string | null;
|
||||
turns?: JsonObject[];
|
||||
};
|
||||
};
|
||||
|
||||
export type CodexThreadInjectItemsParams = JsonObject & {
|
||||
@@ -173,11 +192,10 @@ export type CodexTurnStartResponse = {
|
||||
|
||||
export type CodexTurn = {
|
||||
id: string;
|
||||
threadId: string;
|
||||
status?: string;
|
||||
error?: CodexErrorNotification["error"];
|
||||
startedAt?: string | null;
|
||||
completedAt?: string | null;
|
||||
startedAt?: number | null;
|
||||
completedAt?: number | null;
|
||||
durationMs?: number | null;
|
||||
items: CodexThreadItem[];
|
||||
};
|
||||
@@ -195,6 +213,7 @@ export type CodexThread = {
|
||||
threadSource?: string | null;
|
||||
agentNickname?: string | null;
|
||||
agentRole?: string | null;
|
||||
turns: CodexTurn[];
|
||||
};
|
||||
|
||||
export type CodexThreadStatus =
|
||||
@@ -524,6 +543,7 @@ type CodexAppServerRequestParamsOverride = {
|
||||
"environment/add": { environmentId: string; execServerUrl: string };
|
||||
"thread/fork": CodexThreadForkParams;
|
||||
"thread/inject_items": CodexThreadInjectItemsParams;
|
||||
"thread/read": CodexThreadReadParams;
|
||||
"thread/start": CodexThreadStartParams;
|
||||
"thread/unsubscribe": CodexThreadUnsubscribeParams;
|
||||
"turn/interrupt": CodexTurnInterruptParams;
|
||||
@@ -551,6 +571,7 @@ type CodexAppServerRequestResultMap = {
|
||||
"thread/fork": CodexThreadForkResponse;
|
||||
"thread/inject_items": JsonValue;
|
||||
"thread/list": JsonValue;
|
||||
"thread/read": CodexThreadReadResponse;
|
||||
"thread/resume": CodexThreadResumeResponse;
|
||||
"thread/start": CodexThreadStartResponse;
|
||||
"thread/unsubscribe": JsonValue;
|
||||
@@ -563,6 +584,14 @@ export function isJsonObject(value: unknown): value is JsonObject {
|
||||
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
||||
}
|
||||
|
||||
/** Reads the thread identity whose subscription the client retained on create. */
|
||||
export function readCodexThreadCreationResponseId(value: unknown): string | undefined {
|
||||
if (!isJsonObject(value) || !isJsonObject(value.thread) || typeof value.thread.id !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
return value.thread.id.trim() || undefined;
|
||||
}
|
||||
|
||||
export function isRpcResponse(message: RpcMessage): message is RpcResponse {
|
||||
return "id" in message && !("method" in message);
|
||||
}
|
||||
|
||||
124
extensions/codex/src/app-server/rate-limit-cache.test.ts
Normal file
124
extensions/codex/src/app-server/rate-limit-cache.test.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
// Codex tests cover physical-client rate-limit snapshot ownership and rolling merges.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { CodexAppServerClient } from "./client.js";
|
||||
import {
|
||||
mergeCodexRateLimitsUpdate,
|
||||
readCodexRateLimitsRevision,
|
||||
readRecentCodexRateLimits,
|
||||
rememberCodexRateLimitsRead,
|
||||
} from "./rate-limit-cache.js";
|
||||
|
||||
function clientIdentity(): CodexAppServerClient {
|
||||
return {} as unknown as CodexAppServerClient;
|
||||
}
|
||||
|
||||
describe("Codex rate-limit cache", () => {
|
||||
it("isolates snapshots by physical client", () => {
|
||||
const first = clientIdentity();
|
||||
const second = clientIdentity();
|
||||
expect(readCodexRateLimitsRevision(first)).toBe(0);
|
||||
rememberCodexRateLimitsRead(first, { rateLimits: { limitId: "first" } }, 100);
|
||||
rememberCodexRateLimitsRead(second, { rateLimits: { limitId: "second" } }, 200);
|
||||
expect(readCodexRateLimitsRevision(first, "first")).toBe(1);
|
||||
expect(readCodexRateLimitsRevision(second, "second")).toBe(1);
|
||||
|
||||
expect(readRecentCodexRateLimits(first, { nowMs: 250 })).toEqual({
|
||||
rateLimits: { limitId: "first" },
|
||||
});
|
||||
expect(readRecentCodexRateLimits(second, { nowMs: 250 })).toEqual({
|
||||
rateLimits: { limitId: "second" },
|
||||
});
|
||||
expect(readRecentCodexRateLimits(first, { nowMs: 301, maxAgeMs: 200 })).toBeUndefined();
|
||||
expect(readRecentCodexRateLimits(second, { nowMs: 301, maxAgeMs: 200 })).toEqual({
|
||||
rateLimits: { limitId: "second" },
|
||||
});
|
||||
});
|
||||
|
||||
it("merges sparse rolling updates without clearing account metadata", () => {
|
||||
const client = clientIdentity();
|
||||
const codexSnapshot = {
|
||||
limitId: "codex",
|
||||
limitName: "Codex",
|
||||
primary: { usedPercent: 10, windowDurationMins: 300, resetsAt: 1000 },
|
||||
secondary: { usedPercent: 20, windowDurationMins: 10_080, resetsAt: 2000 },
|
||||
credits: { hasCredits: true, unlimited: false, balance: "5" },
|
||||
individualLimit: {
|
||||
limit: "25000",
|
||||
used: "8000",
|
||||
remainingPercent: 68,
|
||||
resetsAt: 3000,
|
||||
},
|
||||
planType: "pro",
|
||||
rateLimitReachedType: "rate_limit_reached",
|
||||
};
|
||||
const otherSnapshot = {
|
||||
limitId: "codex_other",
|
||||
limitName: "Other",
|
||||
primary: { usedPercent: 30, windowDurationMins: 60, resetsAt: 4000 },
|
||||
secondary: null,
|
||||
credits: null,
|
||||
individualLimit: null,
|
||||
planType: "pro",
|
||||
rateLimitReachedType: null,
|
||||
};
|
||||
rememberCodexRateLimitsRead(client, {
|
||||
rateLimits: codexSnapshot,
|
||||
rateLimitsByLimitId: { codex: codexSnapshot, codex_other: otherSnapshot },
|
||||
});
|
||||
|
||||
mergeCodexRateLimitsUpdate(client, {
|
||||
rateLimits: {
|
||||
limitId: null,
|
||||
limitName: null,
|
||||
primary: { usedPercent: 90, windowDurationMins: 300, resetsAt: 5000 },
|
||||
secondary: null,
|
||||
credits: null,
|
||||
individualLimit: null,
|
||||
planType: null,
|
||||
rateLimitReachedType: null,
|
||||
},
|
||||
});
|
||||
mergeCodexRateLimitsUpdate(client, {
|
||||
rateLimits: {
|
||||
limitId: "codex_other",
|
||||
limitName: null,
|
||||
primary: { usedPercent: 75, windowDurationMins: 60, resetsAt: 6000 },
|
||||
secondary: null,
|
||||
credits: null,
|
||||
individualLimit: null,
|
||||
planType: null,
|
||||
rateLimitReachedType: null,
|
||||
},
|
||||
});
|
||||
expect(readCodexRateLimitsRevision(client)).toBe(2);
|
||||
expect(readCodexRateLimitsRevision(client, "codex_other")).toBe(2);
|
||||
|
||||
const mergedCodexSnapshot = {
|
||||
limitId: "codex",
|
||||
limitName: null,
|
||||
primary: { usedPercent: 90, windowDurationMins: 300, resetsAt: 5000 },
|
||||
secondary: null,
|
||||
credits: codexSnapshot.credits,
|
||||
individualLimit: codexSnapshot.individualLimit,
|
||||
planType: "pro",
|
||||
rateLimitReachedType: null,
|
||||
};
|
||||
const mergedOtherSnapshot = {
|
||||
limitId: "codex_other",
|
||||
limitName: null,
|
||||
primary: { usedPercent: 75, windowDurationMins: 60, resetsAt: 6000 },
|
||||
secondary: null,
|
||||
credits: codexSnapshot.credits,
|
||||
individualLimit: codexSnapshot.individualLimit,
|
||||
planType: "pro",
|
||||
rateLimitReachedType: null,
|
||||
};
|
||||
expect(readRecentCodexRateLimits(client)).toEqual({
|
||||
rateLimits: mergedCodexSnapshot,
|
||||
rateLimitsByLimitId: {
|
||||
codex: mergedCodexSnapshot,
|
||||
codex_other: mergedOtherSnapshot,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,55 +1,166 @@
|
||||
/**
|
||||
* Keeps the latest Codex app-server rate-limit payload in process-global state
|
||||
* so failure handling can enrich later usage-limit errors.
|
||||
*/
|
||||
import type { JsonValue } from "./protocol.js";
|
||||
/** Client-owned Codex app-server rate-limit snapshots. */
|
||||
import type { CodexAppServerClient } from "./client.js";
|
||||
import { isJsonObject, type JsonObject, type JsonValue } from "./protocol.js";
|
||||
|
||||
const DEFAULT_CODEX_RATE_LIMIT_CACHE_MAX_AGE_MS = 10 * 60_000;
|
||||
const CODEX_RATE_LIMIT_CACHE_STATE = Symbol.for("openclaw.codexRateLimitCacheState");
|
||||
const SPARSE_ACCOUNT_METADATA_KEYS = ["credits", "individualLimit", "planType"] as const;
|
||||
|
||||
type CodexRateLimitCacheState = {
|
||||
value?: JsonValue;
|
||||
updatedAtMs?: number;
|
||||
value: JsonValue;
|
||||
updatedAtMs: number;
|
||||
revisionsByLimitId: Record<string, number>;
|
||||
};
|
||||
|
||||
function getCodexRateLimitCacheState(): CodexRateLimitCacheState {
|
||||
const globalState = globalThis as typeof globalThis & {
|
||||
[CODEX_RATE_LIMIT_CACHE_STATE]?: CodexRateLimitCacheState;
|
||||
};
|
||||
globalState[CODEX_RATE_LIMIT_CACHE_STATE] ??= {};
|
||||
return globalState[CODEX_RATE_LIMIT_CACHE_STATE];
|
||||
const rateLimitsByClient = new WeakMap<CodexAppServerClient, CodexRateLimitCacheState>();
|
||||
|
||||
/** Replaces one physical client's cache with an authoritative rate-limit read response. */
|
||||
export function rememberCodexRateLimitsRead(
|
||||
client: CodexAppServerClient,
|
||||
value: JsonValue | undefined,
|
||||
nowMs = Date.now(),
|
||||
): void {
|
||||
if (value !== undefined) {
|
||||
const currentState = rateLimitsByClient.get(client);
|
||||
const revisionsByLimitId = { ...currentState?.revisionsByLimitId };
|
||||
for (const limitId of readRateLimitIds(value)) {
|
||||
revisionsByLimitId[limitId] = (revisionsByLimitId[limitId] ?? 0) + 1;
|
||||
}
|
||||
rateLimitsByClient.set(client, {
|
||||
value,
|
||||
updatedAtMs: nowMs,
|
||||
revisionsByLimitId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Stores a non-empty Codex rate-limit payload with its observation time. */
|
||||
export function rememberCodexRateLimits(value: JsonValue | undefined, nowMs = Date.now()): void {
|
||||
if (value === undefined) {
|
||||
/** Merges a sparse rolling notification into one physical client's latest read response. */
|
||||
export function mergeCodexRateLimitsUpdate(
|
||||
client: CodexAppServerClient,
|
||||
value: JsonValue | undefined,
|
||||
nowMs = Date.now(),
|
||||
): void {
|
||||
const update =
|
||||
isJsonObject(value) && isJsonObject(value.rateLimits) ? value.rateLimits : undefined;
|
||||
if (!update) {
|
||||
return;
|
||||
}
|
||||
const state = getCodexRateLimitCacheState();
|
||||
state.value = value;
|
||||
state.updatedAtMs = nowMs;
|
||||
const currentState = rateLimitsByClient.get(client);
|
||||
const current = currentState?.value;
|
||||
const limitId = readLimitId(update);
|
||||
rateLimitsByClient.set(client, {
|
||||
value: mergeRateLimitUpdate(current, update),
|
||||
updatedAtMs: nowMs,
|
||||
revisionsByLimitId: {
|
||||
...currentState?.revisionsByLimitId,
|
||||
[limitId]: (currentState?.revisionsByLimitId[limitId] ?? 0) + 1,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Reads the cached Codex rate-limit payload when it is still within the max-age window. */
|
||||
export function readRecentCodexRateLimits(options?: {
|
||||
nowMs?: number;
|
||||
maxAgeMs?: number;
|
||||
}): JsonValue | undefined {
|
||||
const state = getCodexRateLimitCacheState();
|
||||
if (state.value === undefined || state.updatedAtMs === undefined) {
|
||||
/** Per-limit marker used to trust only primary Codex updates from one turn startup. */
|
||||
export function readCodexRateLimitsRevision(
|
||||
client: CodexAppServerClient,
|
||||
limitId = "codex",
|
||||
): number {
|
||||
return rateLimitsByClient.get(client)?.revisionsByLimitId[limitId] ?? 0;
|
||||
}
|
||||
|
||||
/** Reads one physical client's cached rate-limit payload within the max-age window. */
|
||||
export function readRecentCodexRateLimits(
|
||||
client: CodexAppServerClient,
|
||||
options?: {
|
||||
nowMs?: number;
|
||||
maxAgeMs?: number;
|
||||
},
|
||||
): JsonValue | undefined {
|
||||
const state = rateLimitsByClient.get(client);
|
||||
if (!state) {
|
||||
return undefined;
|
||||
}
|
||||
const nowMs = options?.nowMs ?? Date.now();
|
||||
const maxAgeMs = options?.maxAgeMs ?? DEFAULT_CODEX_RATE_LIMIT_CACHE_MAX_AGE_MS;
|
||||
if (maxAgeMs >= 0 && nowMs - state.updatedAtMs > maxAgeMs) {
|
||||
return undefined;
|
||||
}
|
||||
return state.value;
|
||||
return maxAgeMs >= 0 && nowMs - state.updatedAtMs > maxAgeMs ? undefined : state.value;
|
||||
}
|
||||
|
||||
/** Clears the process-global rate-limit cache for deterministic tests. */
|
||||
export function resetCodexRateLimitCacheForTests(): void {
|
||||
const state = getCodexRateLimitCacheState();
|
||||
state.value = undefined;
|
||||
state.updatedAtMs = undefined;
|
||||
function mergeRateLimitUpdate(current: JsonValue | undefined, update: JsonObject): JsonObject {
|
||||
const currentEnvelope = isJsonObject(current) ? current : undefined;
|
||||
const currentPrimary =
|
||||
currentEnvelope && isJsonObject(currentEnvelope.rateLimits)
|
||||
? currentEnvelope.rateLimits
|
||||
: undefined;
|
||||
const currentByLimitId =
|
||||
currentEnvelope && isJsonObject(currentEnvelope.rateLimitsByLimitId)
|
||||
? currentEnvelope.rateLimitsByLimitId
|
||||
: undefined;
|
||||
const limitId = readLimitId(update);
|
||||
const currentPrimaryLimitId = currentPrimary ? readLimitId(currentPrimary) : undefined;
|
||||
const currentForLimit =
|
||||
(currentByLimitId && isJsonObject(currentByLimitId[limitId])
|
||||
? currentByLimitId[limitId]
|
||||
: undefined) ?? (currentPrimaryLimitId === limitId ? currentPrimary : undefined);
|
||||
const merged = mergeSparseSnapshot(
|
||||
isJsonObject(currentForLimit) ? currentForLimit : undefined,
|
||||
currentPrimary,
|
||||
update,
|
||||
limitId,
|
||||
);
|
||||
const nextPrimary =
|
||||
!currentPrimary || currentPrimaryLimitId === limitId ? merged : currentPrimary;
|
||||
let nextByLimitId: JsonObject | undefined;
|
||||
if (currentByLimitId) {
|
||||
nextByLimitId = { ...currentByLimitId, [limitId]: merged };
|
||||
} else if (currentPrimary && currentPrimaryLimitId && currentPrimaryLimitId !== limitId) {
|
||||
nextByLimitId = {
|
||||
[currentPrimaryLimitId]: currentPrimary,
|
||||
[limitId]: merged,
|
||||
};
|
||||
}
|
||||
return {
|
||||
...currentEnvelope,
|
||||
rateLimits: nextPrimary,
|
||||
...(nextByLimitId ? { rateLimitsByLimitId: nextByLimitId } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function readRateLimitIds(value: JsonValue): string[] {
|
||||
if (!isJsonObject(value)) {
|
||||
return [];
|
||||
}
|
||||
const ids = new Set<string>();
|
||||
if (isJsonObject(value.rateLimits)) {
|
||||
ids.add(readLimitId(value.rateLimits));
|
||||
}
|
||||
if (isJsonObject(value.rateLimitsByLimitId)) {
|
||||
for (const [key, snapshot] of Object.entries(value.rateLimitsByLimitId)) {
|
||||
const snapshotLimitId =
|
||||
isJsonObject(snapshot) && typeof snapshot.limitId === "string"
|
||||
? snapshot.limitId.trim()
|
||||
: "";
|
||||
ids.add(snapshotLimitId || key);
|
||||
}
|
||||
}
|
||||
return [...ids];
|
||||
}
|
||||
|
||||
function mergeSparseSnapshot(
|
||||
current: JsonObject | undefined,
|
||||
accountFallback: JsonObject | undefined,
|
||||
update: JsonObject,
|
||||
limitId: string,
|
||||
): JsonObject {
|
||||
const merged: JsonObject = { ...update, limitId };
|
||||
// Rolling updates serialize unavailable account metadata as null. Preserve
|
||||
// only those sparse fields; window and reached-state nulls remain authoritative.
|
||||
for (const key of SPARSE_ACCOUNT_METADATA_KEYS) {
|
||||
const previous = current?.[key] ?? accountFallback?.[key];
|
||||
if (merged[key] == null && previous != null) {
|
||||
merged[key] = previous;
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
function readLimitId(snapshot: JsonObject): string {
|
||||
const value = snapshot.limitId;
|
||||
return typeof value === "string" && value.trim() ? value.trim() : "codex";
|
||||
}
|
||||
|
||||
@@ -1,23 +1,80 @@
|
||||
// Codex tests cover request plugin behavior.
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { CodexAppServerClient } from "./client.js";
|
||||
import { readRecentCodexRateLimits } from "./rate-limit-cache.js";
|
||||
|
||||
const sharedClientMocks = vi.hoisted(() => ({
|
||||
abandon: vi.fn(async () => undefined),
|
||||
createIsolatedCodexAppServerClient: vi.fn(),
|
||||
getSharedCodexAppServerClient: vi.fn(),
|
||||
release: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./shared-client.js", () => ({
|
||||
...sharedClientMocks,
|
||||
getLeasedSharedCodexAppServerClient: sharedClientMocks.getSharedCodexAppServerClient,
|
||||
releaseLeasedSharedCodexAppServerClient: vi.fn(),
|
||||
leaseSharedCodexAppServerClient: async (...args: unknown[]) => {
|
||||
let settled = false;
|
||||
return {
|
||||
client: await sharedClientMocks.getSharedCodexAppServerClient(...args),
|
||||
release: () => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
sharedClientMocks.release();
|
||||
}
|
||||
},
|
||||
abandon: async () => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
await sharedClientMocks.abandon();
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
const { requestCodexAppServerJson } = await import("./request.js");
|
||||
|
||||
function resumeResponse(threadId: string) {
|
||||
return {
|
||||
thread: {
|
||||
id: threadId,
|
||||
sessionId: "session-1",
|
||||
forkedFromId: null,
|
||||
preview: "",
|
||||
ephemeral: false,
|
||||
modelProvider: "openai",
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
status: { type: "idle" },
|
||||
path: null,
|
||||
cwd: "/repo",
|
||||
cliVersion: "0.139.0",
|
||||
source: "unknown",
|
||||
agentNickname: null,
|
||||
agentRole: null,
|
||||
gitInfo: null,
|
||||
name: null,
|
||||
turns: [],
|
||||
},
|
||||
model: "gpt-5.5-codex",
|
||||
modelProvider: "openai",
|
||||
serviceTier: null,
|
||||
cwd: "/repo",
|
||||
instructionSources: [],
|
||||
approvalPolicy: "never",
|
||||
approvalsReviewer: "user",
|
||||
sandbox: { type: "dangerFullAccess" },
|
||||
permissionProfile: null,
|
||||
reasoningEffort: null,
|
||||
};
|
||||
}
|
||||
|
||||
describe("requestCodexAppServerJson sandbox guard", () => {
|
||||
beforeEach(() => {
|
||||
sharedClientMocks.createIsolatedCodexAppServerClient.mockReset();
|
||||
sharedClientMocks.getSharedCodexAppServerClient.mockReset();
|
||||
sharedClientMocks.release.mockClear();
|
||||
sharedClientMocks.abandon.mockClear();
|
||||
});
|
||||
|
||||
it("fails closed before raw app-server bypass methods in sandboxed sessions", async () => {
|
||||
@@ -35,6 +92,29 @@ describe("requestCodexAppServerJson sandbox guard", () => {
|
||||
expect(sharedClientMocks.getSharedCodexAppServerClient).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses the explicit agent sandbox for globally scoped session keys", async () => {
|
||||
await expect(
|
||||
requestCodexAppServerJson({
|
||||
method: "command/exec",
|
||||
requestParams: { command: ["sh", "-lc", "id"] },
|
||||
config: {
|
||||
agents: {
|
||||
list: [
|
||||
{ id: "main", default: true, sandbox: { mode: "off" } },
|
||||
{ id: "work", sandbox: { mode: "all" } },
|
||||
],
|
||||
},
|
||||
},
|
||||
agentId: "work",
|
||||
sessionKey: "global-session",
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
"Codex-native app-server method `command/exec` is unavailable because OpenClaw sandboxing is active for this session.",
|
||||
);
|
||||
|
||||
expect(sharedClientMocks.getSharedCodexAppServerClient).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fails closed before raw app-server bypass methods when exec host=node is active", async () => {
|
||||
for (const method of ["command/exec", "process/spawn"]) {
|
||||
await expect(
|
||||
@@ -65,7 +145,31 @@ describe("requestCodexAppServerJson sandbox guard", () => {
|
||||
}),
|
||||
).resolves.toEqual({ ok: true });
|
||||
|
||||
expect(request).toHaveBeenCalledWith("thread/list", { limit: 10 }, { timeoutMs: 60_000 });
|
||||
expect(request).toHaveBeenCalledWith(
|
||||
"thread/list",
|
||||
{ limit: 10 },
|
||||
expect.objectContaining({
|
||||
timeoutMs: expect.any(Number),
|
||||
signal: expect.any(AbortSignal),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("records full rate-limit reads on the physical control client", async () => {
|
||||
const snapshot = { rateLimits: { limitId: "codex", primary: { usedPercent: 12 } } };
|
||||
const client = {
|
||||
request: vi.fn(async () => snapshot),
|
||||
} as unknown as CodexAppServerClient;
|
||||
sharedClientMocks.getSharedCodexAppServerClient.mockResolvedValue(client);
|
||||
|
||||
await expect(
|
||||
requestCodexAppServerJson({
|
||||
method: "account/rateLimits/read",
|
||||
requestParams: undefined,
|
||||
}),
|
||||
).resolves.toEqual(snapshot);
|
||||
|
||||
expect(readRecentCodexRateLimits(client)).toEqual(snapshot);
|
||||
});
|
||||
|
||||
it("fails closed for config-level exec host=node even without a session key", async () => {
|
||||
@@ -109,11 +213,125 @@ describe("requestCodexAppServerJson sandbox guard", () => {
|
||||
}),
|
||||
).resolves.toEqual({ ok: true });
|
||||
|
||||
expect(request).toHaveBeenCalledWith("thread/list", { limit: 10 }, { timeoutMs: 60_000 });
|
||||
expect(request).toHaveBeenCalledWith(
|
||||
"thread/list",
|
||||
{ limit: 10 },
|
||||
expect.objectContaining({
|
||||
timeoutMs: expect.any(Number),
|
||||
signal: expect.any(AbortSignal),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("unsubscribes owned resumes but abandons a mismatched response", async () => {
|
||||
const request = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(resumeResponse("thread-1"))
|
||||
.mockResolvedValueOnce({})
|
||||
.mockRejectedValueOnce(new Error("resume response lost"))
|
||||
.mockResolvedValueOnce(resumeResponse("wrong-thread"));
|
||||
sharedClientMocks.getSharedCodexAppServerClient.mockResolvedValue({
|
||||
request,
|
||||
addNotificationHandler: vi.fn(() => () => undefined),
|
||||
});
|
||||
|
||||
await expect(
|
||||
requestCodexAppServerJson({
|
||||
method: "thread/resume",
|
||||
requestParams: { threadId: "thread-1" },
|
||||
}),
|
||||
).resolves.toMatchObject({ thread: { id: "thread-1" } });
|
||||
await expect(
|
||||
requestCodexAppServerJson({
|
||||
method: "thread/resume",
|
||||
requestParams: { threadId: "thread-2" },
|
||||
}),
|
||||
).rejects.toThrow("resume response lost");
|
||||
await expect(
|
||||
requestCodexAppServerJson({
|
||||
method: "thread/resume",
|
||||
requestParams: { threadId: "thread-3" },
|
||||
}),
|
||||
).rejects.toThrow("Codex thread/resume returned wrong-thread for thread-3");
|
||||
|
||||
expect(request.mock.calls.map(([method, params]) => [method, params])).toEqual([
|
||||
["thread/resume", { threadId: "thread-1" }],
|
||||
["thread/unsubscribe", { threadId: "thread-1" }],
|
||||
["thread/resume", { threadId: "thread-2" }],
|
||||
["thread/resume", { threadId: "thread-3" }],
|
||||
]);
|
||||
expect(sharedClientMocks.abandon).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("does not release a thread owner when the request deadline expires before resume", async () => {
|
||||
const request = vi.fn();
|
||||
sharedClientMocks.getSharedCodexAppServerClient.mockResolvedValue({ request });
|
||||
const now = vi.spyOn(Date, "now").mockReturnValueOnce(0).mockReturnValue(10);
|
||||
|
||||
await expect(
|
||||
requestCodexAppServerJson({
|
||||
method: "thread/resume",
|
||||
requestParams: { threadId: "thread-1" },
|
||||
timeoutMs: 1,
|
||||
}),
|
||||
).rejects.toThrow("codex app-server thread/resume timed out");
|
||||
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
expect(sharedClientMocks.release).toHaveBeenCalledOnce();
|
||||
expect(sharedClientMocks.abandon).not.toHaveBeenCalled();
|
||||
now.mockRestore();
|
||||
});
|
||||
|
||||
it("retires an isolated client that resolves after the end-to-end deadline", async () => {
|
||||
let resolveClient!: (client: CodexAppServerClient) => void;
|
||||
sharedClientMocks.createIsolatedCodexAppServerClient.mockImplementationOnce(
|
||||
async () =>
|
||||
await new Promise<CodexAppServerClient>((resolve) => {
|
||||
resolveClient = resolve;
|
||||
}),
|
||||
);
|
||||
const request = vi.fn();
|
||||
const closeAndWait = vi.fn(async () => undefined);
|
||||
const response = requestCodexAppServerJson({
|
||||
method: "model/list",
|
||||
requestParams: { limit: 10 },
|
||||
isolated: true,
|
||||
timeoutMs: 5,
|
||||
});
|
||||
|
||||
await expect(response).rejects.toThrow("codex app-server model/list timed out");
|
||||
resolveClient({ request, closeAndWait } as unknown as CodexAppServerClient);
|
||||
await vi.waitFor(() => expect(closeAndWait).toHaveBeenCalledOnce());
|
||||
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not let isolated teardown extend the caller deadline", async () => {
|
||||
const request = vi.fn(async () => ({ data: [] }));
|
||||
const closeAndWait = vi.fn(async () => await new Promise<void>(() => {}));
|
||||
sharedClientMocks.createIsolatedCodexAppServerClient.mockResolvedValue({
|
||||
request,
|
||||
closeAndWait,
|
||||
});
|
||||
|
||||
await expect(
|
||||
requestCodexAppServerJson({
|
||||
method: "model/list",
|
||||
requestParams: { limit: 10 },
|
||||
isolated: true,
|
||||
timeoutMs: 5,
|
||||
}),
|
||||
).rejects.toThrow("codex app-server model/list timed out");
|
||||
|
||||
expect(request).toHaveBeenCalledOnce();
|
||||
expect(closeAndWait).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("allows sandbox-pinned thread starts in sandboxed sessions", async () => {
|
||||
const request = vi.fn(async () => ({ thread: { id: "thread-1" }, model: "gpt-5.5" }));
|
||||
const request = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ thread: { id: "thread-1" }, model: "gpt-5.5" })
|
||||
.mockResolvedValueOnce({});
|
||||
sharedClientMocks.getSharedCodexAppServerClient.mockResolvedValue({ request });
|
||||
const params = {
|
||||
cwd: "/workspace",
|
||||
@@ -129,7 +347,44 @@ describe("requestCodexAppServerJson sandbox guard", () => {
|
||||
}),
|
||||
).resolves.toEqual({ thread: { id: "thread-1" }, model: "gpt-5.5" });
|
||||
|
||||
expect(request).toHaveBeenCalledWith("thread/start", params, { timeoutMs: 60_000 });
|
||||
expect(request.mock.calls).toEqual([
|
||||
[
|
||||
"thread/start",
|
||||
params,
|
||||
expect.objectContaining({
|
||||
timeoutMs: expect.any(Number),
|
||||
signal: expect.any(AbortSignal),
|
||||
}),
|
||||
],
|
||||
["thread/unsubscribe", { threadId: "thread-1" }, { timeoutMs: 5_000 }],
|
||||
]);
|
||||
});
|
||||
|
||||
it("unsubscribes one-shot shared thread forks", async () => {
|
||||
const request = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ thread: { id: "child-thread" } })
|
||||
.mockResolvedValueOnce({});
|
||||
sharedClientMocks.getSharedCodexAppServerClient.mockResolvedValue({ request });
|
||||
|
||||
await expect(
|
||||
requestCodexAppServerJson({
|
||||
method: "thread/fork",
|
||||
requestParams: { threadId: "parent-thread" },
|
||||
}),
|
||||
).resolves.toEqual({ thread: { id: "child-thread" } });
|
||||
|
||||
expect(request.mock.calls).toEqual([
|
||||
[
|
||||
"thread/fork",
|
||||
{ threadId: "parent-thread" },
|
||||
expect.objectContaining({
|
||||
timeoutMs: expect.any(Number),
|
||||
signal: expect.any(AbortSignal),
|
||||
}),
|
||||
],
|
||||
["thread/unsubscribe", { threadId: "child-thread" }, { timeoutMs: 5_000 }],
|
||||
]);
|
||||
});
|
||||
|
||||
it("blocks thread starts with sandbox environments when exec host=node is active", async () => {
|
||||
|
||||
@@ -1,21 +1,30 @@
|
||||
import {
|
||||
CODEX_APP_SERVER_UNSUBSCRIBE_TIMEOUT_MS,
|
||||
CodexAppServerUnsafeSubscriptionError,
|
||||
settleCodexAppServerClientLease,
|
||||
} from "./attempt-client-cleanup.js";
|
||||
/**
|
||||
* Sends typed JSON-RPC requests to the Codex app-server with sandbox guard
|
||||
* checks, shared-client leasing, and isolated-client shutdown handling.
|
||||
*/
|
||||
import type { resolveCodexAppServerAuthProfileIdForAgent } from "./auth-bridge.js";
|
||||
import type { CodexAppServerStartOptions } from "./config.js";
|
||||
import type {
|
||||
CodexAppServerRequestMethod,
|
||||
CodexAppServerRequestParams,
|
||||
CodexAppServerRequestResult,
|
||||
JsonValue,
|
||||
import {
|
||||
isJsonObject,
|
||||
readCodexThreadCreationResponseId,
|
||||
type CodexAppServerRequestMethod,
|
||||
type CodexAppServerRequestParams,
|
||||
type CodexAppServerRequestResult,
|
||||
type CodexThreadResumeParams,
|
||||
type JsonValue,
|
||||
} from "./protocol.js";
|
||||
import { rememberCodexRateLimitsRead } from "./rate-limit-cache.js";
|
||||
import { resolveCodexAppServerDirectSandboxBypassBlock } from "./sandbox-guard.js";
|
||||
import {
|
||||
createIsolatedCodexAppServerClient,
|
||||
getLeasedSharedCodexAppServerClient,
|
||||
releaseLeasedSharedCodexAppServerClient,
|
||||
leaseSharedCodexAppServerClient,
|
||||
} from "./shared-client.js";
|
||||
import { resumeCodexAppServerThread } from "./thread-resume.js";
|
||||
import { withTimeout } from "./timeout.js";
|
||||
|
||||
/** Sends a typed Codex app-server request and returns the method-specific response shape. */
|
||||
@@ -25,6 +34,7 @@ export async function requestCodexAppServerJson<M extends CodexAppServerRequestM
|
||||
timeoutMs?: number;
|
||||
startOptions?: CodexAppServerStartOptions;
|
||||
authProfileId?: string | null;
|
||||
agentId?: string;
|
||||
agentDir?: string;
|
||||
config?: Parameters<typeof resolveCodexAppServerAuthProfileIdForAgent>[0]["config"];
|
||||
sessionKey?: string;
|
||||
@@ -37,6 +47,7 @@ export async function requestCodexAppServerJson<T = JsonValue | undefined>(param
|
||||
timeoutMs?: number;
|
||||
startOptions?: CodexAppServerStartOptions;
|
||||
authProfileId?: string | null;
|
||||
agentId?: string;
|
||||
agentDir?: string;
|
||||
config?: Parameters<typeof resolveCodexAppServerAuthProfileIdForAgent>[0]["config"];
|
||||
sessionKey?: string;
|
||||
@@ -49,6 +60,7 @@ export async function requestCodexAppServerJson<T = JsonValue | undefined>(param
|
||||
timeoutMs?: number;
|
||||
startOptions?: CodexAppServerStartOptions;
|
||||
authProfileId?: string | null;
|
||||
agentId?: string;
|
||||
agentDir?: string;
|
||||
config?: Parameters<typeof resolveCodexAppServerAuthProfileIdForAgent>[0]["config"];
|
||||
sessionKey?: string;
|
||||
@@ -59,6 +71,7 @@ export async function requestCodexAppServerJson<T = JsonValue | undefined>(param
|
||||
method: params.method,
|
||||
requestParams: params.requestParams,
|
||||
config: params.config,
|
||||
agentId: params.agentId,
|
||||
sessionKey: params.sessionKey,
|
||||
sessionId: params.sessionId,
|
||||
});
|
||||
@@ -66,33 +79,112 @@ export async function requestCodexAppServerJson<T = JsonValue | undefined>(param
|
||||
throw new Error(sandboxBlock);
|
||||
}
|
||||
const timeoutMs = params.timeoutMs ?? 60_000;
|
||||
return await withTimeout(
|
||||
(async () => {
|
||||
const client = await (
|
||||
params.isolated ? createIsolatedCodexAppServerClient : getLeasedSharedCodexAppServerClient
|
||||
)({
|
||||
startOptions: params.startOptions,
|
||||
timeoutMs,
|
||||
authProfileId: params.authProfileId,
|
||||
agentDir: params.agentDir,
|
||||
config: params.config,
|
||||
});
|
||||
try {
|
||||
return await client.request<T>(params.method, params.requestParams, { timeoutMs });
|
||||
} finally {
|
||||
if (params.isolated) {
|
||||
// Wait for the child to actually exit (with a SIGKILL fallback) so
|
||||
// the parent process doesn't hang on an orphaned codex app-server.
|
||||
// The stdio bin shim does not always propagate stdin EOF to the
|
||||
// underlying codex binary, so the unref'd close() path can leave
|
||||
// the child running and keep the parent's event loop alive.
|
||||
await client.closeAndWait({ exitTimeoutMs: 2_000, forceKillDelayMs: 250 });
|
||||
const timeoutMessage = `codex app-server ${params.method} timed out`;
|
||||
const abortController = new AbortController();
|
||||
const operation = (async () => {
|
||||
const startedAt = Date.now();
|
||||
const clientOptions = {
|
||||
startOptions: params.startOptions,
|
||||
timeoutMs,
|
||||
authProfileId: params.authProfileId,
|
||||
agentDir: params.agentDir,
|
||||
config: params.config,
|
||||
abandonSignal: abortController.signal,
|
||||
};
|
||||
const clientLease = params.isolated
|
||||
? undefined
|
||||
: await leaseSharedCodexAppServerClient(clientOptions);
|
||||
const client = clientLease?.client ?? (await createIsolatedCodexAppServerClient(clientOptions));
|
||||
const requestedThreadId =
|
||||
params.method === "thread/resume" && isJsonObject(params.requestParams)
|
||||
? typeof params.requestParams.threadId === "string"
|
||||
? params.requestParams.threadId
|
||||
: undefined
|
||||
: undefined;
|
||||
let subscribedThreadId: string | undefined;
|
||||
let abandonClient = false;
|
||||
try {
|
||||
abortController.signal.throwIfAborted();
|
||||
const requestTimeoutMs = remainingRequestTimeoutMs(startedAt, timeoutMs, params.method);
|
||||
let response: T;
|
||||
if (params.method === "thread/resume" && requestedThreadId) {
|
||||
subscribedThreadId = requestedThreadId;
|
||||
response = (await resumeCodexAppServerThread({
|
||||
client,
|
||||
abandonClient: clientLease
|
||||
? clientLease.abandon
|
||||
: async () =>
|
||||
await client.closeAndWait({ exitTimeoutMs: 2_000, forceKillDelayMs: 250 }),
|
||||
request: params.requestParams as CodexThreadResumeParams,
|
||||
timeoutMs: requestTimeoutMs,
|
||||
signal: abortController.signal,
|
||||
})) as T;
|
||||
} else {
|
||||
response = await client.request<T>(params.method, params.requestParams, {
|
||||
timeoutMs: requestTimeoutMs,
|
||||
signal: abortController.signal,
|
||||
});
|
||||
}
|
||||
if (params.method === "account/rateLimits/read") {
|
||||
rememberCodexRateLimitsRead(client, response as JsonValue | undefined);
|
||||
}
|
||||
if (isThreadSubscriptionMethod(params.method)) {
|
||||
const returnedThreadId = readCodexThreadCreationResponseId(response);
|
||||
if (!returnedThreadId) {
|
||||
abandonClient = true;
|
||||
throw new CodexAppServerUnsafeSubscriptionError(
|
||||
`Codex ${params.method} response omitted its thread id`,
|
||||
);
|
||||
}
|
||||
if (params.method === "thread/resume") {
|
||||
if (!requestedThreadId) {
|
||||
abandonClient = true;
|
||||
throw new CodexAppServerUnsafeSubscriptionError(
|
||||
"Codex thread/resume succeeded without a requested thread id",
|
||||
);
|
||||
}
|
||||
} else {
|
||||
releaseLeasedSharedCodexAppServerClient(client);
|
||||
subscribedThreadId = returnedThreadId;
|
||||
}
|
||||
}
|
||||
})(),
|
||||
timeoutMs,
|
||||
`codex app-server ${params.method} timed out`,
|
||||
);
|
||||
return response;
|
||||
} catch (error) {
|
||||
abandonClient ||= error instanceof CodexAppServerUnsafeSubscriptionError;
|
||||
throw error;
|
||||
} finally {
|
||||
if (params.isolated) {
|
||||
// Cleanup may outlive the caller's end-to-end deadline, but the outer
|
||||
// timeout aborts all work and returns without orphaning the child.
|
||||
await client.closeAndWait({ exitTimeoutMs: 2_000, forceKillDelayMs: 250 });
|
||||
} else if (clientLease) {
|
||||
await settleCodexAppServerClientLease(clientLease, {
|
||||
threadId: subscribedThreadId,
|
||||
timeoutMs: CODEX_APP_SERVER_UNSUBSCRIBE_TIMEOUT_MS,
|
||||
abandon: abandonClient,
|
||||
});
|
||||
}
|
||||
}
|
||||
})();
|
||||
try {
|
||||
return await withTimeout(operation, timeoutMs, timeoutMessage);
|
||||
} catch (error) {
|
||||
abortController.abort(error);
|
||||
void operation.catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function remainingRequestTimeoutMs(startedAt: number, timeoutMs: number, method: string): number {
|
||||
if (timeoutMs <= 0) {
|
||||
return timeoutMs;
|
||||
}
|
||||
const remaining = timeoutMs - (Date.now() - startedAt);
|
||||
if (remaining <= 0) {
|
||||
throw new Error(`codex app-server ${method} timed out`);
|
||||
}
|
||||
return Math.max(1, remaining);
|
||||
}
|
||||
|
||||
function isThreadSubscriptionMethod(method: string): boolean {
|
||||
return method === "thread/start" || method === "thread/fork" || method === "thread/resume";
|
||||
}
|
||||
|
||||
@@ -15,19 +15,29 @@ import { clearPluginCommands } from "openclaw/plugin-sdk/plugin-runtime";
|
||||
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
|
||||
import { afterEach, beforeEach, expect, vi } from "vitest";
|
||||
import { defaultCodexAppInventoryCache } from "./app-inventory-cache.js";
|
||||
import type { CodexAppServerClientFactory } from "./client-factory.js";
|
||||
import type { CodexAppServerClient } from "./client.js";
|
||||
import { createCodexDynamicToolBridge } from "./dynamic-tools.js";
|
||||
import type { CodexServerNotification } from "./protocol.js";
|
||||
import { resetCodexRateLimitCacheForTests } from "./rate-limit-cache.js";
|
||||
import type { CodexServerNotification, CodexThread } from "./protocol.js";
|
||||
import {
|
||||
runCodexAppServerAttempt as runCodexAppServerAttemptImpl,
|
||||
testing,
|
||||
} from "./run-attempt.js";
|
||||
import { closeCodexSandboxExecServersForTests } from "./sandbox-exec-server.js";
|
||||
import { createCodexTestModel } from "./test-support.js";
|
||||
import {
|
||||
registerCodexTestSessionIdentity,
|
||||
resetCodexTestBindingStore,
|
||||
testCodexAppServerBindingStore,
|
||||
} from "./session-binding.test-helpers.js";
|
||||
import type { CodexAppServerClientLeaseFactory } from "./shared-client.js";
|
||||
import {
|
||||
adaptCodexTestClientFactory,
|
||||
createCodexTestModel,
|
||||
type CodexTestAppServerClientFactory,
|
||||
} from "./test-support.js";
|
||||
|
||||
export let tempDir: string;
|
||||
let codexAppServerClientFactoryForTest: CodexAppServerClientFactory | undefined;
|
||||
let codexAppServerClientLeaseFactoryForTest: CodexAppServerClientLeaseFactory | undefined;
|
||||
const multiplexedTestClients = new WeakSet<CodexAppServerClient>();
|
||||
export const fastWait = { interval: 1, timeout: 5_000 } as const;
|
||||
const appServerHarnessWait = { interval: 1, timeout: 120_000 } as const;
|
||||
const activeAppServerAttemptsForTest = new Set<{
|
||||
@@ -37,9 +47,12 @@ const activeAppServerAttemptsForTest = new Set<{
|
||||
sessionKey?: string;
|
||||
}>();
|
||||
|
||||
type RunCodexAppServerAttemptOptions = NonNullable<
|
||||
type RunCodexAppServerAttemptImplOptions = NonNullable<
|
||||
Parameters<typeof runCodexAppServerAttemptImpl>[1]
|
||||
>;
|
||||
type RunCodexAppServerAttemptOptions = Omit<RunCodexAppServerAttemptImplOptions, "bindingStore"> & {
|
||||
bindingStore?: RunCodexAppServerAttemptImplOptions["bindingStore"];
|
||||
};
|
||||
|
||||
export function queueActiveRunMessageForTest(
|
||||
...args: Parameters<typeof queueAgentHarnessMessage>
|
||||
@@ -47,19 +60,66 @@ export function queueActiveRunMessageForTest(
|
||||
return queueAgentHarnessMessage(...args);
|
||||
}
|
||||
|
||||
export function setCodexAppServerClientFactoryForTest(factory: CodexAppServerClientFactory): void {
|
||||
codexAppServerClientFactoryForTest = factory;
|
||||
export function setCodexAppServerClientFactoryForTest(
|
||||
factory: CodexTestAppServerClientFactory,
|
||||
): void {
|
||||
codexAppServerClientLeaseFactoryForTest = adaptCodexTestClientFactory(async (...args) => {
|
||||
const client = await factory(...args);
|
||||
const testClient = client as unknown as {
|
||||
addCloseHandler?: (handler: () => void) => () => void;
|
||||
};
|
||||
// Narrow test doubles still need the client lifecycle hook installed by
|
||||
// the keyed router, even when the test never simulates transport closure.
|
||||
testClient.addCloseHandler ??= () => () => undefined;
|
||||
multiplexTestClientHandlers(client);
|
||||
return client;
|
||||
});
|
||||
}
|
||||
|
||||
function multiplexTestClientHandlers(client: CodexAppServerClient): void {
|
||||
if (multiplexedTestClients.has(client)) {
|
||||
return;
|
||||
}
|
||||
multiplexedTestClients.add(client);
|
||||
const notificationHandlers = new Set<
|
||||
Parameters<CodexAppServerClient["addNotificationHandler"]>[0]
|
||||
>();
|
||||
const requestHandlers = new Set<Parameters<CodexAppServerClient["addRequestHandler"]>[0]>();
|
||||
const addNotificationHandler = client.addNotificationHandler.bind(client);
|
||||
const addRequestHandler = client.addRequestHandler.bind(client);
|
||||
addNotificationHandler(async (notification) => {
|
||||
await Promise.all(
|
||||
[...notificationHandlers].map((handler) => Promise.resolve(handler(notification))),
|
||||
);
|
||||
});
|
||||
addRequestHandler(async (request) => {
|
||||
for (const handler of requestHandlers) {
|
||||
const result = await handler(request);
|
||||
if (result !== undefined) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
client.addNotificationHandler = (handler) => {
|
||||
notificationHandlers.add(handler);
|
||||
return () => notificationHandlers.delete(handler);
|
||||
};
|
||||
client.addRequestHandler = (handler) => {
|
||||
requestHandlers.add(handler);
|
||||
return () => requestHandlers.delete(handler);
|
||||
};
|
||||
}
|
||||
|
||||
function resetCodexAppServerClientFactoryForTest(): void {
|
||||
codexAppServerClientFactoryForTest = undefined;
|
||||
codexAppServerClientLeaseFactoryForTest = undefined;
|
||||
}
|
||||
|
||||
export function runCodexAppServerAttempt(
|
||||
params: EmbeddedRunAttemptParams,
|
||||
options: RunCodexAppServerAttemptOptions = {},
|
||||
) {
|
||||
const clientFactory = options.clientFactory ?? codexAppServerClientFactoryForTest;
|
||||
const clientLeaseFactory = options.clientLeaseFactory ?? codexAppServerClientLeaseFactoryForTest;
|
||||
const abortController = params.abortSignal ? undefined : new AbortController();
|
||||
const trackedParams = abortController
|
||||
? ({ ...params, abortSignal: abortController.signal } as EmbeddedRunAttemptParams)
|
||||
@@ -70,10 +130,11 @@ export function runCodexAppServerAttempt(
|
||||
sessionId: params.sessionId,
|
||||
sessionKey: params.sessionKey,
|
||||
};
|
||||
const promise = runCodexAppServerAttemptImpl(
|
||||
trackedParams,
|
||||
clientFactory ? { ...options, clientFactory } : options,
|
||||
).finally(() => {
|
||||
const promise = runCodexAppServerAttemptImpl(trackedParams, {
|
||||
...options,
|
||||
bindingStore: options.bindingStore ?? testCodexAppServerBindingStore,
|
||||
...(clientLeaseFactory ? { clientLeaseFactory } : {}),
|
||||
}).finally(() => {
|
||||
activeAppServerAttemptsForTest.delete(entry);
|
||||
});
|
||||
entry.promise = promise;
|
||||
@@ -121,6 +182,7 @@ async function drainActiveAppServerAttemptsForTest(): Promise<void> {
|
||||
}
|
||||
|
||||
export function createParams(sessionFile: string, workspaceDir: string): EmbeddedRunAttemptParams {
|
||||
registerCodexTestSessionIdentity(sessionFile, "session-1", "agent:main:session-1");
|
||||
return {
|
||||
prompt: "hello",
|
||||
sessionId: "session-1",
|
||||
@@ -223,7 +285,7 @@ export function threadStartResult(threadId = "thread-1") {
|
||||
agentRole: null,
|
||||
gitInfo: null,
|
||||
name: null,
|
||||
turns: [],
|
||||
turns: [] as CodexThread["turns"],
|
||||
},
|
||||
model: "gpt-5.4-codex",
|
||||
modelProvider: "openai",
|
||||
@@ -286,61 +348,73 @@ export function createAppServerHarness(
|
||||
} = {},
|
||||
) {
|
||||
const requests: Array<{ method: string; params: unknown }> = [];
|
||||
let notifyHandler: ((notification: CodexServerNotification) => Promise<void>) | undefined;
|
||||
let handleServerRequest: AppServerRequestHandler | undefined;
|
||||
const notificationHandlers = new Set<
|
||||
(notification: CodexServerNotification) => Promise<void> | void
|
||||
>();
|
||||
const serverRequestHandlers = new Set<AppServerRequestHandler>();
|
||||
const closeHandlers = new Set<() => void>();
|
||||
const request = vi.fn(async (method: string, params?: unknown, requestOptions?: unknown) => {
|
||||
requests.push({ method, params });
|
||||
return requestImpl(method, params, requestOptions as { signal?: AbortSignal } | undefined);
|
||||
});
|
||||
|
||||
const client = {
|
||||
getServerVersion: () => "0.132.0",
|
||||
request,
|
||||
addNotificationHandler: (
|
||||
handler: (notification: CodexServerNotification) => Promise<void> | void,
|
||||
) => {
|
||||
notificationHandlers.add(handler);
|
||||
return () => notificationHandlers.delete(handler);
|
||||
},
|
||||
addRequestHandler: (handler: AppServerRequestHandler) => {
|
||||
serverRequestHandlers.add(handler);
|
||||
return () => serverRequestHandlers.delete(handler);
|
||||
},
|
||||
addCloseHandler: (handler: () => void) => {
|
||||
closeHandlers.add(handler);
|
||||
return () => closeHandlers.delete(handler);
|
||||
},
|
||||
} as unknown as CodexAppServerClient;
|
||||
setCodexAppServerClientFactoryForTest(async (_startOptions, authProfileId, agentDir) => {
|
||||
options.onStart?.(authProfileId, agentDir);
|
||||
return {
|
||||
getServerVersion: () => "0.132.0",
|
||||
request,
|
||||
addNotificationHandler: (
|
||||
handler: (notification: CodexServerNotification) => Promise<void>,
|
||||
) => {
|
||||
notifyHandler = handler;
|
||||
return () => {
|
||||
if (notifyHandler === handler) {
|
||||
notifyHandler = undefined;
|
||||
}
|
||||
};
|
||||
},
|
||||
addRequestHandler: (handler: AppServerRequestHandler) => {
|
||||
handleServerRequest = handler;
|
||||
return () => undefined;
|
||||
},
|
||||
addCloseHandler: (handler: () => void) => {
|
||||
closeHandlers.add(handler);
|
||||
return () => closeHandlers.delete(handler);
|
||||
},
|
||||
} as never;
|
||||
return client;
|
||||
});
|
||||
|
||||
const waitForServerRequestHandler = async () => {
|
||||
await vi.waitFor(() => expect(handleServerRequest).toBeTypeOf("function"), {
|
||||
await vi.waitFor(() => expect(serverRequestHandlers.size).toBeGreaterThan(0), {
|
||||
interval: 1,
|
||||
timeout: appServerHarnessWait.timeout,
|
||||
});
|
||||
return handleServerRequest!;
|
||||
return async (requestLocal: Parameters<AppServerRequestHandler>[0]) => {
|
||||
for (const handler of serverRequestHandlers) {
|
||||
const result = await handler(requestLocal);
|
||||
if (result !== undefined) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
};
|
||||
|
||||
const waitForNotificationHandler = async () => {
|
||||
await vi.waitFor(() => expect(notifyHandler).toBeTypeOf("function"), {
|
||||
await vi.waitFor(() => expect(notificationHandlers.size).toBeGreaterThan(0), {
|
||||
interval: 1,
|
||||
timeout: appServerHarnessWait.timeout,
|
||||
});
|
||||
return notifyHandler!;
|
||||
return async (notification: CodexServerNotification) => {
|
||||
await Promise.all(
|
||||
[...notificationHandlers].map((handler) => Promise.resolve(handler(notification))),
|
||||
);
|
||||
};
|
||||
};
|
||||
const sendNotification = async (notification: CodexServerNotification) => {
|
||||
const handler = notifyHandler ?? (await waitForNotificationHandler());
|
||||
const handler = await waitForNotificationHandler();
|
||||
await handler(notification);
|
||||
};
|
||||
|
||||
return {
|
||||
client,
|
||||
request,
|
||||
requests,
|
||||
waitForMethod: async (method: string, timeoutMs: number = appServerHarnessWait.timeout) => {
|
||||
@@ -413,9 +487,10 @@ export function createStartedThreadHarness(
|
||||
}
|
||||
|
||||
export function createResumeHarness() {
|
||||
return createAppServerHarness(async (method) => {
|
||||
return createAppServerHarness(async (method, params) => {
|
||||
if (method === "thread/resume") {
|
||||
return threadStartResult("thread-existing");
|
||||
const threadId = (params as { threadId?: unknown }).threadId;
|
||||
return threadStartResult(typeof threadId === "string" ? threadId : "thread-existing");
|
||||
}
|
||||
if (method === "turn/start") {
|
||||
return turnStartResult();
|
||||
@@ -499,6 +574,7 @@ export function setupRunAttemptTestHooks(): void {
|
||||
clearMemoryPluginState();
|
||||
resetAgentEventsForTest();
|
||||
resetDiagnosticEventsForTest();
|
||||
resetCodexTestBindingStore();
|
||||
vi.stubEnv("OPENCLAW_TRAJECTORY", "0");
|
||||
vi.stubEnv("CODEX_API_KEY", "");
|
||||
vi.stubEnv("OPENAI_API_KEY", "");
|
||||
@@ -512,7 +588,6 @@ export function setupRunAttemptTestHooks(): void {
|
||||
testing.resetOpenClawCodingToolsFactoryForTests();
|
||||
testing.resetEnsuredCodexWorkspaceDirsForTests();
|
||||
testing.clearPendingCodexNativeHookRelayUnregistersForTests();
|
||||
resetCodexRateLimitCacheForTests();
|
||||
nativeHookRelayTesting.clearNativeHookRelaysForTests();
|
||||
clearMemoryPluginState();
|
||||
clearPluginCommands();
|
||||
|
||||
@@ -7,10 +7,35 @@ import {
|
||||
type EmbeddedRunAttemptParams,
|
||||
} from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { CodexAppServerClientFactory } from "./client-factory.js";
|
||||
import { CodexAppServerRpcError } from "./client.js";
|
||||
import type { CodexServerNotification } from "./protocol.js";
|
||||
import { runCodexAppServerAttempt } from "./run-attempt.js";
|
||||
import { createCodexTestModel } from "./test-support.js";
|
||||
import { createCodexTestBindingStore } from "./session-binding.test-helpers.js";
|
||||
import {
|
||||
adaptCodexTestClientFactory,
|
||||
createCodexTestModel,
|
||||
type CodexTestAppServerClientFactory,
|
||||
} from "./test-support.js";
|
||||
|
||||
const configRuntimeMock = vi.hoisted(() => ({ rejectedProvider: undefined as string | undefined }));
|
||||
|
||||
vi.mock("./config.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("./config.js")>();
|
||||
return {
|
||||
...actual,
|
||||
resolveCodexAppServerRuntime: (
|
||||
params: Parameters<typeof actual.resolveCodexAppServerRuntime>[0],
|
||||
) => {
|
||||
if (
|
||||
configRuntimeMock.rejectedProvider &&
|
||||
params?.modelProvider === configRuntimeMock.rejectedProvider
|
||||
) {
|
||||
throw new Error(`rejected active provider: ${params.modelProvider}`);
|
||||
}
|
||||
return actual.resolveCodexAppServerRuntime(params);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
let tempDir: string;
|
||||
|
||||
@@ -90,6 +115,7 @@ describe("Codex app-server main thread cleanup", () => {
|
||||
vi.stubEnv("OPENCLAW_TRAJECTORY", "0");
|
||||
vi.stubEnv("CODEX_API_KEY", "");
|
||||
vi.stubEnv("OPENAI_API_KEY", "");
|
||||
configRuntimeMock.rejectedProvider = undefined;
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-run-cleanup-"));
|
||||
});
|
||||
|
||||
@@ -105,7 +131,9 @@ describe("Codex app-server main thread cleanup", () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const requests: Array<{ method: string; params: unknown }> = [];
|
||||
let notify: (notification: CodexServerNotification) => Promise<void> = async () => undefined;
|
||||
const notificationHandlers = new Set<
|
||||
(notification: CodexServerNotification) => Promise<void> | void
|
||||
>();
|
||||
const request = vi.fn(async (method: string, params?: unknown) => {
|
||||
requests.push({ method, params });
|
||||
if (method === "thread/start") {
|
||||
@@ -117,32 +145,36 @@ describe("Codex app-server main thread cleanup", () => {
|
||||
return {};
|
||||
});
|
||||
|
||||
const clientFactory: CodexAppServerClientFactory = async () => {
|
||||
const clientFactory: CodexTestAppServerClientFactory = async () => {
|
||||
return {
|
||||
request,
|
||||
addNotificationHandler: (handler: typeof notify) => {
|
||||
notify = handler;
|
||||
return () => undefined;
|
||||
addNotificationHandler: (handler: (notification: CodexServerNotification) => void) => {
|
||||
notificationHandlers.add(handler);
|
||||
return () => notificationHandlers.delete(handler);
|
||||
},
|
||||
addRequestHandler: () => () => undefined,
|
||||
addCloseHandler: () => () => undefined,
|
||||
} as never;
|
||||
};
|
||||
|
||||
const run = runCodexAppServerAttempt(createParams(sessionFile, workspaceDir), {
|
||||
clientFactory,
|
||||
bindingStore: createCodexTestBindingStore(),
|
||||
clientLeaseFactory: adaptCodexTestClientFactory(clientFactory),
|
||||
});
|
||||
await vi.waitFor(() => expect(requests.map((entry) => entry.method)).toContain("turn/start"), {
|
||||
interval: 1,
|
||||
timeout: 5_000,
|
||||
});
|
||||
await notify({
|
||||
method: "turn/completed",
|
||||
params: {
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
turn: { id: "turn-1", status: "completed" },
|
||||
},
|
||||
});
|
||||
for (const handler of notificationHandlers) {
|
||||
await handler({
|
||||
method: "turn/completed",
|
||||
params: {
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
turn: { id: "turn-1", status: "completed" },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const result = await run;
|
||||
expect(result.aborted).toBe(false);
|
||||
@@ -173,19 +205,27 @@ describe("Codex app-server main thread cleanup", () => {
|
||||
return {};
|
||||
});
|
||||
|
||||
const clientFactory: CodexAppServerClientFactory = async () => {
|
||||
const clientFactory: CodexTestAppServerClientFactory = async () => {
|
||||
return {
|
||||
request,
|
||||
addNotificationHandler: () => () => undefined,
|
||||
addRequestHandler: () => () => undefined,
|
||||
addCloseHandler: () => () => undefined,
|
||||
} as never;
|
||||
};
|
||||
const abandon = vi.fn(async () => undefined);
|
||||
|
||||
await expect(
|
||||
runCodexAppServerAttempt(createParams(sessionFile, workspaceDir), {
|
||||
clientFactory,
|
||||
bindingStore: createCodexTestBindingStore(),
|
||||
clientLeaseFactory: async () => ({
|
||||
client: await clientFactory(),
|
||||
release: () => undefined,
|
||||
abandon,
|
||||
}),
|
||||
}),
|
||||
).rejects.toThrow("turn start exploded");
|
||||
expect(abandon).toHaveBeenCalledOnce();
|
||||
expect(requests.map((entry) => entry.method)).toEqual([
|
||||
"thread/start",
|
||||
"turn/start",
|
||||
@@ -197,4 +237,162 @@ describe("Codex app-server main thread cleanup", () => {
|
||||
{ timeoutMs: 5_000 },
|
||||
);
|
||||
});
|
||||
|
||||
it("releases startup ownership when authoritative provider policy rejects", async () => {
|
||||
const sessionFile = path.join(tempDir, "session-policy-rejection.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace-policy-rejection");
|
||||
const request = vi.fn(async (method: string) => {
|
||||
if (method === "thread/start") {
|
||||
const response = threadStartResult();
|
||||
return {
|
||||
...response,
|
||||
thread: { ...response.thread, modelProvider: "lmstudio" },
|
||||
model: "local-model",
|
||||
modelProvider: "lmstudio",
|
||||
};
|
||||
}
|
||||
if (method === "thread/unsubscribe") {
|
||||
return {};
|
||||
}
|
||||
throw new Error(`unexpected method: ${method}`);
|
||||
});
|
||||
const release = vi.fn();
|
||||
const abandon = vi.fn(async () => undefined);
|
||||
configRuntimeMock.rejectedProvider = "lmstudio";
|
||||
|
||||
await expect(
|
||||
runCodexAppServerAttempt(createParams(sessionFile, workspaceDir), {
|
||||
bindingStore: createCodexTestBindingStore(),
|
||||
clientLeaseFactory: async () => ({
|
||||
client: {
|
||||
request,
|
||||
addNotificationHandler: () => () => undefined,
|
||||
addRequestHandler: () => () => undefined,
|
||||
addCloseHandler: () => () => undefined,
|
||||
} as never,
|
||||
release,
|
||||
abandon,
|
||||
}),
|
||||
}),
|
||||
).rejects.toThrow("rejected active provider: lmstudio");
|
||||
|
||||
expect(request).toHaveBeenCalledWith(
|
||||
"thread/unsubscribe",
|
||||
{ threadId: "thread-1" },
|
||||
{ timeoutMs: 5_000 },
|
||||
);
|
||||
expect(release).toHaveBeenCalledOnce();
|
||||
expect(abandon).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps the main client reusable after a structured turn rejection", async () => {
|
||||
const sessionFile = path.join(tempDir, "session-rpc-rejection.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace-rpc-rejection");
|
||||
const request = vi.fn(async (method: string) => {
|
||||
if (method === "thread/start") {
|
||||
return threadStartResult();
|
||||
}
|
||||
if (method === "turn/start") {
|
||||
throw new CodexAppServerRpcError({ code: -32000, message: "turn rejected" }, method);
|
||||
}
|
||||
return {};
|
||||
});
|
||||
const abandon = vi.fn(async () => undefined);
|
||||
|
||||
await expect(
|
||||
runCodexAppServerAttempt(createParams(sessionFile, workspaceDir), {
|
||||
bindingStore: createCodexTestBindingStore(),
|
||||
clientLeaseFactory: async () => ({
|
||||
client: {
|
||||
request,
|
||||
addNotificationHandler: () => () => undefined,
|
||||
addRequestHandler: () => () => undefined,
|
||||
addCloseHandler: () => () => undefined,
|
||||
} as never,
|
||||
release: () => undefined,
|
||||
abandon,
|
||||
}),
|
||||
}),
|
||||
).rejects.toThrow("turn rejected");
|
||||
|
||||
expect(abandon).not.toHaveBeenCalled();
|
||||
expect(request).toHaveBeenCalledWith(
|
||||
"thread/unsubscribe",
|
||||
{ threadId: "thread-1" },
|
||||
{ timeoutMs: 5_000 },
|
||||
);
|
||||
});
|
||||
|
||||
it("reuses one client router after each attempt releases its thread route", async () => {
|
||||
const sessionFile = path.join(tempDir, "session-reused.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace-reused");
|
||||
const bindingStore = createCodexTestBindingStore();
|
||||
const notificationHandlers = new Set<
|
||||
(notification: CodexServerNotification) => Promise<void> | void
|
||||
>();
|
||||
const requestHandlers = new Set<(request: unknown) => unknown>();
|
||||
let turnIndex = 0;
|
||||
const request = vi.fn(async (method: string) => {
|
||||
if (method === "thread/start" || method === "thread/resume") {
|
||||
return threadStartResult();
|
||||
}
|
||||
if (method === "turn/start") {
|
||||
turnIndex += 1;
|
||||
return turnStartResult(`turn-${turnIndex}`);
|
||||
}
|
||||
return {};
|
||||
});
|
||||
const addNotificationHandler = vi.fn(
|
||||
(handler: (notification: CodexServerNotification) => Promise<void> | void) => {
|
||||
notificationHandlers.add(handler);
|
||||
return () => notificationHandlers.delete(handler);
|
||||
},
|
||||
);
|
||||
const addRequestHandler = vi.fn((handler: (request: unknown) => unknown) => {
|
||||
requestHandlers.add(handler);
|
||||
return () => requestHandlers.delete(handler);
|
||||
});
|
||||
const client = {
|
||||
request,
|
||||
addNotificationHandler,
|
||||
addRequestHandler,
|
||||
addCloseHandler: () => () => undefined,
|
||||
};
|
||||
const clientFactory: CodexTestAppServerClientFactory = async () => client as never;
|
||||
|
||||
const runAttempt = async (turnId: string, expectedTurnStartCount: number) => {
|
||||
const run = runCodexAppServerAttempt(createParams(sessionFile, workspaceDir), {
|
||||
bindingStore,
|
||||
clientLeaseFactory: adaptCodexTestClientFactory(clientFactory),
|
||||
});
|
||||
await vi.waitFor(
|
||||
() =>
|
||||
expect(request.mock.calls.filter(([method]) => method === "turn/start")).toHaveLength(
|
||||
expectedTurnStartCount,
|
||||
),
|
||||
{ interval: 1, timeout: 5_000 },
|
||||
);
|
||||
for (const handler of notificationHandlers) {
|
||||
void handler({
|
||||
method: "turn/completed",
|
||||
params: {
|
||||
threadId: "thread-1",
|
||||
turnId,
|
||||
turn: { id: turnId, threadId: "thread-1", status: "completed" },
|
||||
},
|
||||
});
|
||||
}
|
||||
return await run;
|
||||
};
|
||||
|
||||
await expect(runAttempt("turn-1", 1)).resolves.toMatchObject({ aborted: false });
|
||||
const notificationHandlerCount = addNotificationHandler.mock.calls.length;
|
||||
const requestHandlerCount = addRequestHandler.mock.calls.length;
|
||||
await expect(runAttempt("turn-2", 2)).resolves.toMatchObject({ aborted: false });
|
||||
|
||||
expect(addNotificationHandler).toHaveBeenCalledTimes(notificationHandlerCount);
|
||||
expect(addRequestHandler).toHaveBeenCalledTimes(requestHandlerCount);
|
||||
expect(notificationHandlers.size).toBe(notificationHandlerCount);
|
||||
expect(requestHandlers.size).toBe(requestHandlerCount);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,39 +11,63 @@ import {
|
||||
import { SessionManager } from "openclaw/plugin-sdk/agent-sessions";
|
||||
import { registerSandboxBackend } from "openclaw/plugin-sdk/sandbox";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { CodexAppServerClientFactory } from "./client-factory.js";
|
||||
import type { CodexAppServerClient } from "./client.js";
|
||||
import type { CodexServerNotification } from "./protocol.js";
|
||||
import { runCodexAppServerAttempt as runCodexAppServerAttemptImpl } from "./run-attempt.js";
|
||||
import { readCodexAppServerBinding, writeCodexAppServerBinding } from "./session-binding.js";
|
||||
import { createCodexTestModel } from "./test-support.js";
|
||||
import {
|
||||
clearCodexAppServerBinding,
|
||||
readCodexAppServerBinding,
|
||||
registerCodexTestSessionIdentity,
|
||||
resetCodexTestBindingStore,
|
||||
testCodexAppServerBindingStore,
|
||||
writeCodexAppServerBinding as writeStoredCodexAppServerBinding,
|
||||
} from "./session-binding.test-helpers.js";
|
||||
import type { CodexAppServerClientLeaseFactory } from "./shared-client.js";
|
||||
import {
|
||||
adaptCodexTestClientFactory,
|
||||
createCodexTestModel,
|
||||
type CodexTestAppServerClientFactory,
|
||||
} from "./test-support.js";
|
||||
|
||||
let tempDir: string;
|
||||
let codexAppServerClientFactoryForTest: CodexAppServerClientFactory | undefined;
|
||||
let codexAppServerClientLeaseFactoryForTest: CodexAppServerClientLeaseFactory | undefined;
|
||||
|
||||
type RunCodexAppServerAttemptOptions = NonNullable<
|
||||
type RunCodexAppServerAttemptImplOptions = NonNullable<
|
||||
Parameters<typeof runCodexAppServerAttemptImpl>[1]
|
||||
>;
|
||||
type RunCodexAppServerAttemptOptions = Omit<RunCodexAppServerAttemptImplOptions, "bindingStore"> & {
|
||||
bindingStore?: RunCodexAppServerAttemptImplOptions["bindingStore"];
|
||||
};
|
||||
|
||||
function setCodexAppServerClientFactoryForTest(factory: CodexAppServerClientFactory): void {
|
||||
codexAppServerClientFactoryForTest = factory;
|
||||
function setCodexAppServerClientFactoryForTest(factory: CodexTestAppServerClientFactory): void {
|
||||
codexAppServerClientLeaseFactoryForTest = adaptCodexTestClientFactory(factory);
|
||||
}
|
||||
|
||||
function resetCodexAppServerClientFactoryForTest(): void {
|
||||
codexAppServerClientFactoryForTest = undefined;
|
||||
codexAppServerClientLeaseFactoryForTest = undefined;
|
||||
}
|
||||
|
||||
async function writeCodexAppServerBinding(
|
||||
...args: Parameters<typeof writeStoredCodexAppServerBinding>
|
||||
): Promise<void> {
|
||||
registerCodexTestSessionIdentity(args[0], "session-1", "agent:main:session-1");
|
||||
await writeStoredCodexAppServerBinding(...args);
|
||||
}
|
||||
|
||||
function runCodexAppServerAttempt(
|
||||
params: EmbeddedRunAttemptParams,
|
||||
options: RunCodexAppServerAttemptOptions = {},
|
||||
) {
|
||||
const clientFactory = options.clientFactory ?? codexAppServerClientFactoryForTest;
|
||||
return runCodexAppServerAttemptImpl(
|
||||
params,
|
||||
clientFactory ? { ...options, clientFactory } : options,
|
||||
);
|
||||
const clientLeaseFactory = options.clientLeaseFactory ?? codexAppServerClientLeaseFactoryForTest;
|
||||
return runCodexAppServerAttemptImpl(params, {
|
||||
...options,
|
||||
bindingStore: options.bindingStore ?? testCodexAppServerBindingStore,
|
||||
...(clientLeaseFactory ? { clientLeaseFactory } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
function createParams(sessionFile: string, workspaceDir: string): EmbeddedRunAttemptParams {
|
||||
registerCodexTestSessionIdentity(sessionFile, "session-1", "agent:main:session-1");
|
||||
return {
|
||||
prompt: "hello",
|
||||
sessionId: "session-1",
|
||||
@@ -161,7 +185,10 @@ function createStartedThreadHarness(
|
||||
requestImpl: (method: string, params: unknown) => Promise<unknown> = async () => undefined,
|
||||
) {
|
||||
const requests: Array<{ method: string; params: unknown }> = [];
|
||||
let notify: (notification: CodexServerNotification) => Promise<void> = async () => undefined;
|
||||
const notificationHandlers = new Set<
|
||||
(notification: CodexServerNotification) => Promise<void> | void
|
||||
>();
|
||||
const requestHandlers = new Set<(request: unknown) => unknown>();
|
||||
const request = vi.fn(async (method: string, params?: unknown) => {
|
||||
requests.push({ method, params });
|
||||
const override = await requestImpl(method, params);
|
||||
@@ -177,20 +204,37 @@ function createStartedThreadHarness(
|
||||
return {};
|
||||
});
|
||||
|
||||
setCodexAppServerClientFactoryForTest(
|
||||
async () =>
|
||||
({
|
||||
request,
|
||||
addNotificationHandler: (handler: typeof notify) => {
|
||||
notify = handler;
|
||||
return () => undefined;
|
||||
},
|
||||
addRequestHandler: () => () => undefined,
|
||||
}) as never,
|
||||
);
|
||||
const client = {
|
||||
request,
|
||||
addNotificationHandler: (
|
||||
handler: (notification: CodexServerNotification) => Promise<void> | void,
|
||||
) => {
|
||||
notificationHandlers.add(handler);
|
||||
return () => notificationHandlers.delete(handler);
|
||||
},
|
||||
addRequestHandler: (handler: (request: unknown) => unknown) => {
|
||||
requestHandlers.add(handler);
|
||||
return () => requestHandlers.delete(handler);
|
||||
},
|
||||
addCloseHandler: () => () => undefined,
|
||||
} as unknown as CodexAppServerClient;
|
||||
setCodexAppServerClientFactoryForTest(async () => client);
|
||||
|
||||
const notify = async (notification: CodexServerNotification) => {
|
||||
await Promise.all(
|
||||
[...notificationHandlers].map((handler) => Promise.resolve(handler(notification))),
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
client,
|
||||
requests,
|
||||
async handleServerRequest(serverRequest: unknown) {
|
||||
const responses = await Promise.all(
|
||||
[...requestHandlers].map((handler) => Promise.resolve(handler(serverRequest))),
|
||||
);
|
||||
return responses[0];
|
||||
},
|
||||
async waitForMethod(method: string) {
|
||||
await vi.waitFor(() => expect(requests.map((entry) => entry.method)).toContain(method), {
|
||||
interval: 1,
|
||||
@@ -306,6 +350,7 @@ function getRequestInputTextAt(
|
||||
|
||||
describe("runCodexAppServerAttempt context-engine lifecycle", () => {
|
||||
beforeEach(async () => {
|
||||
resetCodexTestBindingStore();
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-context-engine-"));
|
||||
});
|
||||
|
||||
@@ -548,13 +593,14 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
|
||||
await secondRun;
|
||||
});
|
||||
|
||||
it("resumes a matching thread-bootstrap binding even when the bootstrap turn exceeded the opt-in native byte guard", async () => {
|
||||
it("resumes a matching thread-bootstrap binding without a native usage snapshot", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const agentDir = path.join(tempDir, "agent");
|
||||
await writeCodexAppServerBinding(sessionFile, {
|
||||
threadId: "thread-bootstrapped",
|
||||
cwd: workspaceDir,
|
||||
model: "gpt-5.4-codex",
|
||||
modelProvider: "openai",
|
||||
dynamicToolsFingerprint: "[]",
|
||||
contextEngine: {
|
||||
schemaVersion: 1,
|
||||
@@ -568,21 +614,6 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
await fs.writeFile(
|
||||
path.join(path.dirname(sessionFile), "sessions.json"),
|
||||
JSON.stringify({
|
||||
"agent:main:session-1": {
|
||||
sessionFile,
|
||||
totalTokens: 12_000,
|
||||
},
|
||||
}),
|
||||
);
|
||||
const rolloutDir = path.join(agentDir, "codex-home", "sessions");
|
||||
await fs.mkdir(rolloutDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(rolloutDir, "rollout-thread-bootstrapped.jsonl"),
|
||||
"x".repeat(2_000),
|
||||
);
|
||||
const contextEngine = createContextEngine({
|
||||
assemble: vi.fn(async ({ prompt }) => ({
|
||||
messages: [
|
||||
@@ -594,28 +625,11 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
|
||||
contextProjection: { mode: "thread_bootstrap" as const, epoch: "epoch-1" },
|
||||
})),
|
||||
});
|
||||
const harness = createStartedThreadHarness(async (method) => {
|
||||
if (method === "thread/resume") {
|
||||
return threadStartResult("thread-bootstrapped");
|
||||
}
|
||||
if (method === "thread/start") {
|
||||
return threadStartResult("thread-fresh");
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
const harness = createStartedThreadHarness(async (method) =>
|
||||
method === "thread/resume" ? threadStartResult("thread-bootstrapped") : undefined,
|
||||
);
|
||||
const params = createParams(sessionFile, workspaceDir);
|
||||
params.agentDir = agentDir;
|
||||
params.contextEngine = contextEngine;
|
||||
params.config = {
|
||||
agents: {
|
||||
defaults: {
|
||||
compaction: {
|
||||
truncateAfterCompaction: true,
|
||||
maxActiveTranscriptBytes: 1_000,
|
||||
},
|
||||
},
|
||||
},
|
||||
} as EmbeddedRunAttemptParams["config"];
|
||||
|
||||
const run = runCodexAppServerAttempt(params);
|
||||
await harness.waitForMethod("turn/start");
|
||||
@@ -633,12 +647,11 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
|
||||
await run;
|
||||
});
|
||||
|
||||
it("starts a fresh thread instead of resuming a token-pressured thread-bootstrap binding", async () => {
|
||||
it("projects assembled context when the binding changes during startup", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const agentDir = path.join(tempDir, "agent");
|
||||
await writeCodexAppServerBinding(sessionFile, {
|
||||
threadId: "thread-bootstrapped",
|
||||
threadId: "thread-old",
|
||||
cwd: workspaceDir,
|
||||
dynamicToolsFingerprint: "[]",
|
||||
contextEngine: {
|
||||
@@ -653,31 +666,256 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
await fs.writeFile(
|
||||
path.join(path.dirname(sessionFile), "sessions.json"),
|
||||
JSON.stringify({
|
||||
"agent:main:session-1": {
|
||||
sessionFile,
|
||||
totalTokens: 12_000,
|
||||
},
|
||||
const contextEngine = createContextEngine({
|
||||
assemble: vi.fn(async ({ prompt }) => {
|
||||
// Simulate a concurrent /codex resume or reset after the initial read.
|
||||
await clearCodexAppServerBinding(sessionFile);
|
||||
return {
|
||||
messages: [
|
||||
assistantMessage("assembled startup context", 10),
|
||||
userMessage(prompt ?? "", 11),
|
||||
],
|
||||
estimatedTokens: 42,
|
||||
systemPromptAddition: "context-engine system",
|
||||
contextProjection: { mode: "thread_bootstrap" as const, epoch: "epoch-1" },
|
||||
};
|
||||
}),
|
||||
});
|
||||
const harness = createStartedThreadHarness(async (method) =>
|
||||
method === "thread/start" ? threadStartResult("thread-fresh") : undefined,
|
||||
);
|
||||
const rolloutDir = path.join(agentDir, "codex-home", "sessions");
|
||||
await fs.mkdir(rolloutDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(rolloutDir, "rollout-thread-bootstrapped.jsonl"),
|
||||
`${JSON.stringify({
|
||||
payload: {
|
||||
type: "token_count",
|
||||
info: {
|
||||
last_token_usage: {
|
||||
total_tokens: 241_198,
|
||||
},
|
||||
model_context_window: 258_400,
|
||||
},
|
||||
const params = createParams(sessionFile, workspaceDir);
|
||||
params.contextEngine = contextEngine;
|
||||
|
||||
const run = runCodexAppServerAttempt(params);
|
||||
await harness.waitForMethod("turn/start");
|
||||
|
||||
expect(harness.requests.map((request) => request.method)).toEqual([
|
||||
"thread/start",
|
||||
"turn/start",
|
||||
]);
|
||||
expectRequestInputTextContains(harness, "assembled startup context");
|
||||
await harness.completeTurn("completed", "thread-fresh");
|
||||
await run;
|
||||
await expect(readCodexAppServerBinding(sessionFile)).resolves.toMatchObject({
|
||||
threadId: "thread-fresh",
|
||||
contextEngine: {
|
||||
projection: { mode: "thread_bootstrap", epoch: "epoch-1" },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("awaits accepted-turn interruption and retires the client when it cannot be confirmed", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const contextEngine = createContextEngine({
|
||||
assemble: vi.fn(async ({ prompt }) => ({
|
||||
messages: [assistantMessage("projected context", 10), userMessage(prompt ?? "", 11)],
|
||||
estimatedTokens: 42,
|
||||
contextProjection: { mode: "thread_bootstrap" as const, epoch: "epoch-1" },
|
||||
})),
|
||||
});
|
||||
let rejectTurnInterrupt!: (reason?: unknown) => void;
|
||||
const turnInterrupt = new Promise<unknown>((_resolve, reject) => {
|
||||
rejectTurnInterrupt = reject;
|
||||
});
|
||||
const harness = createStartedThreadHarness(async (method) => {
|
||||
if (method === "thread/start") {
|
||||
return threadStartResult("thread-fresh");
|
||||
}
|
||||
if (method === "turn/start") {
|
||||
return turnStartResult("turn-fresh");
|
||||
}
|
||||
if (method === "turn/interrupt") {
|
||||
return await turnInterrupt;
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
let releaseProjectionCommit!: () => void;
|
||||
let markProjectionCommitStarted!: () => void;
|
||||
const projectionCommitStarted = new Promise<void>((resolve) => {
|
||||
markProjectionCommitStarted = resolve;
|
||||
});
|
||||
const projectionCommitReleased = new Promise<void>((resolve) => {
|
||||
releaseProjectionCommit = resolve;
|
||||
});
|
||||
const bindingStore: RunCodexAppServerAttemptImplOptions["bindingStore"] = {
|
||||
...testCodexAppServerBindingStore,
|
||||
async mutate(identity, mutation) {
|
||||
if (
|
||||
mutation.kind === "patch" &&
|
||||
mutation.patch.contextEngine?.projection?.epoch === "epoch-1"
|
||||
) {
|
||||
markProjectionCommitStarted();
|
||||
await projectionCommitReleased;
|
||||
return false;
|
||||
}
|
||||
return await testCodexAppServerBindingStore.mutate(identity, mutation);
|
||||
},
|
||||
};
|
||||
const params = createParams(sessionFile, workspaceDir);
|
||||
params.contextEngine = contextEngine;
|
||||
const onAgentEvent = vi.fn();
|
||||
params.onAgentEvent = onAgentEvent;
|
||||
const releaseClient = vi.fn();
|
||||
const abandonClient = vi.fn(async () => undefined);
|
||||
|
||||
const run = runCodexAppServerAttempt(params, {
|
||||
bindingStore,
|
||||
clientLeaseFactory: async () => ({
|
||||
client: harness.client,
|
||||
release: releaseClient,
|
||||
abandon: abandonClient,
|
||||
}),
|
||||
});
|
||||
await projectionCommitStarted;
|
||||
const bufferedToolRequest = harness.handleServerRequest({
|
||||
id: "tool-after-accepted-turn",
|
||||
method: "item/tool/call",
|
||||
params: {
|
||||
threadId: "thread-fresh",
|
||||
turnId: "turn-fresh",
|
||||
callId: "call-1",
|
||||
namespace: null,
|
||||
tool: "python",
|
||||
arguments: { code: "print('must not run')" },
|
||||
},
|
||||
});
|
||||
releaseProjectionCommit();
|
||||
await harness.waitForMethod("turn/interrupt");
|
||||
const runSettled = vi.fn();
|
||||
void run.then(runSettled, runSettled);
|
||||
await new Promise((resolve) => {
|
||||
setImmediate(resolve);
|
||||
});
|
||||
expect(runSettled).not.toHaveBeenCalled();
|
||||
expect(abandonClient).not.toHaveBeenCalled();
|
||||
|
||||
rejectTurnInterrupt(new Error("interrupt response lost"));
|
||||
await expect(run).rejects.toThrow("binding changed before context projection commit");
|
||||
await expect(bufferedToolRequest).resolves.toBeUndefined();
|
||||
expect(abandonClient).toHaveBeenCalledOnce();
|
||||
expect(releaseClient).not.toHaveBeenCalled();
|
||||
expect(onAgentEvent).not.toHaveBeenCalledWith(expect.objectContaining({ stream: "tool" }));
|
||||
expect(
|
||||
harness.requests.some(
|
||||
(request) =>
|
||||
request.method === "turn/interrupt" &&
|
||||
(request.params as { threadId?: string; turnId?: string }).threadId === "thread-fresh" &&
|
||||
(request.params as { threadId?: string; turnId?: string }).turnId === "turn-fresh",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("invalidates the projection and preserves fresh usage after native compaction", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
await writeCodexAppServerBinding(sessionFile, {
|
||||
threadId: "thread-bootstrapped",
|
||||
cwd: workspaceDir,
|
||||
model: "gpt-5.4-codex",
|
||||
modelProvider: "openai",
|
||||
dynamicToolsFingerprint: "[]",
|
||||
nativeContextUsage: { currentTokens: 220_000 },
|
||||
modelContextWindow: 258_400,
|
||||
contextEngine: {
|
||||
schemaVersion: 1,
|
||||
engineId: "lossless-claw",
|
||||
policyFingerprint:
|
||||
'{"schemaVersion":1,"engineId":"lossless-claw","ownsCompaction":true,"projectionMaxChars":24000}',
|
||||
projection: {
|
||||
schemaVersion: 1,
|
||||
mode: "thread_bootstrap",
|
||||
epoch: "epoch-1",
|
||||
fingerprint: "fingerprint-1",
|
||||
},
|
||||
})}\n`,
|
||||
},
|
||||
});
|
||||
const contextEngine = createContextEngine({
|
||||
assemble: vi.fn(async ({ prompt }) => ({
|
||||
messages: [userMessage(prompt ?? "", 11)],
|
||||
estimatedTokens: 42,
|
||||
systemPromptAddition: "context-engine system",
|
||||
contextProjection: {
|
||||
mode: "thread_bootstrap" as const,
|
||||
epoch: "epoch-1",
|
||||
fingerprint: "fingerprint-1",
|
||||
},
|
||||
})),
|
||||
});
|
||||
const harness = createStartedThreadHarness(async (method) =>
|
||||
method === "thread/resume" ? threadStartResult("thread-bootstrapped") : undefined,
|
||||
);
|
||||
const params = createParams(sessionFile, workspaceDir);
|
||||
params.contextEngine = contextEngine;
|
||||
|
||||
const run = runCodexAppServerAttempt(params);
|
||||
await harness.waitForMethod("turn/start");
|
||||
await harness.notify({
|
||||
method: "item/started",
|
||||
params: {
|
||||
threadId: "thread-bootstrapped",
|
||||
turnId: "turn-1",
|
||||
item: { id: "compact-1", type: "contextCompaction" },
|
||||
},
|
||||
});
|
||||
const startedBinding = await readCodexAppServerBinding(sessionFile);
|
||||
expect(startedBinding).not.toHaveProperty("nativeContextUsage");
|
||||
expect(startedBinding?.contextEngine).not.toHaveProperty("projection");
|
||||
await harness.notify({
|
||||
method: "thread/tokenUsage/updated",
|
||||
params: {
|
||||
threadId: "thread-bootstrapped",
|
||||
turnId: "turn-1",
|
||||
tokenUsage: {
|
||||
total: { totalTokens: 900_000 },
|
||||
last: { totalTokens: 12_000 },
|
||||
modelContextWindow: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
await harness.notify({
|
||||
method: "item/completed",
|
||||
params: {
|
||||
threadId: "thread-bootstrapped",
|
||||
turnId: "turn-1",
|
||||
item: { id: "compact-1", type: "contextCompaction" },
|
||||
},
|
||||
});
|
||||
const compactedBinding = await readCodexAppServerBinding(sessionFile);
|
||||
expect(compactedBinding).not.toHaveProperty("nativeContextUsage");
|
||||
await harness.completeTurn("completed", "thread-bootstrapped");
|
||||
await run;
|
||||
|
||||
const binding = await readCodexAppServerBinding(sessionFile);
|
||||
expect(binding?.contextEngine?.projection).toBeUndefined();
|
||||
expect(binding?.nativeContextUsage).toEqual({
|
||||
currentTokens: 12_000,
|
||||
});
|
||||
expect(binding?.modelContextWindow).toBe(258_400);
|
||||
});
|
||||
|
||||
it("starts a fresh thread instead of resuming a token-pressured thread-bootstrap binding", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
await writeCodexAppServerBinding(sessionFile, {
|
||||
threadId: "thread-bootstrapped",
|
||||
cwd: workspaceDir,
|
||||
dynamicToolsFingerprint: "[]",
|
||||
nativeContextUsage: { currentTokens: 241_198 },
|
||||
modelContextWindow: 258_400,
|
||||
contextEngine: {
|
||||
schemaVersion: 1,
|
||||
engineId: "lossless-claw",
|
||||
policyFingerprint:
|
||||
'{"schemaVersion":1,"engineId":"lossless-claw","ownsCompaction":true,"projectionMaxChars":24000}',
|
||||
projection: {
|
||||
schemaVersion: 1,
|
||||
mode: "thread_bootstrap",
|
||||
epoch: "epoch-1",
|
||||
},
|
||||
},
|
||||
});
|
||||
const contextEngine = createContextEngine({
|
||||
assemble: vi.fn(async ({ prompt }) => ({
|
||||
messages: [assistantMessage("reprojected context", 10), userMessage(prompt ?? "", 11)],
|
||||
@@ -696,13 +934,14 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
|
||||
return undefined;
|
||||
});
|
||||
const params = createParams(sessionFile, workspaceDir);
|
||||
params.agentDir = agentDir;
|
||||
params.contextEngine = contextEngine;
|
||||
|
||||
const run = runCodexAppServerAttempt(params);
|
||||
await harness.waitForMethod("turn/start");
|
||||
|
||||
expect(harness.requests.map((request) => request.method)).toEqual([
|
||||
"thread/resume",
|
||||
"thread/unsubscribe",
|
||||
"thread/start",
|
||||
"turn/start",
|
||||
]);
|
||||
@@ -717,7 +956,6 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
|
||||
it("does not inject mirrored history when a stale thread-bootstrap binding has no active context engine", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const agentDir = path.join(tempDir, "agent");
|
||||
const sessionManager = SessionManager.open(sessionFile);
|
||||
sessionManager.appendMessage(
|
||||
userMessage("previous stale-bootstrap request", Date.now()) as never,
|
||||
@@ -729,6 +967,7 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
|
||||
threadId: "thread-stale-bootstrap",
|
||||
cwd: workspaceDir,
|
||||
dynamicToolsFingerprint: "[]",
|
||||
nativeContextUsage: { currentTokens: 300_000 },
|
||||
contextEngine: {
|
||||
schemaVersion: 1,
|
||||
engineId: "lossless-claw",
|
||||
@@ -741,30 +980,6 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
await fs.writeFile(
|
||||
path.join(path.dirname(sessionFile), "sessions.json"),
|
||||
JSON.stringify({
|
||||
"agent:main:session-1": {
|
||||
sessionFile,
|
||||
totalTokens: 12_000,
|
||||
},
|
||||
}),
|
||||
);
|
||||
const rolloutDir = path.join(agentDir, "codex-home", "sessions");
|
||||
await fs.mkdir(rolloutDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(rolloutDir, "rollout-thread-stale-bootstrap.jsonl"),
|
||||
`${JSON.stringify({
|
||||
payload: {
|
||||
type: "token_count",
|
||||
info: {
|
||||
last_token_usage: {
|
||||
total_tokens: 300_000,
|
||||
},
|
||||
},
|
||||
},
|
||||
})}\n`,
|
||||
);
|
||||
const harness = createStartedThreadHarness(async (method) => {
|
||||
if (method === "thread/resume") {
|
||||
return threadStartResult("thread-stale-bootstrap");
|
||||
@@ -775,17 +990,6 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
|
||||
return undefined;
|
||||
});
|
||||
const params = createParams(sessionFile, workspaceDir);
|
||||
params.agentDir = agentDir;
|
||||
params.config = {
|
||||
agents: {
|
||||
defaults: {
|
||||
compaction: {
|
||||
truncateAfterCompaction: true,
|
||||
maxActiveTranscriptBytes: "1mb",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as EmbeddedRunAttemptParams["config"];
|
||||
|
||||
const run = runCodexAppServerAttempt(params);
|
||||
await harness.waitForMethod("turn/start");
|
||||
@@ -933,7 +1137,7 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
|
||||
sessionId: "session-1",
|
||||
threadId: "thread-new",
|
||||
engineId: "lossless-claw",
|
||||
epoch: "epoch-new",
|
||||
projectionPending: true,
|
||||
action: "rotated",
|
||||
}),
|
||||
);
|
||||
@@ -945,6 +1149,8 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
|
||||
await writeCodexAppServerBinding(sessionFile, {
|
||||
threadId: "thread-old",
|
||||
cwd: workspaceDir,
|
||||
model: "gpt-5.4-codex",
|
||||
modelProvider: "openai",
|
||||
dynamicToolsFingerprint: "[]",
|
||||
contextEngine: {
|
||||
schemaVersion: 1,
|
||||
@@ -1111,6 +1317,12 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
|
||||
},
|
||||
});
|
||||
await run;
|
||||
await expect(readCodexAppServerBinding(sessionFile)).resolves.toMatchObject({
|
||||
threadId: "thread-old",
|
||||
contextEngine: {
|
||||
projection: { mode: "thread_bootstrap", epoch: "epoch-1" },
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
restoreSandboxBackend();
|
||||
}
|
||||
@@ -1192,7 +1404,11 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
|
||||
await writeCodexAppServerBinding(sessionFile, {
|
||||
threadId: "thread-old",
|
||||
cwd: workspaceDir,
|
||||
model: "gpt-5.4-codex",
|
||||
modelProvider: "openai",
|
||||
dynamicToolsFingerprint: "[]",
|
||||
nativeContextUsage: { currentTokens: 120_000 },
|
||||
modelContextWindow: 258_400,
|
||||
contextEngine: {
|
||||
schemaVersion: 1,
|
||||
engineId: "lossless-claw",
|
||||
@@ -1239,7 +1455,13 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
|
||||
throw new Error("Codex ran out of room in the model's context window");
|
||||
}
|
||||
if (method === "thread/start") {
|
||||
return threadStartResult("thread-fresh");
|
||||
const response = threadStartResult("thread-fresh");
|
||||
return {
|
||||
...response,
|
||||
thread: { ...response.thread, modelProvider: "lmstudio" },
|
||||
model: "local-model",
|
||||
modelProvider: "lmstudio",
|
||||
};
|
||||
}
|
||||
if (method === "turn/start" && request.threadId === "thread-fresh") {
|
||||
return turnStartResult("turn-fresh");
|
||||
@@ -1250,15 +1472,30 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
|
||||
params.contextEngine = contextEngine;
|
||||
params.contextTokenBudget = 400_000;
|
||||
|
||||
const run = runCodexAppServerAttempt(params);
|
||||
const run = runCodexAppServerAttempt(params, {
|
||||
pluginConfig: { appServer: { mode: "guardian" } },
|
||||
});
|
||||
await vi.waitFor(() =>
|
||||
expect(harness.requests.map((request) => request.method)).toEqual([
|
||||
"thread/resume",
|
||||
"turn/start",
|
||||
"thread/unsubscribe",
|
||||
"thread/start",
|
||||
"turn/start",
|
||||
]),
|
||||
);
|
||||
await harness.notify({
|
||||
method: "thread/tokenUsage/updated",
|
||||
params: {
|
||||
threadId: "thread-fresh",
|
||||
turnId: "turn-fresh",
|
||||
tokenUsage: {
|
||||
total: { totalTokens: 900_000 },
|
||||
last: { totalTokens: 12_000 },
|
||||
modelContextWindow: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
await harness.notify({
|
||||
method: "turn/completed",
|
||||
params: {
|
||||
@@ -1274,15 +1511,36 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
|
||||
const result = await run;
|
||||
|
||||
expect(result.assistantTexts).toContain("fresh answer");
|
||||
const turnStarts = harness.requests.filter((request) => request.method === "turn/start");
|
||||
expect(turnStarts[0]?.params).toMatchObject({ approvalsReviewer: "auto_review" });
|
||||
expect(turnStarts[1]?.params).toMatchObject({
|
||||
model: "local-model",
|
||||
approvalsReviewer: "user",
|
||||
approvalPolicy: "on-request",
|
||||
});
|
||||
expect(compact).not.toHaveBeenCalled();
|
||||
expect(assemble).toHaveBeenCalledTimes(1);
|
||||
const retryInputText = getRequestInputTextAt(harness, -1);
|
||||
expect(retryInputText).toBe("hello");
|
||||
expect(retryInputText).toContain("context epoch-before");
|
||||
expect(retryInputText).toContain("hello");
|
||||
expect(retryInputText).not.toContain("successor compacted context");
|
||||
const savedBinding = await readCodexAppServerBinding(sessionFile);
|
||||
expect(savedBinding?.threadId).toBe("thread-fresh");
|
||||
expect(savedBinding?.contextEngine?.engineId).toBe("lossless-claw");
|
||||
expect(savedBinding?.contextEngine?.projection).toBeUndefined();
|
||||
expect(savedBinding?.contextEngine?.projection).toEqual({
|
||||
schemaVersion: 1,
|
||||
mode: "thread_bootstrap",
|
||||
epoch: "epoch-before",
|
||||
});
|
||||
expect(savedBinding?.nativeContextUsage).toEqual({
|
||||
currentTokens: 12_000,
|
||||
});
|
||||
expect(savedBinding?.modelContextWindow).toBeUndefined();
|
||||
expect(
|
||||
harness.requests
|
||||
.filter((request) => request.method === "thread/unsubscribe")
|
||||
.map((request) => request.params),
|
||||
).toEqual([{ threadId: "thread-old" }, { threadId: "thread-fresh" }]);
|
||||
});
|
||||
|
||||
it("preserves a newer context-engine binding when a stale resumed thread overflows", async () => {
|
||||
@@ -1573,6 +1831,7 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
|
||||
expect(harness.requests.map((request) => request.method)).toEqual([
|
||||
"thread/resume",
|
||||
"turn/start",
|
||||
"thread/unsubscribe",
|
||||
"thread/start",
|
||||
"turn/start",
|
||||
]),
|
||||
|
||||
@@ -120,6 +120,8 @@ describe("runCodexAppServerAttempt hooks and model diagnostics", () => {
|
||||
phase?: string;
|
||||
startedAt?: number;
|
||||
text?: string;
|
||||
threadId?: string;
|
||||
turnId?: string;
|
||||
};
|
||||
stream: string;
|
||||
}>;
|
||||
@@ -127,6 +129,11 @@ describe("runCodexAppServerAttempt hooks and model diagnostics", () => {
|
||||
(event) => event.stream === "lifecycle" && event.data.phase === "start",
|
||||
);
|
||||
expect(typeof lifecycleStart?.data.startedAt).toBe("number");
|
||||
const turnAccepted = agentEvents.find(
|
||||
(event) =>
|
||||
event.stream === "codex_app_server.lifecycle" && event.data.phase === "turn_accepted",
|
||||
);
|
||||
expect(turnAccepted?.data).toMatchObject({ threadId: "thread-1", turnId: "turn-1" });
|
||||
const assistantEvent = agentEvents.find((event) => event.stream === "assistant");
|
||||
expect(assistantEvent?.data).toEqual({ text: "hello back" });
|
||||
const lifecycleEnd = agentEvents.find(
|
||||
@@ -138,10 +145,16 @@ describe("runCodexAppServerAttempt hooks and model diagnostics", () => {
|
||||
(event) => event.stream === "lifecycle" && event.data.phase === "start",
|
||||
);
|
||||
const assistantIndex = agentEvents.findIndex((event) => event.stream === "assistant");
|
||||
const acceptedIndex = agentEvents.findIndex(
|
||||
(event) =>
|
||||
event.stream === "codex_app_server.lifecycle" && event.data.phase === "turn_accepted",
|
||||
);
|
||||
const endIndex = agentEvents.findIndex(
|
||||
(event) => event.stream === "lifecycle" && event.data.phase === "end",
|
||||
);
|
||||
expect(startIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(acceptedIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(startIndex).toBeGreaterThan(acceptedIndex);
|
||||
expect(assistantIndex).toBeGreaterThan(startIndex);
|
||||
expect(endIndex).toBeGreaterThan(assistantIndex);
|
||||
const globalAssistantEvent = globalAgentEvents.find((event) => event.stream === "assistant");
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
} from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import * as approvalBridge from "./approval-bridge.js";
|
||||
import { CodexAppServerRpcError } from "./client.js";
|
||||
import {
|
||||
createParams,
|
||||
createResumeHarness,
|
||||
@@ -16,9 +17,21 @@ import {
|
||||
runCodexAppServerAttempt,
|
||||
setupRunAttemptTestHooks,
|
||||
tempDir,
|
||||
threadStartResult,
|
||||
} from "./run-attempt-test-harness.js";
|
||||
import { testing } from "./run-attempt.js";
|
||||
import { readCodexAppServerBinding, writeCodexAppServerBinding } from "./session-binding.js";
|
||||
import {
|
||||
readCodexAppServerBinding,
|
||||
registerCodexTestSessionIdentity,
|
||||
writeCodexAppServerBinding as writeStoredCodexAppServerBinding,
|
||||
} from "./session-binding.test-helpers.js";
|
||||
|
||||
async function writeCodexAppServerBinding(
|
||||
...args: Parameters<typeof writeStoredCodexAppServerBinding>
|
||||
): Promise<void> {
|
||||
registerCodexTestSessionIdentity(args[0], "session-1", "agent:main:session-1");
|
||||
await writeStoredCodexAppServerBinding(...args);
|
||||
}
|
||||
|
||||
setupRunAttemptTestHooks();
|
||||
|
||||
@@ -252,7 +265,7 @@ describe("runCodexAppServerAttempt native hook relay", () => {
|
||||
expect(nativeHookRelayTesting.getNativeHookRelayRegistrationForTests(relayId)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("lets Codex app-server approval modes own native permission requests by default", async () => {
|
||||
it("installs policy-stable native hook relay events before thread policy is known", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const harness = createStartedThreadHarness();
|
||||
@@ -273,11 +286,16 @@ describe("runCodexAppServerAttempt native hook relay", () => {
|
||||
expect(Array.isArray(startConfig?.["hooks.PreToolUse"])).toBe(true);
|
||||
expect(startConfig?.["hooks.PostToolUse"]).toEqual([]);
|
||||
expect(startConfig?.["hooks.Stop"]).toEqual([]);
|
||||
expect(startConfig).not.toHaveProperty("hooks.PermissionRequest");
|
||||
const permissionRequestHooks = startConfig?.["hooks.PermissionRequest"] as
|
||||
| Array<{ hooks?: Array<{ command?: string }> }>
|
||||
| undefined;
|
||||
expect(permissionRequestHooks?.[0]?.hooks?.[0]?.command).toContain(
|
||||
"--event permission_request",
|
||||
);
|
||||
const relayId = extractRelayIdFromThreadRequest(startRequest?.params);
|
||||
expect(
|
||||
nativeHookRelayTesting.getNativeHookRelayRegistrationForTests(relayId)?.allowedEvents,
|
||||
).toEqual(["pre_tool_use", "post_tool_use", "before_agent_finalize"]);
|
||||
).toEqual(["pre_tool_use", "post_tool_use", "permission_request", "before_agent_finalize"]);
|
||||
|
||||
await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
|
||||
await run;
|
||||
@@ -285,6 +303,68 @@ describe("runCodexAppServerAttempt native hook relay", () => {
|
||||
expect(nativeHookRelayTesting.getNativeHookRelayRegistrationForTests(relayId)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("defers permission hooks after Codex returns a provider with guarded policy", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const harness = createStartedThreadHarness(async (method) => {
|
||||
if (method !== "thread/start") {
|
||||
return undefined;
|
||||
}
|
||||
const response = threadStartResult();
|
||||
return {
|
||||
...response,
|
||||
thread: { ...response.thread, modelProvider: "lmstudio" },
|
||||
model: "local-model",
|
||||
modelProvider: "lmstudio",
|
||||
};
|
||||
});
|
||||
const approvalRequester = vi.fn(async () => "allow" as const);
|
||||
nativeHookRelayTesting.setNativeHookRelayPermissionApprovalRequesterForTests(approvalRequester);
|
||||
const params = createParams(sessionFile, workspaceDir);
|
||||
params.modelId = "openai/gpt-5.4-codex";
|
||||
|
||||
const run = runCodexAppServerAttempt(params, {
|
||||
pluginConfig: {
|
||||
appServer: {
|
||||
mode: "yolo",
|
||||
approvalsReviewer: "auto_review",
|
||||
},
|
||||
},
|
||||
nativeHookRelay: { enabled: true },
|
||||
});
|
||||
await harness.waitForMethod("turn/start");
|
||||
|
||||
const startRequest = harness.requests.find((request) => request.method === "thread/start");
|
||||
const startParams = startRequest?.params as
|
||||
| { approvalPolicy?: unknown; config?: Record<string, unknown> }
|
||||
| undefined;
|
||||
expect(startParams?.approvalPolicy).toBe("never");
|
||||
expect(Array.isArray(startParams?.config?.["hooks.PermissionRequest"])).toBe(true);
|
||||
const turnStartRequest = harness.requests.find((request) => request.method === "turn/start");
|
||||
expect(
|
||||
(turnStartRequest?.params as { approvalPolicy?: unknown } | undefined)?.approvalPolicy,
|
||||
).toBe("on-request");
|
||||
const relayId = extractRelayIdFromThreadRequest(startRequest?.params);
|
||||
await expect(
|
||||
invokeNativeHookRelay({
|
||||
provider: "codex",
|
||||
relayId,
|
||||
event: "permission_request",
|
||||
rawPayload: {
|
||||
hook_event_name: "PermissionRequest",
|
||||
permission_mode: "default",
|
||||
tool_name: "Bash",
|
||||
tool_input: { command: "git push" },
|
||||
},
|
||||
}),
|
||||
).resolves.toEqual({ stdout: "", stderr: "", exitCode: 0 });
|
||||
expect(approvalRequester).not.toHaveBeenCalled();
|
||||
|
||||
await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
|
||||
await run;
|
||||
testing.flushPendingCodexNativeHookRelayUnregistersForTests();
|
||||
});
|
||||
|
||||
it("preserves explicit native permission request relay events in app-server approval modes", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
@@ -432,7 +512,7 @@ describe("runCodexAppServerAttempt native hook relay", () => {
|
||||
"run-2",
|
||||
);
|
||||
|
||||
await secondHarness.completeTurn({ threadId: "thread-existing", turnId: "turn-1" });
|
||||
await secondHarness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
|
||||
await secondRun;
|
||||
expect(nativeHookRelayTesting.getNativeHookRelayRegistrationForTests(firstRelayId)?.runId).toBe(
|
||||
"run-2",
|
||||
@@ -484,7 +564,7 @@ describe("runCodexAppServerAttempt native hook relay", () => {
|
||||
expect(extractRelayIdFromThreadRequest(resumeRequest?.params)).toBe(firstRelayId);
|
||||
expect(extractGenerationFromThreadRequest(resumeRequest?.params)).toBe(firstGeneration);
|
||||
|
||||
await secondHarness.completeTurn({ threadId: "thread-existing", turnId: "turn-1" });
|
||||
await secondHarness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
|
||||
await secondRun;
|
||||
testing.flushPendingCodexNativeHookRelayUnregistersForTests();
|
||||
});
|
||||
@@ -613,7 +693,7 @@ describe("runCodexAppServerAttempt native hook relay", () => {
|
||||
});
|
||||
const harness = createStartedThreadHarness(async (method) => {
|
||||
if (method === "thread/resume") {
|
||||
throw new Error("resume failed");
|
||||
throw new CodexAppServerRpcError({ code: -32000, message: "resume failed" }, method);
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -36,10 +36,10 @@ import {
|
||||
} from "./run-attempt-test-harness.js";
|
||||
import { testing } from "./run-attempt.js";
|
||||
import {
|
||||
createCodexTestBindingStore,
|
||||
readCodexAppServerBinding,
|
||||
resolveCodexAppServerBindingPath,
|
||||
writeCodexAppServerBinding,
|
||||
} from "./session-binding.js";
|
||||
} from "./session-binding.test-helpers.js";
|
||||
|
||||
setupRunAttemptTestHooks();
|
||||
|
||||
@@ -51,7 +51,7 @@ describe("createCodexAttemptTurnWatchController", () => {
|
||||
const onTimeout = vi.fn();
|
||||
const onAbort = vi.fn();
|
||||
const controller = createCodexAttemptTurnWatchController({
|
||||
threadId: "thread-1",
|
||||
getThreadId: () => "thread-1",
|
||||
signal: new AbortController().signal,
|
||||
getTurnId: () => "turn-1",
|
||||
isCompleted: () => false,
|
||||
@@ -80,7 +80,7 @@ describe("createCodexAttemptTurnWatchController", () => {
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 20);
|
||||
});
|
||||
controller.noteNotificationReceived("response.output_text.delta", {
|
||||
controller.noteNotificationReceived("item/fileChange/patchUpdated", {
|
||||
attemptProgress: true,
|
||||
attemptTimeoutMs: 40,
|
||||
});
|
||||
@@ -93,7 +93,7 @@ describe("createCodexAttemptTurnWatchController", () => {
|
||||
expect.objectContaining({
|
||||
kind: "progress",
|
||||
timeoutMs: 40,
|
||||
lastActivityReason: "notification:response.output_text.delta",
|
||||
lastActivityReason: "notification:item/fileChange/patchUpdated",
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
@@ -138,8 +138,6 @@ describe("runCodexAppServerAttempt turn watches", () => {
|
||||
path.join(tempDir, "workspace"),
|
||||
);
|
||||
params.timeoutMs = 200;
|
||||
const bindingPath = resolveCodexAppServerBindingPath(params.sessionFile);
|
||||
|
||||
const run = runCodexAppServerAttempt(params, {
|
||||
pluginConfig: { appServer: { turnCompletionIdleTimeoutMs: 5 } },
|
||||
postToolRawAssistantCompletionIdleTimeoutMs: 5,
|
||||
@@ -185,7 +183,7 @@ describe("runCodexAppServerAttempt turn watches", () => {
|
||||
),
|
||||
{ interval: 1 },
|
||||
);
|
||||
await expect(fs.stat(bindingPath)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
await expect(readCodexAppServerBinding(params.sessionFile)).resolves.toBeUndefined();
|
||||
expect(queueActiveRunMessageForTest("session-1", "after timeout")).toBe(false);
|
||||
});
|
||||
|
||||
@@ -403,8 +401,7 @@ describe("runCodexAppServerAttempt turn watches", () => {
|
||||
expect(result.promptTimeoutOutcome).toBeUndefined();
|
||||
});
|
||||
|
||||
it("unsubscribes and closes the app-server client when the active turn goes idle past the attempt timeout", async () => {
|
||||
const close = vi.fn();
|
||||
it("unsubscribes the app-server client when the active turn goes idle past the attempt timeout", async () => {
|
||||
const request = vi.fn(async (method: string) => {
|
||||
if (method === "thread/start") {
|
||||
return threadStartResult("thread-1");
|
||||
@@ -421,7 +418,6 @@ describe("runCodexAppServerAttempt turn watches", () => {
|
||||
async () =>
|
||||
({
|
||||
request,
|
||||
close,
|
||||
addNotificationHandler: () => () => undefined,
|
||||
addRequestHandler: () => () => undefined,
|
||||
}) as never,
|
||||
@@ -454,7 +450,6 @@ describe("runCodexAppServerAttempt turn watches", () => {
|
||||
},
|
||||
{ timeoutMs: 5_000 },
|
||||
);
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
expect(queueActiveRunMessageForTest("session-1", "after timeout")).toBe(false);
|
||||
});
|
||||
|
||||
@@ -1855,118 +1850,6 @@ describe("runCodexAppServerAttempt turn watches", () => {
|
||||
expect(completionWarnData?.timeoutMs).toBe(100);
|
||||
});
|
||||
|
||||
it("counts native response deltas as post-tool raw assistant activity", async () => {
|
||||
let notify: (notification: CodexServerNotification) => Promise<void> = async () => undefined;
|
||||
let handleRequest:
|
||||
| ((request: { id: string; method: string; params?: unknown }) => Promise<unknown>)
|
||||
| undefined;
|
||||
const request = vi.fn(async (method: string) => {
|
||||
if (method === "thread/start") {
|
||||
return threadStartResult("thread-1");
|
||||
}
|
||||
if (method === "turn/start") {
|
||||
return turnStartResult("turn-1", "inProgress");
|
||||
}
|
||||
return {};
|
||||
});
|
||||
setCodexAppServerClientFactoryForTest(
|
||||
async () =>
|
||||
({
|
||||
request,
|
||||
addNotificationHandler: (handler: typeof notify) => {
|
||||
notify = handler;
|
||||
return () => undefined;
|
||||
},
|
||||
addRequestHandler: (
|
||||
handler: (request: {
|
||||
id: string;
|
||||
method: string;
|
||||
params?: unknown;
|
||||
}) => Promise<unknown>,
|
||||
) => {
|
||||
handleRequest = handler;
|
||||
return () => undefined;
|
||||
},
|
||||
}) as never,
|
||||
);
|
||||
const params = createParams(
|
||||
path.join(tempDir, "session.jsonl"),
|
||||
path.join(tempDir, "workspace"),
|
||||
);
|
||||
params.timeoutMs = 60_000;
|
||||
|
||||
let settled = false;
|
||||
const run = runCodexAppServerAttempt(params, {
|
||||
turnCompletionIdleTimeoutMs: 500,
|
||||
turnAssistantCompletionIdleTimeoutMs: 5,
|
||||
postToolRawAssistantCompletionIdleTimeoutMs: 50,
|
||||
turnTerminalIdleTimeoutMs: 500,
|
||||
}).finally(() => {
|
||||
settled = true;
|
||||
});
|
||||
await vi.waitFor(() => expect(handleRequest).toBeTypeOf("function"), fastWait);
|
||||
|
||||
const toolResult = (await handleRequest?.({
|
||||
id: "request-tool-1",
|
||||
method: "item/tool/call",
|
||||
params: {
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
callId: "call-1",
|
||||
namespace: null,
|
||||
tool: "message",
|
||||
arguments: { action: "send", text: "already sent" },
|
||||
},
|
||||
})) as { success?: boolean };
|
||||
expect(toolResult.success).toBe(false);
|
||||
await notify({
|
||||
method: "rawResponseItem/completed",
|
||||
params: {
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
item: {
|
||||
type: "message",
|
||||
id: "raw-status-1",
|
||||
role: "assistant",
|
||||
content: [{ type: "output_text", text: "I'm writing a large patch now." }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 30);
|
||||
});
|
||||
// This covers the future-compatible path for raw response deltas if Codex
|
||||
// app-server exposes them directly; current Codex primarily emits
|
||||
// rawResponseItem/completed for the raw-event surface.
|
||||
await notify({
|
||||
method: "response.custom_tool_call_input.delta",
|
||||
params: {
|
||||
item_id: "ctc-large-edit-1",
|
||||
output_index: 0,
|
||||
delta: '{"cmd":"apply_patch","patch":"large chunk"}',
|
||||
},
|
||||
});
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 30);
|
||||
});
|
||||
expect(settled).toBe(false);
|
||||
|
||||
await notify({
|
||||
method: "turn/completed",
|
||||
params: {
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
turn: { id: "turn-1", status: "completed" },
|
||||
},
|
||||
});
|
||||
|
||||
const result = await run;
|
||||
expect(result.aborted).toBe(false);
|
||||
expect(result.timedOut).toBe(false);
|
||||
expect(result.promptError).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the post-tool guard armed for patch update snapshots", async () => {
|
||||
let notify: (notification: CodexServerNotification) => Promise<void> = async () => undefined;
|
||||
let handleRequest:
|
||||
@@ -2077,212 +1960,6 @@ describe("runCodexAppServerAttempt turn watches", () => {
|
||||
expect(completionWarnData?.lastNotificationMethod).toBe("item/fileChange/patchUpdated");
|
||||
});
|
||||
|
||||
it("keeps the post-tool guard armed for scoped native response deltas", async () => {
|
||||
let notify: (notification: CodexServerNotification) => Promise<void> = async () => undefined;
|
||||
let handleRequest:
|
||||
| ((request: { id: string; method: string; params?: unknown }) => Promise<unknown>)
|
||||
| undefined;
|
||||
const request = vi.fn(async (method: string) => {
|
||||
if (method === "thread/start") {
|
||||
return threadStartResult("thread-1");
|
||||
}
|
||||
if (method === "turn/start") {
|
||||
return turnStartResult("turn-1", "inProgress");
|
||||
}
|
||||
return {};
|
||||
});
|
||||
setCodexAppServerClientFactoryForTest(
|
||||
async () =>
|
||||
({
|
||||
request,
|
||||
addNotificationHandler: (handler: typeof notify) => {
|
||||
notify = handler;
|
||||
return () => undefined;
|
||||
},
|
||||
addRequestHandler: (
|
||||
handler: (request: {
|
||||
id: string;
|
||||
method: string;
|
||||
params?: unknown;
|
||||
}) => Promise<unknown>,
|
||||
) => {
|
||||
handleRequest = handler;
|
||||
return () => undefined;
|
||||
},
|
||||
}) as never,
|
||||
);
|
||||
const params = createParams(
|
||||
path.join(tempDir, "session-scoped-delta-timeout.jsonl"),
|
||||
path.join(tempDir, "workspace-scoped-delta-timeout"),
|
||||
);
|
||||
params.timeoutMs = 2_000;
|
||||
|
||||
const run = runCodexAppServerAttempt(params, {
|
||||
turnCompletionIdleTimeoutMs: 500,
|
||||
turnAssistantCompletionIdleTimeoutMs: 5,
|
||||
postToolRawAssistantCompletionIdleTimeoutMs: 50,
|
||||
turnTerminalIdleTimeoutMs: 500,
|
||||
});
|
||||
await vi.waitFor(() => expect(handleRequest).toBeTypeOf("function"), fastWait);
|
||||
|
||||
await handleRequest?.({
|
||||
id: "request-tool-1",
|
||||
method: "item/tool/call",
|
||||
params: {
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
callId: "call-1",
|
||||
namespace: null,
|
||||
tool: "message",
|
||||
arguments: { action: "send", text: "already sent" },
|
||||
},
|
||||
});
|
||||
await notify({
|
||||
method: "rawResponseItem/completed",
|
||||
params: {
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
item: {
|
||||
type: "message",
|
||||
id: "raw-status-1",
|
||||
role: "assistant",
|
||||
content: [{ type: "output_text", text: "I'm writing a large patch now." }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 30);
|
||||
});
|
||||
await notify({
|
||||
method: "response.custom_tool_call_input.delta",
|
||||
params: {
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
item_id: "ctc-large-edit-1",
|
||||
output_index: 0,
|
||||
delta: '{"cmd":"apply_patch","patch":"large chunk"}',
|
||||
},
|
||||
});
|
||||
|
||||
const result = await run;
|
||||
expect(result.timedOut).toBe(true);
|
||||
expect(result.promptError).toBe(
|
||||
"codex app-server turn idle timed out waiting for turn/completed",
|
||||
);
|
||||
});
|
||||
|
||||
it("ignores unscoped native response deltas while another turn leases the client", async () => {
|
||||
let notify: (notification: CodexServerNotification) => Promise<void> = async () => undefined;
|
||||
let handleRequest:
|
||||
| ((request: { id: string; method: string; params?: unknown }) => Promise<unknown>)
|
||||
| undefined;
|
||||
const warn = vi.spyOn(embeddedAgentLog, "warn").mockImplementation(() => undefined);
|
||||
const request = vi.fn(async (method: string) => {
|
||||
if (method === "thread/start") {
|
||||
return threadStartResult("thread-1");
|
||||
}
|
||||
if (method === "turn/start") {
|
||||
return turnStartResult("turn-1", "inProgress");
|
||||
}
|
||||
return {};
|
||||
});
|
||||
setCodexAppServerClientFactoryForTest(
|
||||
async () =>
|
||||
({
|
||||
request,
|
||||
getActiveSharedLeaseCountForUnscopedNotifications: () => 2,
|
||||
addNotificationHandler: (handler: typeof notify) => {
|
||||
notify = handler;
|
||||
return () => undefined;
|
||||
},
|
||||
addRequestHandler: (
|
||||
handler: (request: {
|
||||
id: string;
|
||||
method: string;
|
||||
params?: unknown;
|
||||
}) => Promise<unknown>,
|
||||
) => {
|
||||
handleRequest = handler;
|
||||
return () => undefined;
|
||||
},
|
||||
}) as never,
|
||||
);
|
||||
const params = createParams(
|
||||
path.join(tempDir, "session.jsonl"),
|
||||
path.join(tempDir, "workspace"),
|
||||
);
|
||||
params.timeoutMs = 60_000;
|
||||
|
||||
let settled = false;
|
||||
const run = runCodexAppServerAttempt(params, {
|
||||
turnCompletionIdleTimeoutMs: 500,
|
||||
turnAssistantCompletionIdleTimeoutMs: 5,
|
||||
postToolRawAssistantCompletionIdleTimeoutMs: 80,
|
||||
turnTerminalIdleTimeoutMs: 500,
|
||||
}).finally(() => {
|
||||
settled = true;
|
||||
});
|
||||
await vi.waitFor(() => expect(handleRequest).toBeTypeOf("function"), fastWait);
|
||||
|
||||
await handleRequest?.({
|
||||
id: "request-tool-1",
|
||||
method: "item/tool/call",
|
||||
params: {
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
callId: "call-1",
|
||||
namespace: null,
|
||||
tool: "message",
|
||||
arguments: { action: "send", text: "already sent" },
|
||||
},
|
||||
});
|
||||
await notify({
|
||||
method: "rawResponseItem/completed",
|
||||
params: {
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
item: {
|
||||
type: "message",
|
||||
id: "raw-status-1",
|
||||
role: "assistant",
|
||||
content: [{ type: "output_text", text: "I'm writing a large patch now." }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 40);
|
||||
});
|
||||
await notify({
|
||||
method: "response.custom_tool_call_input.delta",
|
||||
params: {
|
||||
item_id: "foreign-large-edit-1",
|
||||
output_index: 0,
|
||||
delta: '{"cmd":"apply_patch","patch":"other turn"}',
|
||||
},
|
||||
});
|
||||
await vi.waitFor(() => expect(settled).toBe(true), fastWait);
|
||||
|
||||
const result = await run;
|
||||
expect(result.aborted).toBe(true);
|
||||
expect(result.timedOut).toBe(true);
|
||||
expect(result.promptError).toBe(
|
||||
"codex app-server turn idle timed out waiting for turn/completed",
|
||||
);
|
||||
const completionWarnCall = warn.mock.calls.find(
|
||||
([message]) => message === "codex app-server turn idle timed out waiting for completion",
|
||||
);
|
||||
const completionWarnData = completionWarnCall?.[1] as
|
||||
| {
|
||||
lastActivityReason?: string;
|
||||
lastNotificationMethod?: string;
|
||||
}
|
||||
| undefined;
|
||||
expect(completionWarnData?.lastActivityReason).toBe("notification:rawResponseItem/completed");
|
||||
expect(completionWarnData?.lastNotificationMethod).toBe("rawResponseItem/completed");
|
||||
});
|
||||
|
||||
it("times out post-native-tool raw assistant progress after the post-tool timeout", async () => {
|
||||
let notify: (notification: CodexServerNotification) => Promise<void> = async () => undefined;
|
||||
const request = vi.fn(async (method: string) => {
|
||||
@@ -2980,6 +2657,47 @@ describe("runCodexAppServerAttempt turn watches", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("retires a timed-out client even when binding cleanup fails", async () => {
|
||||
vi.spyOn(embeddedAgentLog, "warn").mockImplementation(() => undefined);
|
||||
const baseBindingStore = createCodexTestBindingStore();
|
||||
const bindingStore = {
|
||||
...baseBindingStore,
|
||||
mutate: vi.fn(async (...args: Parameters<typeof baseBindingStore.mutate>) => {
|
||||
if (args[1].kind === "clear") {
|
||||
throw new Error("binding store unavailable");
|
||||
}
|
||||
return await baseBindingStore.mutate(...args);
|
||||
}),
|
||||
};
|
||||
const harness = createStartedThreadHarness();
|
||||
const release = vi.fn();
|
||||
const abandon = vi.fn(async () => undefined);
|
||||
const params = createParams(
|
||||
path.join(tempDir, "session-retirement.jsonl"),
|
||||
path.join(tempDir, "workspace-retirement"),
|
||||
);
|
||||
params.timeoutMs = 200;
|
||||
|
||||
const result = await runCodexAppServerAttempt(params, {
|
||||
bindingStore,
|
||||
turnCompletionIdleTimeoutMs: 15,
|
||||
clientLeaseFactory: async () => ({ client: harness.client, release, abandon }),
|
||||
});
|
||||
|
||||
expect(result.timedOut).toBe(true);
|
||||
expect(bindingStore.mutate).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
expect.objectContaining({ kind: "clear", threadId: "thread-1" }),
|
||||
);
|
||||
expect(harness.request).toHaveBeenCalledWith(
|
||||
"thread/unsubscribe",
|
||||
{ threadId: "thread-1" },
|
||||
{ timeoutMs: 5_000 },
|
||||
);
|
||||
expect(abandon).toHaveBeenCalledOnce();
|
||||
expect(release).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("clears the thread binding after a completion-idle timeout so the next turn starts fresh", async () => {
|
||||
// Regression for openclaw#89974. The "user interrupted the previous turn on
|
||||
// purpose" wording is Codex's generic <turn_aborted> rollout marker, written
|
||||
@@ -2991,6 +2709,7 @@ describe("runCodexAppServerAttempt turn watches", () => {
|
||||
vi.spyOn(embeddedAgentLog, "warn").mockImplementation(() => undefined);
|
||||
const sessionFile = path.join(tempDir, "session-89974.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace-89974");
|
||||
const firstParams = createParams(sessionFile, workspaceDir);
|
||||
await writeCodexAppServerBinding(sessionFile, {
|
||||
threadId: "thread-existing",
|
||||
cwd: workspaceDir,
|
||||
@@ -3001,7 +2720,6 @@ describe("runCodexAppServerAttempt turn watches", () => {
|
||||
|
||||
// Turn 1: resume an existing thread, then never deliver turn/completed.
|
||||
const firstHarness = createResumeHarness();
|
||||
const firstParams = createParams(sessionFile, workspaceDir);
|
||||
firstParams.timeoutMs = 200;
|
||||
const firstRun = runCodexAppServerAttempt(firstParams, { turnCompletionIdleTimeoutMs: 15 });
|
||||
await firstHarness.waitForMethod("turn/start");
|
||||
@@ -3043,9 +2761,9 @@ describe("runCodexAppServerAttempt turn watches", () => {
|
||||
const processing = harness.notify(notification);
|
||||
await Promise.resolve();
|
||||
|
||||
expect(readRecentCodexRateLimits()).toBeUndefined();
|
||||
expect(readRecentCodexRateLimits(harness.client)).toBeUndefined();
|
||||
await processing;
|
||||
expect(readRecentCodexRateLimits()).toEqual(notification.params);
|
||||
expect(readRecentCodexRateLimits(harness.client)).toEqual(notification.params);
|
||||
|
||||
await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
|
||||
await expect(run).resolves.toMatchObject({ aborted: false, timedOut: false });
|
||||
@@ -4086,9 +3804,8 @@ describe("runCodexAppServerAttempt turn watches", () => {
|
||||
);
|
||||
|
||||
await harness.waitForMethod("turn/start");
|
||||
const completed = harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
|
||||
await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
|
||||
harness.close();
|
||||
await completed;
|
||||
|
||||
const result = await run;
|
||||
expect(result.promptError ?? undefined).toBeUndefined();
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
// Codex tests cover run attempt.usage limits plugin behavior.
|
||||
import path from "node:path";
|
||||
import { saveAuthProfileStore } from "openclaw/plugin-sdk/agent-runtime";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { rememberCodexRateLimits } from "./rate-limit-cache.js";
|
||||
import { readCodexRateLimitsRevision, rememberCodexRateLimitsRead } from "./rate-limit-cache.js";
|
||||
import {
|
||||
createParams,
|
||||
createStartedThreadHarness,
|
||||
@@ -25,7 +26,11 @@ describe("runCodexAppServerAttempt usage limits", () => {
|
||||
if (!harnessRef.current) {
|
||||
throw new Error("Expected Codex app-server harness to be initialized");
|
||||
}
|
||||
void harnessRef.current.notify(rateLimitsUpdated(resetsAt));
|
||||
const revisionBeforeUpdate = readCodexRateLimitsRevision(harnessRef.current.client);
|
||||
await harnessRef.current.notify(rateLimitsUpdated(resetsAt));
|
||||
expect(readCodexRateLimitsRevision(harnessRef.current.client)).toBe(
|
||||
revisionBeforeUpdate + 1,
|
||||
);
|
||||
throw Object.assign(new Error("You've reached your usage limit."), {
|
||||
data: { codexErrorInfo: "usageLimitExceeded" },
|
||||
});
|
||||
@@ -36,6 +41,7 @@ describe("runCodexAppServerAttempt usage limits", () => {
|
||||
|
||||
const params = createParams(sessionFile, workspaceDir);
|
||||
params.authProfileId = authProfileId;
|
||||
params.agentDir = path.join(tempDir, "agents", "main", "agent");
|
||||
params.authProfileStore = {
|
||||
version: 1,
|
||||
profiles: {
|
||||
@@ -48,11 +54,13 @@ describe("runCodexAppServerAttempt usage limits", () => {
|
||||
},
|
||||
},
|
||||
};
|
||||
saveAuthProfileStore(params.authProfileStore, params.agentDir);
|
||||
|
||||
const result = await runCodexAppServerAttempt(params);
|
||||
expect(result.promptErrorSource).toBe("prompt");
|
||||
expect(result.promptError).toContain("You've reached your Codex subscription usage limit.");
|
||||
expect(result.promptError).toContain("Next reset in");
|
||||
expect(params.authProfileStore.usageStats?.[authProfileId]?.blockedUntil).toBe(resetsAt * 1000);
|
||||
});
|
||||
|
||||
it("uses a recent Codex rate-limit snapshot when turn/start omits reset details", async () => {
|
||||
@@ -60,7 +68,15 @@ describe("runCodexAppServerAttempt usage limits", () => {
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const resetsAt = Math.ceil(Date.now() / 1000) + 120;
|
||||
const authProfileId = "openai:work";
|
||||
rememberCodexRateLimits({
|
||||
const harness = createStartedThreadHarness(async (method) => {
|
||||
if (method === "turn/start") {
|
||||
throw Object.assign(new Error("You've reached your usage limit."), {
|
||||
data: { codexErrorInfo: "usageLimitExceeded" },
|
||||
});
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
rememberCodexRateLimitsRead(harness.client, {
|
||||
rateLimits: {
|
||||
limitId: "codex",
|
||||
limitName: "Codex",
|
||||
@@ -72,14 +88,6 @@ describe("runCodexAppServerAttempt usage limits", () => {
|
||||
},
|
||||
rateLimitsByLimitId: null,
|
||||
});
|
||||
const harness = createStartedThreadHarness(async (method) => {
|
||||
if (method === "turn/start") {
|
||||
throw Object.assign(new Error("You've reached your usage limit."), {
|
||||
data: { codexErrorInfo: "usageLimitExceeded" },
|
||||
});
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const params = createParams(sessionFile, workspaceDir);
|
||||
params.authProfileId = authProfileId;
|
||||
@@ -106,6 +114,62 @@ describe("runCodexAppServerAttempt usage limits", () => {
|
||||
expect(params.authProfileStore.usageStats?.[authProfileId]?.blockedUntil).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not trust an unrelated in-turn rate-limit update for profile blocking", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const resetsAt = Math.ceil(Date.now() / 1000) + 120;
|
||||
const authProfileId = "openai:work";
|
||||
const harnessRef: { current?: ReturnType<typeof createStartedThreadHarness> } = {};
|
||||
const harness = createStartedThreadHarness(async (method) => {
|
||||
if (method === "turn/start") {
|
||||
if (!harnessRef.current) {
|
||||
throw new Error("Expected Codex app-server harness to be initialized");
|
||||
}
|
||||
await harnessRef.current.notify({
|
||||
method: "account/rateLimits/updated",
|
||||
params: {
|
||||
rateLimits: {
|
||||
limitId: "codex_other",
|
||||
primary: { usedPercent: 100, windowDurationMins: 60, resetsAt: resetsAt + 60 },
|
||||
rateLimitReachedType: "rate_limit_reached",
|
||||
},
|
||||
},
|
||||
});
|
||||
throw Object.assign(new Error("You've reached your usage limit."), {
|
||||
data: { codexErrorInfo: "usageLimitExceeded" },
|
||||
});
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
harnessRef.current = harness;
|
||||
rememberCodexRateLimitsRead(harness.client, {
|
||||
rateLimits: {
|
||||
limitId: "codex",
|
||||
primary: { usedPercent: 100, windowDurationMins: 300, resetsAt },
|
||||
rateLimitReachedType: "rate_limit_reached",
|
||||
},
|
||||
});
|
||||
const params = createParams(sessionFile, workspaceDir);
|
||||
params.authProfileId = authProfileId;
|
||||
params.authProfileStore = {
|
||||
version: 1,
|
||||
profiles: {
|
||||
[authProfileId]: {
|
||||
type: "oauth",
|
||||
provider: "openai",
|
||||
access: "access",
|
||||
refresh: "refresh",
|
||||
expires: Date.now() + 60_000,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = await runCodexAppServerAttempt(params);
|
||||
|
||||
expect(result.promptError).toContain("Next reset in");
|
||||
expect(params.authProfileStore.usageStats?.[authProfileId]?.blockedUntil).toBeUndefined();
|
||||
});
|
||||
|
||||
it("refreshes Codex account rate limits when turn/start omits reset details", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
|
||||
35
extensions/codex/src/app-server/runtime-thread-config.ts
Normal file
35
extensions/codex/src/app-server/runtime-thread-config.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
/** Small runtime-only Codex thread config boundary shared by isolated turns. */
|
||||
import type { JsonObject } from "./protocol.js";
|
||||
|
||||
// Stream structured patch snapshots so large generated edits keep the turn active.
|
||||
const CODEX_CODE_MODE_THREAD_CONFIG: JsonObject = {
|
||||
"features.code_mode": true,
|
||||
"features.code_mode_only": false,
|
||||
"features.apply_patch_streaming_events": true,
|
||||
};
|
||||
|
||||
const CODEX_CODE_MODE_DISABLED_THREAD_CONFIG: JsonObject = {
|
||||
"features.code_mode": false,
|
||||
"features.code_mode_only": false,
|
||||
};
|
||||
|
||||
/** Applies native code-mode policy without loading the full thread lifecycle. */
|
||||
export function buildCodexRuntimeThreadConfig(
|
||||
config: JsonObject | undefined,
|
||||
options: { nativeCodeModeEnabled?: boolean; nativeCodeModeOnlyEnabled?: boolean } = {},
|
||||
): JsonObject {
|
||||
const codeModeConfig: JsonObject = {
|
||||
...CODEX_CODE_MODE_THREAD_CONFIG,
|
||||
"features.code_mode_only": options.nativeCodeModeOnlyEnabled === true,
|
||||
};
|
||||
if (options.nativeCodeModeEnabled === false) {
|
||||
const disabledConfig = { ...config, ...CODEX_CODE_MODE_DISABLED_THREAD_CONFIG };
|
||||
// Patch streaming belongs to native code mode; omit it when that tool surface is disabled.
|
||||
delete disabledConfig["features.apply_patch_streaming_events"];
|
||||
return disabledConfig;
|
||||
}
|
||||
if (options.nativeCodeModeOnlyEnabled === true) {
|
||||
return { ...codeModeConfig, ...config, "features.code_mode_only": true };
|
||||
}
|
||||
return { ...codeModeConfig, ...config };
|
||||
}
|
||||
@@ -74,6 +74,7 @@ export function resolveCodexAppServerDirectSandboxBypassBlock(params: {
|
||||
method: string;
|
||||
requestParams?: unknown;
|
||||
config?: OpenClawConfig;
|
||||
agentId?: string;
|
||||
sessionKey?: string;
|
||||
sessionId?: string;
|
||||
}): string | undefined {
|
||||
@@ -81,6 +82,7 @@ export function resolveCodexAppServerDirectSandboxBypassBlock(params: {
|
||||
if (NODE_EXEC_BLOCKED_CONTROL_PLANE_METHODS.has(params.method)) {
|
||||
const nodeExecBlock = resolveCodexNativeNodeExecBlock({
|
||||
config: params.config,
|
||||
agentId: params.agentId,
|
||||
sessionKey: params.sessionKey,
|
||||
sessionId: params.sessionId,
|
||||
surface: `app-server method \`${params.method}\``,
|
||||
@@ -94,6 +96,7 @@ export function resolveCodexAppServerDirectSandboxBypassBlock(params: {
|
||||
}
|
||||
const nodeExecBlock = resolveCodexNativeNodeExecBlock({
|
||||
config: params.config,
|
||||
agentId: params.agentId,
|
||||
sessionKey: params.sessionKey,
|
||||
sessionId: params.sessionId,
|
||||
surface: `app-server method \`${params.method}\``,
|
||||
@@ -107,6 +110,7 @@ export function resolveCodexAppServerDirectSandboxBypassBlock(params: {
|
||||
}
|
||||
const sandboxBlock = resolveCodexNativeSandboxBlock({
|
||||
config: params.config,
|
||||
agentId: params.agentId,
|
||||
sessionKey,
|
||||
surface: `app-server method \`${params.method}\``,
|
||||
});
|
||||
@@ -125,6 +129,7 @@ export function resolveCodexAppServerDirectSandboxBypassBlock(params: {
|
||||
/** Resolves the generic native-execution block for sandboxed or node-hosted sessions. */
|
||||
export function resolveCodexNativeExecutionBlock(params: {
|
||||
config?: OpenClawConfig;
|
||||
agentId?: string;
|
||||
sessionKey?: string;
|
||||
sessionId?: string;
|
||||
surface: string;
|
||||
@@ -135,6 +140,7 @@ export function resolveCodexNativeExecutionBlock(params: {
|
||||
/** Returns a block message when native Codex execution cannot honor active sandboxing. */
|
||||
export function resolveCodexNativeSandboxBlock(params: {
|
||||
config?: OpenClawConfig;
|
||||
agentId?: string;
|
||||
sessionKey?: string;
|
||||
sessionId?: string;
|
||||
surface: string;
|
||||
@@ -145,6 +151,7 @@ export function resolveCodexNativeSandboxBlock(params: {
|
||||
}
|
||||
const runtime = resolveSandboxRuntimeStatus({
|
||||
cfg: params.config,
|
||||
agentId: params.agentId,
|
||||
sessionKey,
|
||||
});
|
||||
if (!runtime.sandboxed) {
|
||||
@@ -197,6 +204,7 @@ function formatCodexNativeSandboxBlock(params: { surface: string }): string {
|
||||
|
||||
function resolveCodexNativeNodeExecBlock(params: {
|
||||
config?: OpenClawConfig;
|
||||
agentId?: string;
|
||||
sessionKey?: string;
|
||||
sessionId?: string;
|
||||
surface: string;
|
||||
@@ -204,6 +212,7 @@ function resolveCodexNativeNodeExecBlock(params: {
|
||||
const sessionKey = params.sessionKey?.trim() || params.sessionId?.trim();
|
||||
const policy = resolveCodexNativeExecutionPolicy({
|
||||
config: params.config,
|
||||
agentId: params.agentId,
|
||||
sessionKey,
|
||||
readRuntimeSessionEntry: Boolean(sessionKey),
|
||||
});
|
||||
|
||||
@@ -10,8 +10,31 @@ import {
|
||||
} from "openclaw/plugin-sdk/agent-runtime-test-contracts";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { CodexThreadStartParams } from "./protocol.js";
|
||||
import { createCodexTestModel } from "./test-support.js";
|
||||
import { startOrResumeThread } from "./thread-lifecycle.js";
|
||||
import {
|
||||
resetCodexTestBindingStore,
|
||||
registerCodexTestSessionIdentity,
|
||||
testCodexAppServerBindingStore,
|
||||
} from "./session-binding.test-helpers.js";
|
||||
import { createCodexTestModel, ensureCodexTestClientNotificationSurface } from "./test-support.js";
|
||||
import { startOrResumeThread as startOrResumeThreadImpl } from "./thread-lifecycle.js";
|
||||
|
||||
function startOrResumeThread(
|
||||
params: Omit<Parameters<typeof startOrResumeThreadImpl>[0], "bindingStore" | "abandonClient"> & {
|
||||
abandonClient?: () => Promise<void>;
|
||||
},
|
||||
) {
|
||||
registerCodexTestSessionIdentity(
|
||||
params.params.sessionFile,
|
||||
params.params.sessionId,
|
||||
params.params.sessionKey,
|
||||
);
|
||||
return startOrResumeThreadImpl({
|
||||
...params,
|
||||
client: ensureCodexTestClientNotificationSurface(params.client),
|
||||
abandonClient: params.abandonClient ?? (async () => undefined),
|
||||
bindingStore: testCodexAppServerBindingStore,
|
||||
});
|
||||
}
|
||||
|
||||
let tempDir: string;
|
||||
|
||||
@@ -89,6 +112,7 @@ function threadStartResult(threadId = "thread-1", serviceTier: string | null = n
|
||||
|
||||
describe("Codex app-server dynamic tool schema boundary contract", () => {
|
||||
beforeEach(async () => {
|
||||
resetCodexTestBindingStore();
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-schema-contract-"));
|
||||
});
|
||||
|
||||
|
||||
3
extensions/codex/src/app-server/session-binding-meta.ts
Normal file
3
extensions/codex/src/app-server/session-binding-meta.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
/** Process-stable plugin-state metadata for Codex app-server bindings. */
|
||||
export const CODEX_APP_SERVER_BINDING_NAMESPACE = "app-server-thread-bindings";
|
||||
export const CODEX_APP_SERVER_BINDING_MAX_ENTRIES = 50_000;
|
||||
31
extensions/codex/src/app-server/session-binding-store.ts
Normal file
31
extensions/codex/src/app-server/session-binding-store.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
/** Lazy store facade that keeps binding schema/auth code off plugin startup. */
|
||||
import type { PluginStateSyncKeyedStore } from "openclaw/plugin-sdk/plugin-state-runtime";
|
||||
import {
|
||||
CODEX_APP_SERVER_BINDING_MAX_ENTRIES,
|
||||
CODEX_APP_SERVER_BINDING_NAMESPACE,
|
||||
} from "./session-binding-meta.js";
|
||||
import type { CodexAppServerBindingStore, StoredCodexAppServerBinding } from "./session-binding.js";
|
||||
|
||||
export { CODEX_APP_SERVER_BINDING_MAX_ENTRIES, CODEX_APP_SERVER_BINDING_NAMESPACE };
|
||||
export type { StoredCodexAppServerBinding } from "./session-binding.js";
|
||||
|
||||
/** Defers schema compilation and auth loading until the first binding operation. */
|
||||
export function createLazyCodexAppServerBindingStore(
|
||||
state: Pick<PluginStateSyncKeyedStore<StoredCodexAppServerBinding>, "lookup" | "update">,
|
||||
): CodexAppServerBindingStore {
|
||||
let resolved: Promise<CodexAppServerBindingStore> | undefined;
|
||||
const store = () =>
|
||||
(resolved ??= import("./session-binding.js").then(({ createCodexAppServerBindingStore }) =>
|
||||
createCodexAppServerBindingStore(state),
|
||||
));
|
||||
return {
|
||||
read: async (identity) => (await store()).read(identity),
|
||||
mutate: async (identity, mutation) => (await store()).mutate(identity, mutation),
|
||||
prepareSessionGenerationReclaim: async (identity) =>
|
||||
(await store()).prepareSessionGenerationReclaim(identity),
|
||||
adoptSessionGeneration: async (identity, previousSessionId) =>
|
||||
(await store()).adoptSessionGeneration(identity, previousSessionId),
|
||||
retireSessionGeneration: async (identity) => (await store()).retireSessionGeneration(identity),
|
||||
withLease: async (identity, run) => (await store()).withLease(identity, run),
|
||||
};
|
||||
}
|
||||
116
extensions/codex/src/app-server/session-binding.test-helpers.ts
Normal file
116
extensions/codex/src/app-server/session-binding.test-helpers.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
/** In-memory binding store helpers for Codex app-server tests. */
|
||||
import type { PluginStateSyncKeyedStore } from "openclaw/plugin-sdk/plugin-state-runtime";
|
||||
import {
|
||||
bindingStoreKey,
|
||||
createCodexAppServerBindingStore,
|
||||
type CodexAppServerBindingStore,
|
||||
type CodexAppServerThreadBinding,
|
||||
type StoredCodexAppServerBinding,
|
||||
} from "./session-binding.js";
|
||||
|
||||
export function createCodexTestBindingStateStore(): PluginStateSyncKeyedStore<StoredCodexAppServerBinding> {
|
||||
const values = new Map<string, StoredCodexAppServerBinding>();
|
||||
return {
|
||||
register(key, value) {
|
||||
values.set(key, value);
|
||||
},
|
||||
registerIfAbsent(key, value) {
|
||||
if (values.has(key)) {
|
||||
return false;
|
||||
}
|
||||
values.set(key, value);
|
||||
return true;
|
||||
},
|
||||
update(key, updateValue) {
|
||||
const next = updateValue(values.get(key));
|
||||
if (next === undefined) {
|
||||
return false;
|
||||
}
|
||||
values.set(key, next);
|
||||
return true;
|
||||
},
|
||||
lookup: (key) => values.get(key),
|
||||
consume(key) {
|
||||
const value = values.get(key);
|
||||
values.delete(key);
|
||||
return value;
|
||||
},
|
||||
delete: (key) => values.delete(key),
|
||||
entries: () => [...values].map(([key, value]) => ({ key, value, createdAt: 0 })),
|
||||
clear: () => values.clear(),
|
||||
};
|
||||
}
|
||||
|
||||
export function createCodexTestBindingStore(): CodexAppServerBindingStore {
|
||||
return createCodexAppServerBindingStore(createCodexTestBindingStateStore());
|
||||
}
|
||||
|
||||
const sharedStateStore = createCodexTestBindingStateStore();
|
||||
export const testCodexAppServerBindingStore = createCodexAppServerBindingStore(sharedStateStore);
|
||||
const testSessionIdentities = new Map<
|
||||
string,
|
||||
{ agentId: string; sessionId: string; sessionKey?: string }
|
||||
>();
|
||||
|
||||
export function resetCodexTestBindingStore(): void {
|
||||
sharedStateStore.clear();
|
||||
testSessionIdentities.clear();
|
||||
}
|
||||
|
||||
export function registerCodexTestSessionIdentity(
|
||||
locator: string,
|
||||
sessionId: string,
|
||||
sessionKey?: string,
|
||||
agentId = "main",
|
||||
): void {
|
||||
testSessionIdentities.set(locator, {
|
||||
agentId,
|
||||
sessionId,
|
||||
...(sessionKey ? { sessionKey } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
export function seedCodexTestBinding(locator: string, binding: CodexAppServerThreadBinding): void {
|
||||
sharedStateStore.register(bindingStoreKey(testIdentity(locator)), {
|
||||
version: 1,
|
||||
state: "active",
|
||||
binding,
|
||||
});
|
||||
}
|
||||
|
||||
function testIdentity(locator: string) {
|
||||
const identity = testSessionIdentities.get(locator);
|
||||
return {
|
||||
kind: "session" as const,
|
||||
agentId: identity?.agentId ?? "main",
|
||||
sessionId: identity?.sessionId ?? locator,
|
||||
...(identity?.sessionKey ? { sessionKey: identity.sessionKey } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function readCodexAppServerBinding(
|
||||
sessionId: string,
|
||||
): Promise<CodexAppServerThreadBinding | undefined> {
|
||||
return await testCodexAppServerBindingStore.read(testIdentity(sessionId));
|
||||
}
|
||||
|
||||
export async function writeCodexAppServerBinding(
|
||||
sessionId: string,
|
||||
binding: CodexAppServerThreadBinding,
|
||||
): Promise<void> {
|
||||
await testCodexAppServerBindingStore.mutate(testIdentity(sessionId), { kind: "set", binding });
|
||||
}
|
||||
|
||||
export async function clearCodexAppServerBinding(sessionId: string): Promise<void> {
|
||||
await testCodexAppServerBindingStore.mutate(testIdentity(sessionId), { kind: "clear" });
|
||||
}
|
||||
|
||||
export async function clearCodexAppServerBindingForThread(
|
||||
sessionId: string,
|
||||
threadId: string,
|
||||
): Promise<boolean> {
|
||||
return await testCodexAppServerBindingStore.mutate(testIdentity(sessionId), {
|
||||
kind: "clear",
|
||||
threadId,
|
||||
});
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,21 +1,29 @@
|
||||
// Codex tests cover shared client plugin behavior.
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { WebSocketServer, type RawData } from "ws";
|
||||
import type { resolveCodexAppServerAuthProfileIdForAgent } from "./auth-bridge.js";
|
||||
import { CodexAppServerClient, MIN_CODEX_APP_SERVER_VERSION } from "./client.js";
|
||||
import { codexAppServerStartOptionsKey } from "./config.js";
|
||||
import { createClientHarness } from "./test-support.js";
|
||||
|
||||
type AuthProfileResolverParams = Parameters<typeof resolveCodexAppServerAuthProfileIdForAgent>[0];
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
bridgeCodexAppServerStartOptions: vi.fn(async ({ startOptions }) => startOptions),
|
||||
applyCodexAppServerAuthProfile: vi.fn(
|
||||
async (_params?: { agentDir?: string; authProfileId?: string; config?: unknown }) => undefined,
|
||||
async (_params?: {
|
||||
client?: CodexAppServerClient;
|
||||
agentDir?: string;
|
||||
authProfileId?: string;
|
||||
config?: unknown;
|
||||
}) => undefined,
|
||||
),
|
||||
resolveCodexAppServerAuthProfileIdForAgent: vi.fn(
|
||||
(params?: { authProfileId?: string }) => params?.authProfileId,
|
||||
(params?: AuthProfileResolverParams) => params?.authProfileId,
|
||||
),
|
||||
resolveCodexAppServerAuthProfileStore: vi.fn(
|
||||
(params?: { authProfileStore?: unknown }) => params?.authProfileStore,
|
||||
),
|
||||
resolveCodexAppServerAuthAccountCacheKey: vi.fn(async () => "account:credential"),
|
||||
refreshCodexAppServerAuthTokens: vi.fn(async () => ({
|
||||
accessToken: "refreshed-access",
|
||||
chatgptAccountId: "refreshed-account",
|
||||
@@ -32,8 +40,9 @@ vi.mock("./auth-bridge.js", () => ({
|
||||
bridgeCodexAppServerStartOptions: mocks.bridgeCodexAppServerStartOptions,
|
||||
resolveCodexAppServerAuthProfileIdForAgent: mocks.resolveCodexAppServerAuthProfileIdForAgent,
|
||||
resolveCodexAppServerAuthProfileStore: mocks.resolveCodexAppServerAuthProfileStore,
|
||||
refreshCodexAppServerAuthTokens: mocks.refreshCodexAppServerAuthTokens,
|
||||
resolveCodexAppServerAuthAccountCacheKey: mocks.resolveCodexAppServerAuthAccountCacheKey,
|
||||
resolveCodexAppServerFallbackApiKeyCacheKey: mocks.resolveCodexAppServerFallbackApiKeyCacheKey,
|
||||
refreshCodexAppServerAuthTokens: mocks.refreshCodexAppServerAuthTokens,
|
||||
}));
|
||||
|
||||
vi.mock("./managed-binary.js", () => ({
|
||||
@@ -50,16 +59,10 @@ vi.mock("openclaw/plugin-sdk/agent-runtime", () => ({
|
||||
}));
|
||||
|
||||
let listCodexAppServerModels: typeof import("./models.js").listCodexAppServerModels;
|
||||
let clearSharedCodexAppServerClient: typeof import("./shared-client.js").clearSharedCodexAppServerClient;
|
||||
let clearSharedCodexAppServerClientIfCurrent: typeof import("./shared-client.js").clearSharedCodexAppServerClientIfCurrent;
|
||||
let clearSharedCodexAppServerClientIfCurrentAndWait: typeof import("./shared-client.js").clearSharedCodexAppServerClientIfCurrentAndWait;
|
||||
let clearSharedCodexAppServerClientAndWait: typeof import("./shared-client.js").clearSharedCodexAppServerClientAndWait;
|
||||
let createIsolatedCodexAppServerClient: typeof import("./shared-client.js").createIsolatedCodexAppServerClient;
|
||||
let detachSharedCodexAppServerClientIfCurrent: typeof import("./shared-client.js").detachSharedCodexAppServerClientIfCurrent;
|
||||
let getLeasedSharedCodexAppServerClient: typeof import("./shared-client.js").getLeasedSharedCodexAppServerClient;
|
||||
let getSharedCodexAppServerClient: typeof import("./shared-client.js").getSharedCodexAppServerClient;
|
||||
let retainSharedCodexAppServerClientIfCurrent: typeof import("./shared-client.js").retainSharedCodexAppServerClientIfCurrent;
|
||||
let releaseLeasedSharedCodexAppServerClient: typeof import("./shared-client.js").releaseLeasedSharedCodexAppServerClient;
|
||||
let retireSharedCodexAppServerClientIfCurrent: typeof import("./shared-client.js").retireSharedCodexAppServerClientIfCurrent;
|
||||
let leaseSharedCodexAppServerClient: typeof import("./shared-client.js").leaseSharedCodexAppServerClient;
|
||||
let retainSharedCodexAppServerClient: typeof import("./shared-client.js").retainSharedCodexAppServerClient;
|
||||
let resetSharedCodexAppServerClientForTests: typeof import("./shared-client.js").resetSharedCodexAppServerClientForTests;
|
||||
|
||||
async function sendInitializeResult(
|
||||
@@ -131,16 +134,10 @@ describe("shared Codex app-server client", () => {
|
||||
beforeAll(async () => {
|
||||
({ listCodexAppServerModels } = await import("./models.js"));
|
||||
({
|
||||
clearSharedCodexAppServerClient,
|
||||
clearSharedCodexAppServerClientIfCurrent,
|
||||
clearSharedCodexAppServerClientIfCurrentAndWait,
|
||||
clearSharedCodexAppServerClientAndWait,
|
||||
createIsolatedCodexAppServerClient,
|
||||
detachSharedCodexAppServerClientIfCurrent,
|
||||
getLeasedSharedCodexAppServerClient,
|
||||
getSharedCodexAppServerClient,
|
||||
retainSharedCodexAppServerClientIfCurrent,
|
||||
releaseLeasedSharedCodexAppServerClient,
|
||||
retireSharedCodexAppServerClientIfCurrent,
|
||||
leaseSharedCodexAppServerClient,
|
||||
retainSharedCodexAppServerClient,
|
||||
resetSharedCodexAppServerClientForTests,
|
||||
} = await import("./shared-client.js"));
|
||||
});
|
||||
@@ -151,17 +148,20 @@ describe("shared Codex app-server client", () => {
|
||||
vi.useRealTimers();
|
||||
mocks.bridgeCodexAppServerStartOptions.mockClear();
|
||||
mocks.applyCodexAppServerAuthProfile.mockClear();
|
||||
mocks.applyCodexAppServerAuthProfile.mockImplementation(async () => undefined);
|
||||
mocks.resolveCodexAppServerAuthProfileIdForAgent.mockClear();
|
||||
mocks.resolveCodexAppServerAuthProfileIdForAgent.mockImplementation(
|
||||
(params?: { authProfileId?: string }) => params?.authProfileId,
|
||||
(params?: AuthProfileResolverParams) => params?.authProfileId,
|
||||
);
|
||||
mocks.resolveCodexAppServerAuthProfileStore.mockClear();
|
||||
mocks.resolveCodexAppServerAuthProfileStore.mockImplementation(
|
||||
(params?: { authProfileStore?: unknown }) => params?.authProfileStore,
|
||||
);
|
||||
mocks.refreshCodexAppServerAuthTokens.mockClear();
|
||||
mocks.resolveCodexAppServerAuthAccountCacheKey.mockClear();
|
||||
mocks.resolveCodexAppServerAuthAccountCacheKey.mockResolvedValue("account:credential");
|
||||
mocks.resolveCodexAppServerFallbackApiKeyCacheKey.mockClear();
|
||||
mocks.resolveCodexAppServerFallbackApiKeyCacheKey.mockReturnValue(undefined);
|
||||
mocks.refreshCodexAppServerAuthTokens.mockClear();
|
||||
mocks.resolveManagedCodexAppServerStartOptions.mockClear();
|
||||
mocks.resolveManagedCodexAppServerStartOptions.mockImplementation(
|
||||
async (startOptions) => startOptions,
|
||||
@@ -213,11 +213,12 @@ describe("shared Codex app-server client", () => {
|
||||
const abandonController = new AbortController();
|
||||
vi.spyOn(CodexAppServerClient, "start").mockReturnValue(harness.client);
|
||||
|
||||
const abandonedAcquire = getSharedCodexAppServerClient({
|
||||
const abandonedAcquire = leaseSharedCodexAppServerClient({
|
||||
timeoutMs: 1000,
|
||||
abandonSignal: abandonController.signal,
|
||||
});
|
||||
const activeAcquire = getSharedCodexAppServerClient({ timeoutMs: 1000 });
|
||||
const abandonedResult = abandonedAcquire.catch((error: unknown) => error);
|
||||
const activeAcquire = leaseSharedCodexAppServerClient({ timeoutMs: 1000 });
|
||||
await vi.waitFor(() => expect(harness.writes.length).toBeGreaterThanOrEqual(1));
|
||||
|
||||
abandonController.abort();
|
||||
@@ -225,9 +226,130 @@ describe("shared Codex app-server client", () => {
|
||||
|
||||
await sendInitializeResult(harness, "openclaw/0.125.0 (macOS; test)");
|
||||
|
||||
await expect(abandonedAcquire).resolves.toBe(harness.client);
|
||||
await expect(activeAcquire).resolves.toBe(harness.client);
|
||||
await expect(abandonedResult).resolves.toBeInstanceOf(Error);
|
||||
const activeLease = await activeAcquire;
|
||||
expect(activeLease.client).toBe(harness.client);
|
||||
expect(harness.process.stdin.destroyed).toBe(false);
|
||||
activeLease.release();
|
||||
});
|
||||
|
||||
it("does not let one acquire timeout close startup owned by another caller", async () => {
|
||||
const harness = createClientHarness();
|
||||
vi.spyOn(CodexAppServerClient, "start").mockReturnValue(harness.client);
|
||||
|
||||
const timedOutAcquire = leaseSharedCodexAppServerClient({ timeoutMs: 5 });
|
||||
const activeAcquire = leaseSharedCodexAppServerClient({ timeoutMs: 1000 });
|
||||
|
||||
await expect(timedOutAcquire).rejects.toThrow("codex app-server initialize timed out");
|
||||
expect(harness.process.stdin.destroyed).toBe(false);
|
||||
|
||||
await sendInitializeResult(harness, "openclaw/0.125.0 (macOS; test)");
|
||||
const activeLease = await activeAcquire;
|
||||
expect(activeLease.client).toBe(harness.client);
|
||||
expect(harness.process.stdin.destroyed).toBe(false);
|
||||
activeLease.release();
|
||||
});
|
||||
|
||||
it("does not launch a client after its only acquire times out during preparation", async () => {
|
||||
let finishManagedResolution!: () => void;
|
||||
mocks.resolveManagedCodexAppServerStartOptions.mockImplementationOnce(async (startOptions) => {
|
||||
await new Promise<void>((resolve) => {
|
||||
finishManagedResolution = resolve;
|
||||
});
|
||||
return startOptions;
|
||||
});
|
||||
const startSpy = vi.spyOn(CodexAppServerClient, "start");
|
||||
const startOptions = {
|
||||
transport: "stdio" as const,
|
||||
command: "codex",
|
||||
commandSource: "managed" as const,
|
||||
args: ["app-server", "--listen", "stdio://"],
|
||||
headers: {},
|
||||
};
|
||||
|
||||
await expect(
|
||||
leaseSharedCodexAppServerClient({
|
||||
startOptions,
|
||||
authProfileId: "openai:work",
|
||||
timeoutMs: 5,
|
||||
}),
|
||||
).rejects.toThrow("codex app-server preparation timed out");
|
||||
finishManagedResolution();
|
||||
await new Promise<void>((resolve) => {
|
||||
setImmediate(resolve);
|
||||
});
|
||||
|
||||
expect(startSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not launch an isolated client after preparation times out", async () => {
|
||||
let finishManagedResolution!: () => void;
|
||||
mocks.resolveManagedCodexAppServerStartOptions.mockImplementationOnce(async (startOptions) => {
|
||||
await new Promise<void>((resolve) => {
|
||||
finishManagedResolution = resolve;
|
||||
});
|
||||
return startOptions;
|
||||
});
|
||||
const startSpy = vi.spyOn(CodexAppServerClient, "start");
|
||||
|
||||
await expect(createIsolatedCodexAppServerClient({ timeoutMs: 5 })).rejects.toThrow(
|
||||
"codex app-server preparation timed out",
|
||||
);
|
||||
finishManagedResolution();
|
||||
await new Promise<void>((resolve) => {
|
||||
setImmediate(resolve);
|
||||
});
|
||||
|
||||
expect(startSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not create a pool entry when abort wins after preparation resolves", async () => {
|
||||
const controller = new AbortController();
|
||||
const originalThrowIfAborted = controller.signal.throwIfAborted.bind(controller.signal);
|
||||
let checks = 0;
|
||||
vi.spyOn(controller.signal, "throwIfAborted").mockImplementation(() => {
|
||||
checks += 1;
|
||||
if (checks === 2) {
|
||||
controller.abort(new Error("aborted after preparation"));
|
||||
}
|
||||
originalThrowIfAborted();
|
||||
});
|
||||
const startSpy = vi.spyOn(CodexAppServerClient, "start");
|
||||
|
||||
await expect(
|
||||
leaseSharedCodexAppServerClient({
|
||||
timeoutMs: 1000,
|
||||
authProfileId: "openai:work",
|
||||
abandonSignal: controller.signal,
|
||||
}),
|
||||
).rejects.toThrow("aborted after preparation");
|
||||
|
||||
expect(startSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not grant a lease when abort wins after initialization resolves", async () => {
|
||||
const harness = createClientHarness();
|
||||
vi.spyOn(CodexAppServerClient, "start").mockReturnValue(harness.client);
|
||||
const controller = new AbortController();
|
||||
const originalThrowIfAborted = controller.signal.throwIfAborted.bind(controller.signal);
|
||||
let checks = 0;
|
||||
vi.spyOn(controller.signal, "throwIfAborted").mockImplementation(() => {
|
||||
checks += 1;
|
||||
if (checks === 4) {
|
||||
controller.abort(new Error("aborted after initialization"));
|
||||
}
|
||||
originalThrowIfAborted();
|
||||
});
|
||||
|
||||
const leasePromise = leaseSharedCodexAppServerClient({
|
||||
timeoutMs: 1000,
|
||||
authProfileId: "openai:work",
|
||||
abandonSignal: controller.signal,
|
||||
});
|
||||
await sendInitializeResult(harness, "openclaw/0.125.0 (macOS; test)");
|
||||
|
||||
await expect(leasePromise).rejects.toThrow("aborted after initialization");
|
||||
expect(harness.process.stdin.destroyed).toBe(true);
|
||||
});
|
||||
|
||||
it("does not wait for isolated initialize after a timeout closes the client", async () => {
|
||||
@@ -240,6 +362,20 @@ describe("shared Codex app-server client", () => {
|
||||
expect(harness.process.stdin.destroyed).toBe(true);
|
||||
});
|
||||
|
||||
it("bounds isolated auth application with the same startup deadline", async () => {
|
||||
const harness = createClientHarness();
|
||||
vi.spyOn(CodexAppServerClient, "start").mockReturnValue(harness.client);
|
||||
mocks.applyCodexAppServerAuthProfile.mockImplementationOnce(
|
||||
async () => await new Promise<undefined>(() => {}),
|
||||
);
|
||||
|
||||
const clientPromise = createIsolatedCodexAppServerClient({ timeoutMs: 100 });
|
||||
await sendInitializeResult(harness, "openclaw/0.125.0 (macOS; test)");
|
||||
|
||||
await expect(clientPromise).rejects.toThrow("codex app-server initialize timed out");
|
||||
expect(harness.process.stdin.destroyed).toBe(true);
|
||||
});
|
||||
|
||||
it("passes the selected auth profile through the bridge helper", async () => {
|
||||
const harness = createClientHarness();
|
||||
vi.spyOn(CodexAppServerClient, "start").mockReturnValue(harness.client);
|
||||
@@ -347,12 +483,41 @@ describe("shared Codex app-server client", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("installs physical-client handlers before initialization completes", async () => {
|
||||
const harness = createClientHarness();
|
||||
vi.spyOn(CodexAppServerClient, "start").mockReturnValue(harness.client);
|
||||
|
||||
const leasePromise = leaseSharedCodexAppServerClient({ timeoutMs: 1000 });
|
||||
await vi.waitFor(() => expect(harness.writes.length).toBeGreaterThanOrEqual(1));
|
||||
harness.send({
|
||||
id: "refresh-during-initialize",
|
||||
method: "account/chatgptAuthTokens/refresh",
|
||||
params: { reason: "expired" },
|
||||
});
|
||||
await vi.waitFor(() =>
|
||||
expect(harness.writes.map((line) => JSON.parse(line) as unknown)).toContainEqual({
|
||||
id: "refresh-during-initialize",
|
||||
result: {
|
||||
accessToken: "refreshed-access",
|
||||
chatgptAccountId: "refreshed-account",
|
||||
chatgptPlanType: null,
|
||||
},
|
||||
}),
|
||||
);
|
||||
await sendInitializeResult(harness, "openclaw/0.125.0 (macOS; test)");
|
||||
|
||||
const lease = await leasePromise;
|
||||
expect(lease.client).toBe(harness.client);
|
||||
expect(mocks.refreshCodexAppServerAuthTokens).toHaveBeenCalledTimes(1);
|
||||
lease.release();
|
||||
});
|
||||
|
||||
it("skips target auth resolution when native source auth is requested", async () => {
|
||||
const harness = createClientHarness();
|
||||
vi.spyOn(CodexAppServerClient, "start").mockReturnValue(harness.client);
|
||||
const config = { auth: { order: { openai: ["openai:target"] } } };
|
||||
|
||||
const clientPromise = getSharedCodexAppServerClient({
|
||||
const leasePromise = leaseSharedCodexAppServerClient({
|
||||
timeoutMs: 1000,
|
||||
authProfileId: null,
|
||||
agentDir: "/tmp/openclaw-target-agent",
|
||||
@@ -360,7 +525,8 @@ describe("shared Codex app-server client", () => {
|
||||
});
|
||||
await sendInitializeResult(harness, "openclaw/0.125.0 (macOS; test)");
|
||||
|
||||
await expect(clientPromise).resolves.toBe(harness.client);
|
||||
const lease = await leasePromise;
|
||||
expect(lease.client).toBe(harness.client);
|
||||
expect(mocks.resolveCodexAppServerAuthProfileIdForAgent).not.toHaveBeenCalled();
|
||||
const bridgeCall = bridgeStartOptionsCall();
|
||||
expect(bridgeCall.agentDir).toBe("/tmp/openclaw-target-agent");
|
||||
@@ -370,6 +536,7 @@ describe("shared Codex app-server client", () => {
|
||||
expect(applyCall.agentDir).toBe("/tmp/openclaw-target-agent");
|
||||
expect(applyCall.authProfileId).toBeNull();
|
||||
expect(applyCall.config).toBe(config);
|
||||
lease.release();
|
||||
});
|
||||
|
||||
it("resolves the configured implicit auth profile before sharing a client", async () => {
|
||||
@@ -388,7 +555,6 @@ describe("shared Codex app-server client", () => {
|
||||
await expect(listPromise).resolves.toEqual({ models: [] });
|
||||
const resolveCall = resolveAuthProfileCall();
|
||||
expect(resolveCall).toStrictEqual({
|
||||
authProfileId: undefined,
|
||||
agentDir: "/tmp/openclaw-agent",
|
||||
config,
|
||||
});
|
||||
@@ -400,6 +566,32 @@ describe("shared Codex app-server client", () => {
|
||||
expect(applyCall?.config).toBe(config);
|
||||
});
|
||||
|
||||
it("separates shared clients when implicit auth resolves to different profiles", async () => {
|
||||
const first = createClientHarness();
|
||||
const second = createClientHarness();
|
||||
const firstConfig = { auth: { order: { openai: ["openai:work"] } } };
|
||||
const secondConfig = { auth: { order: { openai: ["openai:personal"] } } };
|
||||
mocks.resolveCodexAppServerAuthProfileIdForAgent.mockImplementation(
|
||||
({ config }: AuthProfileResolverParams = {}) => config?.auth?.order?.openai?.[0],
|
||||
);
|
||||
const startSpy = vi
|
||||
.spyOn(CodexAppServerClient, "start")
|
||||
.mockReturnValueOnce(first.client)
|
||||
.mockReturnValueOnce(second.client);
|
||||
|
||||
const firstList = listCodexAppServerModels({ timeoutMs: 1000, config: firstConfig });
|
||||
await sendInitializeResult(first, "openclaw/0.125.0 (macOS; test)");
|
||||
await sendEmptyModelList(first);
|
||||
await expect(firstList).resolves.toEqual({ models: [] });
|
||||
|
||||
const secondList = listCodexAppServerModels({ timeoutMs: 1000, config: secondConfig });
|
||||
await sendInitializeResult(second, "openclaw/0.125.0 (macOS; test)");
|
||||
await sendEmptyModelList(second);
|
||||
await expect(secondList).resolves.toEqual({ models: [] });
|
||||
|
||||
expect(startSpy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("uses the selected agent dir for shared app-server auth bridging", async () => {
|
||||
const harness = createClientHarness();
|
||||
vi.spyOn(CodexAppServerClient, "start").mockReturnValue(harness.client);
|
||||
@@ -421,74 +613,6 @@ describe("shared Codex app-server client", () => {
|
||||
expect(applyCall?.authProfileId).toBe("openai:work");
|
||||
});
|
||||
|
||||
it("migrates legacy singleton global state into the keyed registry", async () => {
|
||||
const legacy = createClientHarness();
|
||||
const next = createClientHarness();
|
||||
const startOptions = {
|
||||
transport: "websocket" as const,
|
||||
command: "codex",
|
||||
args: [],
|
||||
url: "ws://127.0.0.1:39175",
|
||||
authToken: "tok-legacy",
|
||||
headers: {},
|
||||
};
|
||||
const key = codexAppServerStartOptionsKey(startOptions, {
|
||||
agentDir: "/tmp/openclaw-agent",
|
||||
});
|
||||
const globalState = globalThis as typeof globalThis & {
|
||||
[key: symbol]: unknown;
|
||||
};
|
||||
globalState[Symbol.for("openclaw.codexAppServerClientState")] = {
|
||||
key,
|
||||
client: legacy.client,
|
||||
promise: Promise.resolve(legacy.client),
|
||||
};
|
||||
|
||||
await expect(getSharedCodexAppServerClient({ startOptions })).resolves.toBe(legacy.client);
|
||||
|
||||
legacy.client.close();
|
||||
const startSpy = vi.spyOn(CodexAppServerClient, "start").mockReturnValue(next.client);
|
||||
const list = listCodexAppServerModels({ timeoutMs: 1000, startOptions });
|
||||
await sendInitializeResult(next, "openclaw/0.125.0 (macOS; test)");
|
||||
await sendEmptyModelList(next);
|
||||
|
||||
await expect(list).resolves.toEqual({ models: [] });
|
||||
expect(startSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("preserves keyed shared-client state when adding lease metadata", async () => {
|
||||
const legacy = createClientHarness();
|
||||
const startOptions = {
|
||||
transport: "websocket" as const,
|
||||
command: "codex",
|
||||
args: [],
|
||||
url: "ws://127.0.0.1:39176",
|
||||
authToken: "tok-keyed",
|
||||
headers: {},
|
||||
};
|
||||
const key = codexAppServerStartOptionsKey(startOptions, {
|
||||
agentDir: "/tmp/openclaw-agent",
|
||||
});
|
||||
const globalState = globalThis as typeof globalThis & {
|
||||
[key: symbol]: unknown;
|
||||
};
|
||||
globalState[Symbol.for("openclaw.codexAppServerClientState")] = {
|
||||
clients: new Map([[key, { client: legacy.client, promise: Promise.resolve(legacy.client) }]]),
|
||||
};
|
||||
|
||||
await expect(getLeasedSharedCodexAppServerClient({ startOptions })).resolves.toBe(
|
||||
legacy.client,
|
||||
);
|
||||
expect(retireSharedCodexAppServerClientIfCurrent(legacy.client)).toEqual({
|
||||
activeLeases: 1,
|
||||
closed: false,
|
||||
});
|
||||
expect(legacy.process.stdin.destroyed).toBe(false);
|
||||
|
||||
expect(releaseLeasedSharedCodexAppServerClient(legacy.client)).toBe(true);
|
||||
expect(legacy.process.stdin.destroyed).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps an active shared client alive when another agent dir uses a different key", async () => {
|
||||
const first = createClientHarness();
|
||||
const second = createClientHarness();
|
||||
@@ -499,6 +623,7 @@ describe("shared Codex app-server client", () => {
|
||||
|
||||
const firstList = listCodexAppServerModels({
|
||||
timeoutMs: 1000,
|
||||
authProfileId: null,
|
||||
agentDir: "/tmp/openclaw-agent-one",
|
||||
});
|
||||
await sendInitializeResult(first, "openclaw/0.125.0 (macOS; test)");
|
||||
@@ -507,6 +632,7 @@ describe("shared Codex app-server client", () => {
|
||||
|
||||
const secondList = listCodexAppServerModels({
|
||||
timeoutMs: 1000,
|
||||
authProfileId: null,
|
||||
agentDir: "/tmp/openclaw-agent-two",
|
||||
});
|
||||
await sendInitializeResult(second, "openclaw/0.125.0 (macOS; test)");
|
||||
@@ -518,6 +644,76 @@ describe("shared Codex app-server client", () => {
|
||||
expect(second.process.stdin.destroyed).toBe(false);
|
||||
});
|
||||
|
||||
it("bounds idle shared clients and closes the least recently released process", async () => {
|
||||
const harnesses = Array.from({ length: 5 }, () => createClientHarness());
|
||||
const startSpy = vi.spyOn(CodexAppServerClient, "start");
|
||||
for (const harness of harnesses) {
|
||||
startSpy.mockReturnValueOnce(harness.client);
|
||||
}
|
||||
|
||||
for (const [index, harness] of harnesses.slice(0, 4).entries()) {
|
||||
const leasePromise = leaseSharedCodexAppServerClient({
|
||||
timeoutMs: 1000,
|
||||
authProfileId: null,
|
||||
agentDir: `/tmp/openclaw-agent-${index}`,
|
||||
});
|
||||
await sendInitializeResult(harness, "openclaw/0.125.0 (macOS; test)");
|
||||
const lease = await leasePromise;
|
||||
lease.release();
|
||||
}
|
||||
const refreshed = await leaseSharedCodexAppServerClient({
|
||||
timeoutMs: 1000,
|
||||
authProfileId: null,
|
||||
agentDir: "/tmp/openclaw-agent-0",
|
||||
});
|
||||
refreshed.release();
|
||||
const newestLeasePromise = leaseSharedCodexAppServerClient({
|
||||
timeoutMs: 1000,
|
||||
authProfileId: null,
|
||||
agentDir: "/tmp/openclaw-agent-4",
|
||||
});
|
||||
await sendInitializeResult(harnesses[4], "openclaw/0.125.0 (macOS; test)");
|
||||
(await newestLeasePromise).release();
|
||||
|
||||
expect(harnesses[0]?.process.stdin.destroyed).toBe(false);
|
||||
expect(harnesses[1]?.process.stdin.destroyed).toBe(true);
|
||||
for (const harness of harnesses.slice(2)) {
|
||||
expect(harness.process.stdin.destroyed).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not evict a client retained by detached background work", async () => {
|
||||
const retained = createClientHarness();
|
||||
const idle = Array.from({ length: 5 }, () => createClientHarness());
|
||||
const startSpy = vi.spyOn(CodexAppServerClient, "start");
|
||||
for (const harness of [retained, ...idle]) {
|
||||
startSpy.mockReturnValueOnce(harness.client);
|
||||
}
|
||||
const retainedLeasePromise = leaseSharedCodexAppServerClient({
|
||||
timeoutMs: 1000,
|
||||
authProfileId: null,
|
||||
agentDir: "/tmp/openclaw-retained-agent",
|
||||
});
|
||||
await sendInitializeResult(retained, "openclaw/0.125.0 (macOS; test)");
|
||||
const retainedLease = await retainedLeasePromise;
|
||||
const releaseRetention = retainSharedCodexAppServerClient(retained.client);
|
||||
retainedLease.release();
|
||||
|
||||
for (const [index, harness] of idle.entries()) {
|
||||
const leasePromise = leaseSharedCodexAppServerClient({
|
||||
timeoutMs: 1000,
|
||||
authProfileId: null,
|
||||
agentDir: `/tmp/openclaw-idle-agent-${index}`,
|
||||
});
|
||||
await sendInitializeResult(harness, "openclaw/0.125.0 (macOS; test)");
|
||||
(await leasePromise).release();
|
||||
}
|
||||
|
||||
expect(retained.process.stdin.destroyed).toBe(false);
|
||||
releaseRetention();
|
||||
expect(retained.process.stdin.destroyed).toBe(false);
|
||||
});
|
||||
|
||||
it("resolves the managed binary before bridging and spawning the shared client", async () => {
|
||||
const harness = createClientHarness();
|
||||
const startSpy = vi.spyOn(CodexAppServerClient, "start").mockReturnValue(harness.client);
|
||||
@@ -543,6 +739,28 @@ describe("shared Codex app-server client", () => {
|
||||
expect(startCall?.commandSource).toBe("resolved-managed");
|
||||
});
|
||||
|
||||
it("resolves managed binary metadata once while refreshing credentials per acquire", async () => {
|
||||
const harness = createClientHarness();
|
||||
vi.spyOn(CodexAppServerClient, "start").mockReturnValue(harness.client);
|
||||
|
||||
const firstLeasePromise = leaseSharedCodexAppServerClient({
|
||||
timeoutMs: 1000,
|
||||
authProfileId: "openai:work",
|
||||
});
|
||||
await sendInitializeResult(harness, "openclaw/0.125.0 (macOS; test)");
|
||||
const firstLease = await firstLeasePromise;
|
||||
firstLease.release();
|
||||
|
||||
const secondLease = await leaseSharedCodexAppServerClient({
|
||||
timeoutMs: 1000,
|
||||
authProfileId: "openai:work",
|
||||
});
|
||||
secondLease.release();
|
||||
|
||||
expect(mocks.resolveManagedCodexAppServerStartOptions).toHaveBeenCalledOnce();
|
||||
expect(mocks.bridgeCodexAppServerStartOptions).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("starts an independent shared client when the bridged auth token changes", async () => {
|
||||
const first = createClientHarness();
|
||||
const second = createClientHarness();
|
||||
@@ -585,7 +803,29 @@ describe("shared Codex app-server client", () => {
|
||||
expect(first.process.stdin.destroyed).toBe(false);
|
||||
});
|
||||
|
||||
it("starts an independent shared client when fallback api-key auth changes", async () => {
|
||||
it("keeps native and fallback auth in separate shared scopes", async () => {
|
||||
const first = createClientHarness();
|
||||
const second = createClientHarness();
|
||||
const startSpy = vi
|
||||
.spyOn(CodexAppServerClient, "start")
|
||||
.mockReturnValueOnce(first.client)
|
||||
.mockReturnValueOnce(second.client);
|
||||
const firstList = listCodexAppServerModels({ timeoutMs: 1000, authProfileId: null });
|
||||
await sendInitializeResult(first, "openclaw/0.125.0 (macOS; test)");
|
||||
await sendEmptyModelList(first);
|
||||
await expect(firstList).resolves.toEqual({ models: [] });
|
||||
|
||||
const secondList = listCodexAppServerModels({ timeoutMs: 1000 });
|
||||
await sendInitializeResult(second, "openclaw/0.125.0 (macOS; test)");
|
||||
await sendEmptyModelList(second);
|
||||
await expect(secondList).resolves.toEqual({ models: [] });
|
||||
|
||||
expect(startSpy).toHaveBeenCalledTimes(2);
|
||||
expect(first.process.stdin.destroyed).toBe(false);
|
||||
expect(second.process.stdin.destroyed).toBe(false);
|
||||
});
|
||||
|
||||
it("starts a new shared client when fallback api-key auth changes", async () => {
|
||||
const first = createClientHarness();
|
||||
const second = createClientHarness();
|
||||
const startSpy = vi
|
||||
@@ -611,6 +851,37 @@ describe("shared Codex app-server client", () => {
|
||||
expect(second.process.stdin.destroyed).toBe(false);
|
||||
});
|
||||
|
||||
it("starts a new shared client when an explicit profile credential changes", async () => {
|
||||
const first = createClientHarness();
|
||||
const second = createClientHarness();
|
||||
const startSpy = vi
|
||||
.spyOn(CodexAppServerClient, "start")
|
||||
.mockReturnValueOnce(first.client)
|
||||
.mockReturnValueOnce(second.client);
|
||||
mocks.resolveCodexAppServerAuthAccountCacheKey
|
||||
.mockResolvedValueOnce("account:credential-1")
|
||||
.mockResolvedValueOnce("account:credential-2");
|
||||
|
||||
const firstList = listCodexAppServerModels({
|
||||
timeoutMs: 1000,
|
||||
authProfileId: "openai:work",
|
||||
});
|
||||
await sendInitializeResult(first, "openclaw/0.125.0 (macOS; test)");
|
||||
await sendEmptyModelList(first);
|
||||
await expect(firstList).resolves.toEqual({ models: [] });
|
||||
|
||||
const secondList = listCodexAppServerModels({
|
||||
timeoutMs: 1000,
|
||||
authProfileId: "openai:work",
|
||||
});
|
||||
await sendInitializeResult(second, "openclaw/0.125.0 (macOS; test)");
|
||||
await sendEmptyModelList(second);
|
||||
await expect(secondList).resolves.toEqual({ models: [] });
|
||||
|
||||
expect(startSpy).toHaveBeenCalledTimes(2);
|
||||
expect(first.process.stdin.destroyed).toBe(false);
|
||||
});
|
||||
|
||||
it("does not let one shared-client failure tear down another keyed client", async () => {
|
||||
const first = createClientHarness();
|
||||
const second = createClientHarness();
|
||||
@@ -655,128 +926,154 @@ describe("shared Codex app-server client", () => {
|
||||
expect(second.process.kill).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("only clears the shared client that is still current", async () => {
|
||||
it("abandons a matching shared client without disturbing its replacement", async () => {
|
||||
const first = createClientHarness();
|
||||
const second = createClientHarness();
|
||||
vi.spyOn(CodexAppServerClient, "start")
|
||||
.mockReturnValueOnce(first.client)
|
||||
.mockReturnValueOnce(second.client);
|
||||
const firstCloseAndWait = vi.spyOn(first.client, "closeAndWait");
|
||||
|
||||
const firstList = listCodexAppServerModels({ timeoutMs: 1000 });
|
||||
const firstLeasePromise = leaseSharedCodexAppServerClient({ timeoutMs: 1000 });
|
||||
await sendInitializeResult(first, "openclaw/0.125.0 (macOS; test)");
|
||||
await sendEmptyModelList(first);
|
||||
await expect(firstList).resolves.toEqual({ models: [] });
|
||||
const firstLease = await firstLeasePromise;
|
||||
|
||||
expect(clearSharedCodexAppServerClientIfCurrent(first.client)).toBe(true);
|
||||
await firstLease.abandon();
|
||||
expect(first.process.stdin.destroyed).toBe(true);
|
||||
|
||||
const secondList = listCodexAppServerModels({ timeoutMs: 1000 });
|
||||
const secondLeasePromise = leaseSharedCodexAppServerClient({ timeoutMs: 1000 });
|
||||
await sendInitializeResult(second, "openclaw/0.125.0 (macOS; test)");
|
||||
await sendEmptyModelList(second);
|
||||
await expect(secondList).resolves.toEqual({ models: [] });
|
||||
const secondLease = await secondLeasePromise;
|
||||
|
||||
expect(clearSharedCodexAppServerClientIfCurrent(first.client)).toBe(false);
|
||||
await firstLease.abandon();
|
||||
firstLease.release();
|
||||
expect(firstCloseAndWait).toHaveBeenCalledTimes(1);
|
||||
expect(second.process.kill).not.toHaveBeenCalled();
|
||||
expect(clearSharedCodexAppServerClientIfCurrent(second.client)).toBe(true);
|
||||
await secondLease.abandon();
|
||||
expect(second.process.stdin.destroyed).toBe(true);
|
||||
});
|
||||
|
||||
it("can detach the current shared client without closing it", async () => {
|
||||
const first = createClientHarness();
|
||||
const second = createClientHarness();
|
||||
vi.spyOn(CodexAppServerClient, "start")
|
||||
.mockReturnValueOnce(first.client)
|
||||
.mockReturnValueOnce(second.client);
|
||||
it("closes an abandoned client without removing its idle replacement", async () => {
|
||||
const retired = createClientHarness();
|
||||
const replacement = createClientHarness();
|
||||
const otherIdle = Array.from({ length: 4 }, () => createClientHarness());
|
||||
const startSpy = vi.spyOn(CodexAppServerClient, "start");
|
||||
for (const harness of [retired, replacement, ...otherIdle]) {
|
||||
startSpy.mockReturnValueOnce(harness.client);
|
||||
}
|
||||
const sharedOptions = {
|
||||
timeoutMs: 1000,
|
||||
authProfileId: "openai:work",
|
||||
};
|
||||
const retiringLeasePromise = leaseSharedCodexAppServerClient(sharedOptions);
|
||||
const liveLeasePromise = leaseSharedCodexAppServerClient(sharedOptions);
|
||||
await sendInitializeResult(retired, "openclaw/0.125.0 (macOS; test)");
|
||||
const retiringLease = await retiringLeasePromise;
|
||||
const liveLease = await liveLeasePromise;
|
||||
|
||||
const firstList = listCodexAppServerModels({ timeoutMs: 1000 });
|
||||
await sendInitializeResult(first, "openclaw/0.125.0 (macOS; test)");
|
||||
await sendEmptyModelList(first);
|
||||
await expect(firstList).resolves.toEqual({ models: [] });
|
||||
await retiringLease.abandon();
|
||||
const replacementLeasePromise = leaseSharedCodexAppServerClient(sharedOptions);
|
||||
await sendInitializeResult(replacement, "openclaw/0.125.0 (macOS; test)");
|
||||
const replacementLease = await replacementLeasePromise;
|
||||
replacementLease.release();
|
||||
|
||||
expect(detachSharedCodexAppServerClientIfCurrent(first.client)).toBe(true);
|
||||
expect(first.process.stdin.destroyed).toBe(false);
|
||||
const releaseRetention = retainSharedCodexAppServerClient(retired.client);
|
||||
for (const [index, harness] of otherIdle.entries()) {
|
||||
const leasePromise = leaseSharedCodexAppServerClient({
|
||||
...sharedOptions,
|
||||
agentDir: `/tmp/openclaw-retired-idle-agent-${index}`,
|
||||
});
|
||||
await sendInitializeResult(harness, "openclaw/0.125.0 (macOS; test)");
|
||||
(await leasePromise).release();
|
||||
}
|
||||
|
||||
const secondList = listCodexAppServerModels({ timeoutMs: 1000 });
|
||||
await sendInitializeResult(second, "openclaw/0.125.0 (macOS; test)");
|
||||
await sendEmptyModelList(second);
|
||||
await expect(secondList).resolves.toEqual({ models: [] });
|
||||
|
||||
expect(detachSharedCodexAppServerClientIfCurrent(first.client)).toBe(false);
|
||||
first.client.close();
|
||||
expect(first.process.stdin.destroyed).toBe(true);
|
||||
expect(second.process.kill).not.toHaveBeenCalled();
|
||||
expect(detachSharedCodexAppServerClientIfCurrent(second.client)).toBe(true);
|
||||
second.client.close();
|
||||
expect(second.process.stdin.destroyed).toBe(true);
|
||||
expect(replacement.process.stdin.destroyed).toBe(true);
|
||||
expect(retired.process.stdin.destroyed).toBe(true);
|
||||
releaseRetention();
|
||||
liveLease.release();
|
||||
expect(retired.process.stdin.destroyed).toBe(true);
|
||||
});
|
||||
|
||||
it("closes a retired shared app-server after all active leases release", async () => {
|
||||
const first = createClientHarness();
|
||||
const second = createClientHarness();
|
||||
vi.spyOn(CodexAppServerClient, "start")
|
||||
.mockReturnValueOnce(first.client)
|
||||
.mockReturnValueOnce(second.client);
|
||||
|
||||
const firstList = listCodexAppServerModels({ timeoutMs: 1000 });
|
||||
await sendInitializeResult(first, "openclaw/0.125.0 (macOS; test)");
|
||||
await sendEmptyModelList(first);
|
||||
await expect(firstList).resolves.toEqual({ models: [] });
|
||||
|
||||
const releaseFirst = retainSharedCodexAppServerClientIfCurrent(first.client);
|
||||
const releaseSecond = retainSharedCodexAppServerClientIfCurrent(first.client);
|
||||
expect(releaseFirst).toBeTypeOf("function");
|
||||
expect(releaseSecond).toBeTypeOf("function");
|
||||
expect(retireSharedCodexAppServerClientIfCurrent(first.client)).toEqual({
|
||||
activeLeases: 2,
|
||||
closed: false,
|
||||
});
|
||||
expect(first.process.stdin.destroyed).toBe(false);
|
||||
|
||||
const secondList = listCodexAppServerModels({ timeoutMs: 1000 });
|
||||
await sendInitializeResult(second, "openclaw/0.125.0 (macOS; test)");
|
||||
await sendEmptyModelList(second);
|
||||
await expect(secondList).resolves.toEqual({ models: [] });
|
||||
|
||||
releaseFirst?.();
|
||||
expect(first.process.stdin.destroyed).toBe(false);
|
||||
releaseSecond?.();
|
||||
expect(first.process.stdin.destroyed).toBe(true);
|
||||
expect(second.process.kill).not.toHaveBeenCalled();
|
||||
expect(retireSharedCodexAppServerClientIfCurrent(second.client)).toEqual({
|
||||
activeLeases: 0,
|
||||
closed: true,
|
||||
});
|
||||
expect(second.process.stdin.destroyed).toBe(true);
|
||||
});
|
||||
|
||||
it("leases shared app-server clients before returning concurrent acquirers", async () => {
|
||||
it("settles each concurrent shared-client lease exactly once", async () => {
|
||||
const first = createClientHarness();
|
||||
vi.spyOn(CodexAppServerClient, "start").mockReturnValueOnce(first.client);
|
||||
const close = vi.spyOn(first.client, "close");
|
||||
const closeAndWait = vi.spyOn(first.client, "closeAndWait");
|
||||
|
||||
const firstLease = getLeasedSharedCodexAppServerClient({ timeoutMs: 1000 });
|
||||
const secondLease = getLeasedSharedCodexAppServerClient({ timeoutMs: 1000 });
|
||||
const firstLeasePromise = leaseSharedCodexAppServerClient({
|
||||
timeoutMs: 1000,
|
||||
authProfileId: "openai:work",
|
||||
});
|
||||
const secondLeasePromise = leaseSharedCodexAppServerClient({
|
||||
timeoutMs: 1000,
|
||||
authProfileId: "openai:work",
|
||||
});
|
||||
await sendInitializeResult(first, "openclaw/0.125.0 (macOS; test)");
|
||||
await expect(firstLease).resolves.toBe(first.client);
|
||||
await expect(secondLease).resolves.toBe(first.client);
|
||||
const firstLease = await firstLeasePromise;
|
||||
const secondLease = await secondLeasePromise;
|
||||
expect(firstLease.client).toBe(first.client);
|
||||
expect(secondLease.client).toBe(first.client);
|
||||
|
||||
expect(retireSharedCodexAppServerClientIfCurrent(first.client)).toEqual({
|
||||
activeLeases: 2,
|
||||
closed: false,
|
||||
});
|
||||
expect(retireSharedCodexAppServerClientIfCurrent(first.client)).toEqual({
|
||||
activeLeases: 2,
|
||||
closed: false,
|
||||
});
|
||||
expect(first.process.stdin.destroyed).toBe(false);
|
||||
expect(mocks.resolveManagedCodexAppServerStartOptions).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.resolveCodexAppServerAuthProfileIdForAgent).not.toHaveBeenCalled();
|
||||
expect(mocks.bridgeCodexAppServerStartOptions).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(releaseLeasedSharedCodexAppServerClient(first.client)).toBe(true);
|
||||
expect(first.process.stdin.destroyed).toBe(false);
|
||||
expect(releaseLeasedSharedCodexAppServerClient(first.client)).toBe(true);
|
||||
await firstLease.abandon();
|
||||
await firstLease.abandon();
|
||||
firstLease.release();
|
||||
expect(first.process.stdin.destroyed).toBe(true);
|
||||
expect(releaseLeasedSharedCodexAppServerClient(first.client)).toBe(false);
|
||||
expect(close).not.toHaveBeenCalled();
|
||||
expect(closeAndWait).toHaveBeenCalledTimes(1);
|
||||
await expect(firstLease.client.request("model/list", {})).rejects.toThrow();
|
||||
|
||||
secondLease.release();
|
||||
secondLease.release();
|
||||
await secondLease.abandon();
|
||||
expect(first.process.stdin.destroyed).toBe(true);
|
||||
expect(close).not.toHaveBeenCalled();
|
||||
expect(closeAndWait).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("waits only for the shared client that is still current", async () => {
|
||||
it("waits for an already-detached client retirement during clear-all shutdown", async () => {
|
||||
const first = createClientHarness();
|
||||
vi.spyOn(CodexAppServerClient, "start").mockReturnValueOnce(first.client);
|
||||
let finishRetirement!: () => void;
|
||||
const retirementBlocked = new Promise<void>((resolve) => {
|
||||
finishRetirement = resolve;
|
||||
});
|
||||
const closeAndWait = vi.spyOn(first.client, "closeAndWait").mockImplementation(async () => {
|
||||
first.client.close();
|
||||
await retirementBlocked;
|
||||
});
|
||||
const leasePromise = leaseSharedCodexAppServerClient({
|
||||
timeoutMs: 1000,
|
||||
authProfileId: "openai:work",
|
||||
});
|
||||
await sendInitializeResult(first, "openclaw/0.125.0 (macOS; test)");
|
||||
const lease = await leasePromise;
|
||||
const abandon = lease.abandon();
|
||||
await vi.waitFor(() => expect(closeAndWait).toHaveBeenCalledOnce());
|
||||
|
||||
let shutdownSettled = false;
|
||||
const shutdown = clearSharedCodexAppServerClientAndWait({
|
||||
exitTimeoutMs: 25,
|
||||
forceKillDelayMs: 5,
|
||||
}).then(() => {
|
||||
shutdownSettled = true;
|
||||
});
|
||||
await new Promise<void>((resolve) => {
|
||||
setImmediate(resolve);
|
||||
});
|
||||
expect(shutdownSettled).toBe(false);
|
||||
|
||||
finishRetirement();
|
||||
await Promise.all([abandon, shutdown]);
|
||||
|
||||
expect(closeAndWait).toHaveBeenCalledTimes(1);
|
||||
expect(first.process.stdin.destroyed).toBe(true);
|
||||
});
|
||||
|
||||
it("abandons only the client owned by the exact lease", async () => {
|
||||
const first = createClientHarness();
|
||||
const second = createClientHarness();
|
||||
vi.spyOn(CodexAppServerClient, "start")
|
||||
@@ -785,33 +1082,28 @@ describe("shared Codex app-server client", () => {
|
||||
const firstCloseAndWait = vi.spyOn(first.client, "closeAndWait");
|
||||
const secondCloseAndWait = vi.spyOn(second.client, "closeAndWait");
|
||||
|
||||
const firstList = listCodexAppServerModels({
|
||||
const firstLeasePromise = leaseSharedCodexAppServerClient({
|
||||
timeoutMs: 1000,
|
||||
agentDir: "/tmp/openclaw-agent-one",
|
||||
});
|
||||
await sendInitializeResult(first, "openclaw/0.125.0 (macOS; test)");
|
||||
await sendEmptyModelList(first);
|
||||
await expect(firstList).resolves.toEqual({ models: [] });
|
||||
const firstLease = await firstLeasePromise;
|
||||
|
||||
const secondList = listCodexAppServerModels({
|
||||
const secondLeasePromise = leaseSharedCodexAppServerClient({
|
||||
timeoutMs: 1000,
|
||||
agentDir: "/tmp/openclaw-agent-two",
|
||||
});
|
||||
await sendInitializeResult(second, "openclaw/0.125.0 (macOS; test)");
|
||||
await sendEmptyModelList(second);
|
||||
await expect(secondList).resolves.toEqual({ models: [] });
|
||||
const secondLease = await secondLeasePromise;
|
||||
|
||||
await expect(
|
||||
clearSharedCodexAppServerClientIfCurrentAndWait(first.client, {
|
||||
exitTimeoutMs: 25,
|
||||
forceKillDelayMs: 5,
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
await firstLease.abandon();
|
||||
|
||||
expect(firstCloseAndWait).toHaveBeenCalledTimes(1);
|
||||
expect(secondCloseAndWait).not.toHaveBeenCalled();
|
||||
expect(first.process.stdin.destroyed).toBe(true);
|
||||
expect(second.process.stdin.destroyed).toBe(false);
|
||||
|
||||
await secondLease.abandon();
|
||||
});
|
||||
|
||||
it("uses a fresh websocket Authorization header after shared-client token rotation", async () => {
|
||||
@@ -872,7 +1164,7 @@ describe("shared Codex app-server client", () => {
|
||||
|
||||
expect(authHeaders).toEqual(["Bearer tok-first", "Bearer tok-second"]);
|
||||
} finally {
|
||||
clearSharedCodexAppServerClient();
|
||||
resetSharedCodexAppServerClientForTests();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,8 @@
|
||||
// Codex tests cover side question plugin behavior.
|
||||
import { nativeHookRelayTesting } from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import {
|
||||
invokeNativeHookRelay,
|
||||
nativeHookRelayTesting,
|
||||
} from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import {
|
||||
onInternalDiagnosticEvent,
|
||||
resetDiagnosticEventsForTest,
|
||||
@@ -16,29 +19,50 @@ import type { CodexServerNotification, JsonObject, RpcRequest } from "./protocol
|
||||
const readCodexAppServerBindingMock = vi.fn();
|
||||
const isCodexAppServerNativeAuthProfileMock = vi.fn();
|
||||
const getSharedCodexAppServerClientMock = vi.fn();
|
||||
const refreshCodexAppServerAuthTokensMock = vi.fn();
|
||||
const releaseSharedCodexAppServerClientMock = vi.fn();
|
||||
const abandonSharedCodexAppServerClientMock = vi.fn(async () => undefined);
|
||||
const createOpenClawCodingToolsMock = vi.fn();
|
||||
const toolExecuteMock = vi.fn();
|
||||
const handleCodexAppServerApprovalRequestMock = vi.fn();
|
||||
const configRuntimeMock = vi.hoisted(() => ({ rejectedProvider: undefined as string | undefined }));
|
||||
|
||||
vi.mock("./session-binding.js", () => ({
|
||||
clearCodexAppServerBinding: vi.fn(),
|
||||
isCodexAppServerNativeAuthProfile: (...args: unknown[]) =>
|
||||
isCodexAppServerNativeAuthProfileMock(...args),
|
||||
readCodexAppServerBinding: (...args: unknown[]) => readCodexAppServerBindingMock(...args),
|
||||
writeCodexAppServerBinding: vi.fn(),
|
||||
}));
|
||||
vi.mock("./config.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("./config.js")>();
|
||||
return {
|
||||
...actual,
|
||||
resolveCodexAppServerRuntime: (
|
||||
params: Parameters<typeof actual.resolveCodexAppServerRuntime>[0],
|
||||
) => {
|
||||
if (
|
||||
configRuntimeMock.rejectedProvider &&
|
||||
params?.modelProvider === configRuntimeMock.rejectedProvider
|
||||
) {
|
||||
throw new Error(`rejected active provider: ${params.modelProvider}`);
|
||||
}
|
||||
return actual.resolveCodexAppServerRuntime(params);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./session-binding.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("./session-binding.js")>();
|
||||
return {
|
||||
...actual,
|
||||
isCodexAppServerNativeAuthProfile: (...args: unknown[]) =>
|
||||
isCodexAppServerNativeAuthProfileMock(...args),
|
||||
resolveCodexAppServerBindingModelProvider: (params: { modelProvider?: string }) =>
|
||||
params.modelProvider?.trim() ||
|
||||
(isCodexAppServerNativeAuthProfileMock(params) ? "openai" : undefined),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./shared-client.js", () => ({
|
||||
getSharedCodexAppServerClient: (...args: unknown[]) => getSharedCodexAppServerClientMock(...args),
|
||||
getLeasedSharedCodexAppServerClient: (...args: unknown[]) =>
|
||||
getSharedCodexAppServerClientMock(...args),
|
||||
releaseLeasedSharedCodexAppServerClient: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./auth-bridge.js", () => ({
|
||||
refreshCodexAppServerAuthTokens: (...args: unknown[]) =>
|
||||
refreshCodexAppServerAuthTokensMock(...args),
|
||||
leaseSharedCodexAppServerClient: async (...args: unknown[]) => ({
|
||||
client: await getSharedCodexAppServerClientMock(...args),
|
||||
release: releaseSharedCodexAppServerClientMock,
|
||||
abandon: abandonSharedCodexAppServerClientMock,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("./approval-bridge.js", () => ({
|
||||
@@ -50,7 +74,25 @@ vi.mock("openclaw/plugin-sdk/agent-harness", () => ({
|
||||
createOpenClawCodingTools: (...args: unknown[]) => createOpenClawCodingToolsMock(...args),
|
||||
}));
|
||||
|
||||
const { testing, runCodexAppServerSideQuestion } = await import("./side-question.js");
|
||||
const { resolveDynamicToolCallTimeoutMs } = await import("./dynamic-tool-execution.js");
|
||||
const { runCodexAppServerSideQuestion: runCodexAppServerSideQuestionImpl } =
|
||||
await import("./side-question.js");
|
||||
type SideQuestionOptions = Parameters<typeof runCodexAppServerSideQuestionImpl>[1];
|
||||
const bindingStore: SideQuestionOptions["bindingStore"] = {
|
||||
read: (...args) => readCodexAppServerBindingMock(...args),
|
||||
mutate: vi.fn(),
|
||||
prepareSessionGenerationReclaim: vi.fn(),
|
||||
adoptSessionGeneration: vi.fn(),
|
||||
retireSessionGeneration: vi.fn(),
|
||||
withLease: (_identity, run) => run(),
|
||||
};
|
||||
|
||||
function runCodexAppServerSideQuestion(
|
||||
params: Parameters<typeof runCodexAppServerSideQuestionImpl>[0],
|
||||
options: Omit<SideQuestionOptions, "bindingStore"> = {},
|
||||
) {
|
||||
return runCodexAppServerSideQuestionImpl(params, { ...options, bindingStore });
|
||||
}
|
||||
|
||||
type ServerRequest = Required<Pick<RpcRequest, "id" | "method">> & {
|
||||
params?: RpcRequest["params"];
|
||||
@@ -65,6 +107,7 @@ type FakeClient = {
|
||||
request: ReturnType<typeof vi.fn<ClientRequest>>;
|
||||
addNotificationHandler: ReturnType<typeof vi.fn>;
|
||||
addRequestHandler: ReturnType<typeof vi.fn>;
|
||||
addCloseHandler: ReturnType<typeof vi.fn>;
|
||||
notifications: Array<(notification: CodexServerNotification) => void>;
|
||||
requests: Array<(request: ServerRequest) => unknown>;
|
||||
emit: (notification: CodexServerNotification) => void;
|
||||
@@ -96,6 +139,7 @@ function createFakeClient(): FakeClient {
|
||||
}
|
||||
};
|
||||
}),
|
||||
addCloseHandler: vi.fn(() => () => undefined),
|
||||
emit: (notification) => {
|
||||
for (const handler of notifications) {
|
||||
handler(notification);
|
||||
@@ -269,6 +313,28 @@ function agentDelta(threadId: string, turnId: string, delta: string): CodexServe
|
||||
};
|
||||
}
|
||||
|
||||
function agentCompleted(params: {
|
||||
threadId: string;
|
||||
turnId: string;
|
||||
itemId: string;
|
||||
text: string;
|
||||
phase?: string;
|
||||
}): CodexServerNotification {
|
||||
return {
|
||||
method: "item/completed",
|
||||
params: {
|
||||
threadId: params.threadId,
|
||||
turnId: params.turnId,
|
||||
item: {
|
||||
id: params.itemId,
|
||||
type: "agentMessage",
|
||||
text: params.text,
|
||||
...(params.phase ? { phase: params.phase } : {}),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function turnCompleted(threadId: string, turnId: string, text: string): CodexServerNotification {
|
||||
return {
|
||||
method: "turn/completed",
|
||||
@@ -276,7 +342,6 @@ function turnCompleted(threadId: string, turnId: string, text: string): CodexSer
|
||||
threadId,
|
||||
turn: {
|
||||
id: turnId,
|
||||
threadId,
|
||||
status: "completed",
|
||||
items: [{ id: "agent-1", type: "agentMessage", text }],
|
||||
error: null,
|
||||
@@ -288,14 +353,11 @@ function turnCompleted(threadId: string, turnId: string, text: string): CodexSer
|
||||
};
|
||||
}
|
||||
|
||||
function turnCompletedWithNestedThread(
|
||||
threadId: string,
|
||||
turnId: string,
|
||||
text: string,
|
||||
): CodexServerNotification {
|
||||
const notification = turnCompleted(threadId, turnId, text);
|
||||
const turn = (notification.params as JsonObject).turn;
|
||||
return { method: notification.method, params: { threadId: "parent-thread", turn } };
|
||||
function turnCompletedWithoutItems(threadId: string, turnId: string): CodexServerNotification {
|
||||
const notification = turnCompleted(threadId, turnId, "");
|
||||
const turn = (notification.params as JsonObject).turn as JsonObject;
|
||||
turn.items = [];
|
||||
return notification;
|
||||
}
|
||||
|
||||
function sideParams(overrides: Partial<Parameters<typeof runCodexAppServerSideQuestion>[0]> = {}) {
|
||||
@@ -328,10 +390,12 @@ describe("runCodexAppServerSideQuestion", () => {
|
||||
readCodexAppServerBindingMock.mockReset();
|
||||
isCodexAppServerNativeAuthProfileMock.mockReset();
|
||||
getSharedCodexAppServerClientMock.mockReset();
|
||||
refreshCodexAppServerAuthTokensMock.mockReset();
|
||||
releaseSharedCodexAppServerClientMock.mockReset();
|
||||
abandonSharedCodexAppServerClientMock.mockClear();
|
||||
createOpenClawCodingToolsMock.mockReset();
|
||||
toolExecuteMock.mockReset();
|
||||
handleCodexAppServerApprovalRequestMock.mockReset();
|
||||
configRuntimeMock.rejectedProvider = undefined;
|
||||
|
||||
toolExecuteMock.mockResolvedValue({
|
||||
content: [{ type: "text", text: "tool output" }],
|
||||
@@ -354,16 +418,10 @@ describe("runCodexAppServerSideQuestion", () => {
|
||||
model: "gpt-5.5",
|
||||
approvalPolicy: "on-request",
|
||||
sandbox: "workspace-write",
|
||||
createdAt: new Date(0).toISOString(),
|
||||
updatedAt: new Date(0).toISOString(),
|
||||
historyCoveredThrough: new Date(0).toISOString(),
|
||||
});
|
||||
isCodexAppServerNativeAuthProfileMock.mockReturnValue(true);
|
||||
getSharedCodexAppServerClientMock.mockResolvedValue(createFakeClient());
|
||||
refreshCodexAppServerAuthTokensMock.mockResolvedValue({
|
||||
accessToken: "access-token",
|
||||
chatgptAccountId: "account-1",
|
||||
chatgptPlanType: "plus",
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -395,6 +453,7 @@ describe("runCodexAppServerSideQuestion", () => {
|
||||
"cwd",
|
||||
"developerInstructions",
|
||||
"ephemeral",
|
||||
"excludeTurns",
|
||||
"model",
|
||||
"personality",
|
||||
"sandbox",
|
||||
@@ -407,6 +466,7 @@ describe("runCodexAppServerSideQuestion", () => {
|
||||
expect(forkParams?.approvalPolicy).toBe("on-request");
|
||||
expect(forkParams?.sandbox).toBe("workspace-write");
|
||||
expect(forkParams?.ephemeral).toBe(true);
|
||||
expect(forkParams?.excludeTurns).toBe(true);
|
||||
expect(forkParams?.threadSource).toBe("user");
|
||||
expect(forkParams?.approvalsReviewer).toBe("user");
|
||||
expect(forkParams?.cwd).toBe("/tmp/workspace");
|
||||
@@ -419,7 +479,10 @@ describe("runCodexAppServerSideQuestion", () => {
|
||||
expect(forkParams?.developerInstructions).toContain(
|
||||
"Only instructions submitted after the side-conversation boundary are active.",
|
||||
);
|
||||
expect(forkCall?.[2]).toEqual({ timeoutMs: 60_000, signal: undefined });
|
||||
expect(forkCall?.[2]).toEqual({
|
||||
timeoutMs: 60_000,
|
||||
signal: expect.any(AbortSignal),
|
||||
});
|
||||
|
||||
const injectCall = mockCall(client.request, 1);
|
||||
expect(injectCall?.[0]).toBe("thread/inject_items");
|
||||
@@ -430,7 +493,10 @@ describe("runCodexAppServerSideQuestion", () => {
|
||||
expect(injectParams?.items).toHaveLength(1);
|
||||
expect(injectParams?.items?.[0]?.type).toBe("message");
|
||||
expect(injectParams?.items?.[0]?.role).toBe("user");
|
||||
expect(injectCall?.[2]).toEqual({ timeoutMs: 60_000, signal: undefined });
|
||||
expect(injectCall?.[2]).toEqual({
|
||||
timeoutMs: 60_000,
|
||||
signal: expect.any(AbortSignal),
|
||||
});
|
||||
const injectedItem = injectParams?.items?.[0] as
|
||||
| { content?: Array<{ text?: string }> }
|
||||
| undefined;
|
||||
@@ -448,6 +514,15 @@ describe("runCodexAppServerSideQuestion", () => {
|
||||
threadId: "side-thread",
|
||||
input: [{ type: "text", text: "What changed?", text_elements: [] }],
|
||||
cwd: "/tmp/workspace",
|
||||
approvalPolicy: "on-request",
|
||||
approvalsReviewer: "user",
|
||||
sandboxPolicy: {
|
||||
type: "workspaceWrite",
|
||||
writableRoots: ["/tmp/workspace"],
|
||||
networkAccess: false,
|
||||
excludeTmpdirEnvVar: false,
|
||||
excludeSlashTmp: false,
|
||||
},
|
||||
model: "gpt-5.5",
|
||||
personality: "none",
|
||||
effort: null,
|
||||
@@ -460,11 +535,8 @@ describe("runCodexAppServerSideQuestion", () => {
|
||||
},
|
||||
},
|
||||
},
|
||||
{ timeoutMs: 60_000, signal: undefined },
|
||||
{ timeoutMs: 60_000, signal: expect.any(AbortSignal) },
|
||||
]);
|
||||
const turnStartParams = turnStartCall?.[1] as Record<string, unknown> | undefined;
|
||||
expect(turnStartParams).not.toHaveProperty("approvalPolicy");
|
||||
expect(turnStartParams).not.toHaveProperty("sandboxPolicy");
|
||||
expect(client.request.mock.calls.at(-1)).toEqual([
|
||||
"thread/unsubscribe",
|
||||
{ threadId: "side-thread" },
|
||||
@@ -478,13 +550,19 @@ describe("runCodexAppServerSideQuestion", () => {
|
||||
expect(toolOptions).toHaveProperty("sessionId", "session-1");
|
||||
expect(toolOptions).toHaveProperty("modelProvider", "openai");
|
||||
expect(toolOptions).toHaveProperty("modelId", "gpt-5.5");
|
||||
expect(toolOptions).toHaveProperty("messageProvider", "discord-voice");
|
||||
expect(toolOptions).toHaveProperty("messageProvider", "discord");
|
||||
expect(toolOptions).toHaveProperty("toolPolicyMessageProvider", "discord-voice");
|
||||
expect(toolOptions).toHaveProperty("currentChannelId", "voice-room");
|
||||
expect(toolOptions).toHaveProperty("requireExplicitMessageTarget", true);
|
||||
});
|
||||
|
||||
it("returns side-thread completions scoped by nested turn thread id", async () => {
|
||||
it("serializes streamed deltas before an empty completed turn", async () => {
|
||||
const client = createFakeClient();
|
||||
let releaseAssistantStart!: () => void;
|
||||
const assistantStartPending = new Promise<void>((resolve) => {
|
||||
releaseAssistantStart = resolve;
|
||||
});
|
||||
const onAssistantMessageStart = vi.fn(() => assistantStartPending);
|
||||
client.request.mockImplementation(async (method: string) => {
|
||||
if (method === "thread/fork") {
|
||||
return threadResult("side-thread");
|
||||
@@ -493,9 +571,11 @@ describe("runCodexAppServerSideQuestion", () => {
|
||||
return {};
|
||||
}
|
||||
if (method === "turn/start") {
|
||||
queueMicrotask(() =>
|
||||
client.emit(turnCompletedWithNestedThread("side-thread", "turn-1", "Nested answer.")),
|
||||
);
|
||||
queueMicrotask(() => {
|
||||
client.emit(agentDelta("side-thread", "turn-1", "Side "));
|
||||
client.emit(agentDelta("side-thread", "turn-1", "answer."));
|
||||
client.emit(turnCompletedWithoutItems("side-thread", "turn-1"));
|
||||
});
|
||||
return turnStartResult("turn-1");
|
||||
}
|
||||
if (method === "thread/unsubscribe" || method === "turn/interrupt") {
|
||||
@@ -505,9 +585,107 @@ describe("runCodexAppServerSideQuestion", () => {
|
||||
});
|
||||
getSharedCodexAppServerClientMock.mockResolvedValue(client);
|
||||
|
||||
const result = await runCodexAppServerSideQuestion(sideParams());
|
||||
const result = runCodexAppServerSideQuestion(sideParams({ opts: { onAssistantMessageStart } }));
|
||||
await vi.waitFor(() => expect(onAssistantMessageStart).toHaveBeenCalledTimes(1));
|
||||
releaseAssistantStart();
|
||||
|
||||
expect(result).toEqual({ text: "Nested answer." });
|
||||
await expect(result).resolves.toEqual({ text: "Side answer." });
|
||||
});
|
||||
|
||||
it("returns the final completed item when the terminal turn omits items", async () => {
|
||||
const client = createFakeClient();
|
||||
client.request.mockImplementation(async (method: string) => {
|
||||
if (method === "thread/fork") {
|
||||
return threadResult("side-thread");
|
||||
}
|
||||
if (method === "thread/inject_items") {
|
||||
return {};
|
||||
}
|
||||
if (method === "turn/start") {
|
||||
queueMicrotask(() => {
|
||||
client.emit(
|
||||
agentCompleted({
|
||||
threadId: "side-thread",
|
||||
turnId: "turn-1",
|
||||
itemId: "commentary-1",
|
||||
text: "Still working.",
|
||||
phase: "commentary",
|
||||
}),
|
||||
);
|
||||
client.emit(
|
||||
agentCompleted({
|
||||
threadId: "side-thread",
|
||||
turnId: "turn-1",
|
||||
itemId: "final-1",
|
||||
text: "Canonical side answer.",
|
||||
phase: "final_answer",
|
||||
}),
|
||||
);
|
||||
client.emit(turnCompletedWithoutItems("side-thread", "turn-1"));
|
||||
});
|
||||
return turnStartResult("turn-1");
|
||||
}
|
||||
if (method === "thread/unsubscribe" || method === "turn/interrupt") {
|
||||
return {};
|
||||
}
|
||||
throw new Error(`unexpected request: ${method}`);
|
||||
});
|
||||
getSharedCodexAppServerClientMock.mockResolvedValue(client);
|
||||
|
||||
await expect(runCodexAppServerSideQuestion(sideParams())).resolves.toEqual({
|
||||
text: "Canonical side answer.",
|
||||
});
|
||||
expect(client.request.mock.calls.some(([method]) => method === "turn/interrupt")).toBe(false);
|
||||
});
|
||||
|
||||
it("releases a pending notification flush when the side question is aborted", async () => {
|
||||
const client = createFakeClient();
|
||||
const abortController = new AbortController();
|
||||
const onAssistantMessageStart = vi.fn(() => new Promise<void>(() => {}));
|
||||
client.request.mockImplementation(async (method: string) => {
|
||||
if (method === "thread/fork") {
|
||||
return threadResult("side-thread");
|
||||
}
|
||||
if (method === "thread/inject_items") {
|
||||
return {};
|
||||
}
|
||||
if (method === "turn/start") {
|
||||
queueMicrotask(() => client.emit(agentDelta("side-thread", "turn-1", "pending")));
|
||||
return turnStartResult("turn-1");
|
||||
}
|
||||
if (method === "thread/unsubscribe" || method === "turn/interrupt") {
|
||||
return {};
|
||||
}
|
||||
throw new Error(`unexpected request: ${method}`);
|
||||
});
|
||||
getSharedCodexAppServerClientMock.mockResolvedValue(client);
|
||||
const result = runCodexAppServerSideQuestion(
|
||||
sideParams({
|
||||
opts: { abortSignal: abortController.signal, onAssistantMessageStart },
|
||||
}),
|
||||
);
|
||||
await vi.waitFor(() => expect(onAssistantMessageStart).toHaveBeenCalledTimes(1));
|
||||
|
||||
abortController.abort();
|
||||
|
||||
await expect(result).rejects.toThrow("Codex /btw was aborted.");
|
||||
});
|
||||
|
||||
it("reuses one client router after each side question releases its route", async () => {
|
||||
const client = createFakeClient();
|
||||
getSharedCodexAppServerClientMock.mockResolvedValue(client);
|
||||
|
||||
await expect(runCodexAppServerSideQuestion(sideParams())).resolves.toEqual({
|
||||
text: "Side answer.",
|
||||
});
|
||||
await expect(runCodexAppServerSideQuestion(sideParams())).resolves.toEqual({
|
||||
text: "Side answer.",
|
||||
});
|
||||
|
||||
expect(client.addNotificationHandler).toHaveBeenCalledTimes(1);
|
||||
expect(client.addRequestHandler).toHaveBeenCalledTimes(1);
|
||||
expect(client.notifications).toHaveLength(1);
|
||||
expect(client.requests).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("rejects /btw before forking when the current OpenClaw session is sandboxed", async () => {
|
||||
@@ -555,7 +733,12 @@ describe("runCodexAppServerSideQuestion", () => {
|
||||
sessionKey: "agent:main:session-1",
|
||||
runId: "run-side-1",
|
||||
channelId: "voice-room",
|
||||
allowedEvents: ["pre_tool_use", "post_tool_use", "before_agent_finalize"],
|
||||
allowedEvents: [
|
||||
"pre_tool_use",
|
||||
"post_tool_use",
|
||||
"permission_request",
|
||||
"before_agent_finalize",
|
||||
],
|
||||
});
|
||||
return threadResult("side-thread");
|
||||
}
|
||||
@@ -594,7 +777,9 @@ describe("runCodexAppServerSideQuestion", () => {
|
||||
expect(config?.["features.hooks"]).toBe(true);
|
||||
expect(config?.["features.code_mode"]).toBe(true);
|
||||
expect(config?.["features.code_mode_only"]).toBe(false);
|
||||
expect(config?.["hooks.PermissionRequest"]).toEqual([]);
|
||||
expect(codexHookCommand(config, "hooks.PermissionRequest")?.command).toContain(
|
||||
"--event permission_request",
|
||||
);
|
||||
const preToolUseHooks = config?.["hooks.PreToolUse"] as
|
||||
| Array<{ hooks?: Array<{ command?: string; timeout?: number; type?: string }> }>
|
||||
| undefined;
|
||||
@@ -609,7 +794,8 @@ describe("runCodexAppServerSideQuestion", () => {
|
||||
expect(preToolUseState?.enabled).toBe(true);
|
||||
expect(preToolUseState?.trusted_hash).toMatch(/^sha256:[a-f0-9]{64}$/);
|
||||
const permissionRequestState = codexHookStateForEvent(hookState, "permission_request");
|
||||
expect(permissionRequestState).toEqual({ enabled: false });
|
||||
expect(permissionRequestState?.enabled).toBe(true);
|
||||
expect(permissionRequestState?.trusted_hash).toMatch(/^sha256:[a-f0-9]{64}$/);
|
||||
const turnStartCall = client.request.mock.calls.find(([method]) => method === "turn/start");
|
||||
expect(turnStartCall?.[1]).not.toHaveProperty("config");
|
||||
expect(relayIdDuringFork).toBeDefined();
|
||||
@@ -751,8 +937,7 @@ describe("runCodexAppServerSideQuestion", () => {
|
||||
model: "gpt-5.5",
|
||||
approvalPolicy: "never",
|
||||
sandbox: "workspace-write",
|
||||
createdAt: new Date(0).toISOString(),
|
||||
updatedAt: new Date(0).toISOString(),
|
||||
historyCoveredThrough: new Date(0).toISOString(),
|
||||
});
|
||||
const client = createFakeClient();
|
||||
getSharedCodexAppServerClientMock.mockResolvedValue(client);
|
||||
@@ -854,8 +1039,7 @@ describe("runCodexAppServerSideQuestion", () => {
|
||||
model: "gpt-5.5",
|
||||
approvalPolicy: "never",
|
||||
sandbox: "danger-full-access",
|
||||
createdAt: new Date(0).toISOString(),
|
||||
updatedAt: new Date(0).toISOString(),
|
||||
historyCoveredThrough: new Date(0).toISOString(),
|
||||
});
|
||||
|
||||
await expect(
|
||||
@@ -899,8 +1083,7 @@ describe("runCodexAppServerSideQuestion", () => {
|
||||
modelProvider: "lmstudio",
|
||||
approvalPolicy: "never",
|
||||
sandbox: "danger-full-access",
|
||||
createdAt: new Date(0).toISOString(),
|
||||
updatedAt: new Date(0).toISOString(),
|
||||
historyCoveredThrough: new Date(0).toISOString(),
|
||||
});
|
||||
|
||||
await expect(
|
||||
@@ -931,6 +1114,125 @@ describe("runCodexAppServerSideQuestion", () => {
|
||||
expect(config?.["features.code_mode_only"]).toBe(true);
|
||||
});
|
||||
|
||||
it("uses the provider returned by Codex for side-turn policy", async () => {
|
||||
readCodexAppServerBindingMock.mockResolvedValue({
|
||||
schemaVersion: 1,
|
||||
threadId: "parent-thread",
|
||||
sessionFile: "/tmp/session-1.jsonl",
|
||||
cwd: "/tmp/workspace",
|
||||
authProfileId: "openai:work",
|
||||
model: "gpt-5.5",
|
||||
approvalPolicy: "never",
|
||||
sandbox: "danger-full-access",
|
||||
historyCoveredThrough: new Date(0).toISOString(),
|
||||
});
|
||||
const client = createFakeClient();
|
||||
let relayIdDuringFork: string | undefined;
|
||||
let permissionResponse: unknown;
|
||||
const approvalRequester = vi.fn(async () => "allow" as const);
|
||||
nativeHookRelayTesting.setNativeHookRelayPermissionApprovalRequesterForTests(approvalRequester);
|
||||
client.request.mockImplementation(async (method: string, requestParams: unknown) => {
|
||||
if (method === "thread/fork") {
|
||||
relayIdDuringFork = extractRelayIdFromThreadConfig(
|
||||
(requestParams as { config?: Record<string, unknown> }).config,
|
||||
);
|
||||
const response = threadResult("side-thread");
|
||||
return {
|
||||
...response,
|
||||
thread: { ...response.thread, modelProvider: "lmstudio" },
|
||||
model: "local-model",
|
||||
modelProvider: "lmstudio",
|
||||
};
|
||||
}
|
||||
if (method === "thread/inject_items") {
|
||||
return {};
|
||||
}
|
||||
if (method === "turn/start") {
|
||||
permissionResponse = await invokeNativeHookRelay({
|
||||
provider: "codex",
|
||||
relayId: relayIdDuringFork,
|
||||
event: "permission_request",
|
||||
rawPayload: {
|
||||
hook_event_name: "PermissionRequest",
|
||||
permission_mode: "default",
|
||||
tool_name: "Bash",
|
||||
tool_input: { command: "git push" },
|
||||
},
|
||||
});
|
||||
queueMicrotask(() => {
|
||||
client.emit(agentDelta("side-thread", "turn-1", "Side answer."));
|
||||
client.emit(turnCompleted("side-thread", "turn-1", "Side answer."));
|
||||
});
|
||||
return turnStartResult("turn-1");
|
||||
}
|
||||
if (method === "thread/unsubscribe" || method === "turn/interrupt") {
|
||||
return {};
|
||||
}
|
||||
throw new Error(`unexpected request: ${method}`);
|
||||
});
|
||||
getSharedCodexAppServerClientMock.mockResolvedValue(client);
|
||||
|
||||
await expect(
|
||||
runCodexAppServerSideQuestion(sideParams({ provider: "codex", model: "gpt-5.5" }), {
|
||||
pluginConfig: { appServer: { mode: "yolo", approvalsReviewer: "auto_review" } },
|
||||
nativeHookRelay: { enabled: true },
|
||||
}),
|
||||
).resolves.toEqual({ text: "Side answer." });
|
||||
|
||||
const turnStartCall = client.request.mock.calls.find(([method]) => method === "turn/start");
|
||||
const turnStartParams = turnStartCall?.[1] as Record<string, unknown> | undefined;
|
||||
const forkCall = client.request.mock.calls.find(([method]) => method === "thread/fork");
|
||||
const forkParams = forkCall?.[1] as Record<string, unknown> | undefined;
|
||||
expect(forkParams?.approvalPolicy).toBe("never");
|
||||
expect(codexHookCommand(forkParams?.config, "hooks.PermissionRequest")?.command).toContain(
|
||||
"--event permission_request",
|
||||
);
|
||||
expect(turnStartParams).toMatchObject({
|
||||
model: "local-model",
|
||||
approvalPolicy: "on-request",
|
||||
approvalsReviewer: "user",
|
||||
sandboxPolicy: {
|
||||
type: "workspaceWrite",
|
||||
writableRoots: ["/tmp/workspace"],
|
||||
networkAccess: false,
|
||||
},
|
||||
});
|
||||
expect(permissionResponse).toEqual({ stdout: "", stderr: "", exitCode: 0 });
|
||||
expect(approvalRequester).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("unsubscribes a fork when authoritative provider policy rejects it", async () => {
|
||||
const client = createFakeClient();
|
||||
client.request.mockImplementation(async (method: string) => {
|
||||
if (method === "thread/fork") {
|
||||
const response = threadResult("side-thread");
|
||||
return {
|
||||
...response,
|
||||
thread: { ...response.thread, modelProvider: "lmstudio" },
|
||||
model: "local-model",
|
||||
modelProvider: "lmstudio",
|
||||
};
|
||||
}
|
||||
if (method === "thread/unsubscribe") {
|
||||
return {};
|
||||
}
|
||||
throw new Error(`unexpected request: ${method}`);
|
||||
});
|
||||
getSharedCodexAppServerClientMock.mockResolvedValue(client);
|
||||
configRuntimeMock.rejectedProvider = "lmstudio";
|
||||
|
||||
await expect(runCodexAppServerSideQuestion(sideParams())).rejects.toThrow(
|
||||
"rejected active provider: lmstudio",
|
||||
);
|
||||
|
||||
expect(client.request).toHaveBeenCalledWith(
|
||||
"thread/unsubscribe",
|
||||
{ threadId: "side-thread" },
|
||||
{ timeoutMs: 60_000 },
|
||||
);
|
||||
expect(releaseSharedCodexAppServerClientMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("uses bound local providers for side-thread model ids that contain slashes", async () => {
|
||||
const client = createFakeClient();
|
||||
getSharedCodexAppServerClientMock.mockResolvedValue(client);
|
||||
@@ -944,8 +1246,7 @@ describe("runCodexAppServerSideQuestion", () => {
|
||||
modelProvider: "lmstudio",
|
||||
approvalPolicy: "never",
|
||||
sandbox: "danger-full-access",
|
||||
createdAt: new Date(0).toISOString(),
|
||||
updatedAt: new Date(0).toISOString(),
|
||||
historyCoveredThrough: new Date(0).toISOString(),
|
||||
});
|
||||
|
||||
await expect(
|
||||
@@ -983,8 +1284,7 @@ describe("runCodexAppServerSideQuestion", () => {
|
||||
modelProvider: "lmstudio",
|
||||
approvalPolicy: "never",
|
||||
sandbox: "danger-full-access",
|
||||
createdAt: new Date(0).toISOString(),
|
||||
updatedAt: new Date(0).toISOString(),
|
||||
historyCoveredThrough: new Date(0).toISOString(),
|
||||
});
|
||||
|
||||
await expect(
|
||||
@@ -1006,7 +1306,7 @@ describe("runCodexAppServerSideQuestion", () => {
|
||||
|
||||
const forkParams = mockCall(client.request)[1] as Record<string, unknown> | undefined;
|
||||
expect(forkParams?.model).toBe("gpt-5.5");
|
||||
expect(forkParams).not.toHaveProperty("modelProvider");
|
||||
expect(forkParams?.modelProvider).toBe("openai");
|
||||
expect(forkParams?.approvalsReviewer).toBe("auto_review");
|
||||
});
|
||||
|
||||
@@ -1024,8 +1324,7 @@ describe("runCodexAppServerSideQuestion", () => {
|
||||
modelProvider: "lmstudio",
|
||||
approvalPolicy: "never",
|
||||
sandbox: "danger-full-access",
|
||||
createdAt: new Date(0).toISOString(),
|
||||
updatedAt: new Date(0).toISOString(),
|
||||
historyCoveredThrough: new Date(0).toISOString(),
|
||||
});
|
||||
|
||||
await expect(
|
||||
@@ -1047,7 +1346,7 @@ describe("runCodexAppServerSideQuestion", () => {
|
||||
const forkParams = mockCall(client.request)[1] as Record<string, unknown> | undefined;
|
||||
expect(forkParams?.model).toBe("gpt-5.5");
|
||||
expect(forkParams).not.toHaveProperty("modelProvider");
|
||||
expect(forkParams?.approvalsReviewer).toBe("auto_review");
|
||||
expect(forkParams?.approvalsReviewer).toBe("user");
|
||||
});
|
||||
|
||||
it("keeps native hook relays alive across side-thread startup and completion timeouts", async () => {
|
||||
@@ -1371,7 +1670,7 @@ describe("runCodexAppServerSideQuestion", () => {
|
||||
});
|
||||
|
||||
it("uses configured image generation timeout for side-thread image_generate calls", () => {
|
||||
const timeoutMs = testing.resolveSideDynamicToolCallTimeoutMs({
|
||||
const timeoutMs = resolveDynamicToolCallTimeoutMs({
|
||||
call: {
|
||||
threadId: "side-thread",
|
||||
turnId: "turn-1",
|
||||
@@ -1393,7 +1692,7 @@ describe("runCodexAppServerSideQuestion", () => {
|
||||
});
|
||||
|
||||
it("uses a 120 second default for side-thread image_generate calls", () => {
|
||||
const timeoutMs = testing.resolveSideDynamicToolCallTimeoutMs({
|
||||
const timeoutMs = resolveDynamicToolCallTimeoutMs({
|
||||
call: {
|
||||
threadId: "side-thread",
|
||||
turnId: "turn-1",
|
||||
@@ -1407,7 +1706,7 @@ describe("runCodexAppServerSideQuestion", () => {
|
||||
});
|
||||
|
||||
it("uses a 90 second default for generic side-thread dynamic tool calls", () => {
|
||||
const timeoutMs = testing.resolveSideDynamicToolCallTimeoutMs({
|
||||
const timeoutMs = resolveDynamicToolCallTimeoutMs({
|
||||
call: {
|
||||
threadId: "side-thread",
|
||||
turnId: "turn-1",
|
||||
@@ -1421,7 +1720,7 @@ describe("runCodexAppServerSideQuestion", () => {
|
||||
expect(timeoutMs).toBe(90_000);
|
||||
});
|
||||
|
||||
it("cleans up notification handlers when side tool setup fails", async () => {
|
||||
it("does not add a turn route when side tool setup fails", async () => {
|
||||
const client = createFakeClient();
|
||||
createOpenClawCodingToolsMock.mockImplementation(() => {
|
||||
throw new Error("tool setup failed");
|
||||
@@ -1430,38 +1729,8 @@ describe("runCodexAppServerSideQuestion", () => {
|
||||
|
||||
await expect(runCodexAppServerSideQuestion(sideParams())).rejects.toThrow("tool setup failed");
|
||||
|
||||
expect(client.notifications).toHaveLength(0);
|
||||
expect(client.requests).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("uses the app-server auth refresh request handler while the side thread is active", async () => {
|
||||
const client = createFakeClient();
|
||||
client.request.mockImplementation(async (method: string) => {
|
||||
if (method === "thread/fork") {
|
||||
await client.requests[0]?.({
|
||||
id: 1,
|
||||
method: "account/chatgptAuthTokens/refresh",
|
||||
});
|
||||
return threadResult("side-thread");
|
||||
}
|
||||
if (method === "thread/inject_items") {
|
||||
return {};
|
||||
}
|
||||
if (method === "turn/start") {
|
||||
queueMicrotask(() => client.emit(turnCompleted("side-thread", "turn-1", "Done.")));
|
||||
return turnStartResult("turn-1");
|
||||
}
|
||||
return {};
|
||||
});
|
||||
getSharedCodexAppServerClientMock.mockResolvedValue(client);
|
||||
|
||||
await runCodexAppServerSideQuestion(sideParams());
|
||||
|
||||
expect(refreshCodexAppServerAuthTokensMock).toHaveBeenCalledWith({
|
||||
agentDir: "/tmp/agent",
|
||||
authProfileId: "openai:work",
|
||||
config: {},
|
||||
});
|
||||
expect(client.notifications).toHaveLength(1);
|
||||
expect(client.requests).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("returns a clear setup error when there is no Codex parent thread", async () => {
|
||||
@@ -1486,6 +1755,23 @@ describe("runCodexAppServerSideQuestion", () => {
|
||||
await expect(runCodexAppServerSideQuestion(sideParams())).rejects.toThrow(
|
||||
"Codex /btw needs an active Codex thread. Send a normal message first, then try /btw again.",
|
||||
);
|
||||
expect(abandonSharedCodexAppServerClientMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("leaves late fork cleanup to the client when the response times out", async () => {
|
||||
const client = createFakeClient();
|
||||
client.request.mockImplementation(async (method: string) => {
|
||||
if (method === "thread/fork") {
|
||||
throw new Error("thread/fork timed out");
|
||||
}
|
||||
return {};
|
||||
});
|
||||
getSharedCodexAppServerClientMock.mockResolvedValue(client);
|
||||
|
||||
await expect(runCodexAppServerSideQuestion(sideParams())).rejects.toThrow(
|
||||
"thread/fork timed out",
|
||||
);
|
||||
expect(abandonSharedCodexAppServerClientMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("interrupts and unsubscribes the ephemeral thread on abort", async () => {
|
||||
@@ -1523,4 +1809,39 @@ describe("runCodexAppServerSideQuestion", () => {
|
||||
[["thread/unsubscribe", { threadId: "side-thread" }, { timeoutMs: 60_000 }]],
|
||||
);
|
||||
});
|
||||
|
||||
it("retires the client when side-thread interruption is not confirmed", async () => {
|
||||
const controller = new AbortController();
|
||||
const client = createFakeClient();
|
||||
client.request.mockImplementation(async (method: string) => {
|
||||
if (method === "thread/fork") {
|
||||
return threadResult("side-thread");
|
||||
}
|
||||
if (method === "thread/inject_items") {
|
||||
return {};
|
||||
}
|
||||
if (method === "turn/start") {
|
||||
queueMicrotask(() => controller.abort());
|
||||
return turnStartResult("turn-1");
|
||||
}
|
||||
if (method === "turn/interrupt") {
|
||||
throw new Error("interrupt timeout");
|
||||
}
|
||||
throw new Error(`unexpected request: ${method}`);
|
||||
});
|
||||
getSharedCodexAppServerClientMock.mockResolvedValue(client);
|
||||
|
||||
await expect(
|
||||
runCodexAppServerSideQuestion(
|
||||
sideParams({
|
||||
opts: { abortSignal: controller.signal },
|
||||
}),
|
||||
),
|
||||
).rejects.toThrow("Codex /btw was aborted.");
|
||||
expect(abandonSharedCodexAppServerClientMock).toHaveBeenCalledOnce();
|
||||
expect(releaseSharedCodexAppServerClientMock).not.toHaveBeenCalled();
|
||||
expect(client.request.mock.calls.some(([method]) => method === "thread/unsubscribe")).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,563 +1,67 @@
|
||||
// Codex tests cover startup binding plugin behavior.
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { readCodexAppServerBinding, writeCodexAppServerBinding } from "./session-binding.js";
|
||||
import { rotateOversizedCodexAppServerStartupBinding } from "./startup-binding.js";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { CodexAppServerThreadBinding } from "./session-binding.js";
|
||||
import { shouldRotateCodexAppServerStartupBinding, testing } from "./startup-binding.js";
|
||||
|
||||
function binding(
|
||||
currentTokens?: number,
|
||||
modelContextWindow = 100_000,
|
||||
): CodexAppServerThreadBinding {
|
||||
return {
|
||||
threadId: "thread-1",
|
||||
cwd: "/workspace",
|
||||
...(currentTokens === undefined
|
||||
? {}
|
||||
: { nativeContextUsage: { currentTokens }, modelContextWindow }),
|
||||
};
|
||||
}
|
||||
|
||||
function shouldRotate(
|
||||
current: CodexAppServerThreadBinding,
|
||||
overrides: Partial<Parameters<typeof shouldRotateCodexAppServerStartupBinding>[0]> = {},
|
||||
) {
|
||||
return shouldRotateCodexAppServerStartupBinding({
|
||||
binding: current,
|
||||
config: undefined,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
describe("Codex app-server startup binding", () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-startup-binding-"));
|
||||
it("rotates at the last terminal native token fuse", () => {
|
||||
expect(shouldRotate(binding(80_000))).toBe(true);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
it("reserves room for the projected turn", () => {
|
||||
expect(shouldRotate(binding(70_000), { projectedTurnTokens: 10_000 })).toBe(true);
|
||||
});
|
||||
|
||||
async function writeExistingBinding(
|
||||
sessionFile: string,
|
||||
workspaceDir: string,
|
||||
overrides: Partial<Parameters<typeof writeCodexAppServerBinding>[1]> = {},
|
||||
) {
|
||||
await writeCodexAppServerBinding(sessionFile, {
|
||||
threadId: "thread-existing",
|
||||
cwd: workspaceDir,
|
||||
model: "gpt-5.4-codex",
|
||||
modelProvider: "openai",
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
it("uses the smaller prepared model and agent context windows", () => {
|
||||
expect(shouldRotate(binding(60_000, 200_000), { contextWindowTokens: 75_000 })).toBe(true);
|
||||
});
|
||||
|
||||
async function writeSessionRecord(sessionFile: string, record: Record<string, unknown>) {
|
||||
await fs.mkdir(path.dirname(sessionFile), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(path.dirname(sessionFile), "sessions.json"),
|
||||
JSON.stringify({
|
||||
"agent:main:session-1": {
|
||||
sessionFile,
|
||||
...record,
|
||||
it("keeps a thread without a terminal usage snapshot", () => {
|
||||
expect(shouldRotate(binding())).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps a thread below the fuse", () => {
|
||||
expect(shouldRotate(binding(79_999))).toBe(false);
|
||||
});
|
||||
|
||||
it("honors configured reserve tokens and their floor", () => {
|
||||
expect(
|
||||
testing.resolveNativeThreadReserveTokens({
|
||||
agents: {
|
||||
defaults: {
|
||||
compaction: { reserveTokens: 5_000, reserveTokensFloor: 12_000 },
|
||||
},
|
||||
},
|
||||
} as never),
|
||||
).toBe(12_000);
|
||||
expect(
|
||||
testing.resolveNativeThreadTokenFuse({
|
||||
modelContextWindow: 100_000,
|
||||
reserveTokens: 12_000,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
it("does not use a default byte limit when maxActiveTranscriptBytes is unset", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const agentDir = path.join(tempDir, "agent");
|
||||
await writeExistingBinding(sessionFile, workspaceDir, { dynamicToolsFingerprint: "[]" });
|
||||
await writeSessionRecord(sessionFile, { totalTokens: 12_000 });
|
||||
const rolloutDir = path.join(agentDir, "codex-home", "sessions");
|
||||
await fs.mkdir(rolloutDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(rolloutDir, "rollout-thread-existing.jsonl"),
|
||||
"x".repeat(2_000_000),
|
||||
);
|
||||
|
||||
const binding = await rotateOversizedCodexAppServerStartupBinding({
|
||||
binding: await readCodexAppServerBinding(sessionFile),
|
||||
sessionFile,
|
||||
agentDir,
|
||||
config: {
|
||||
agents: {
|
||||
defaults: {
|
||||
compaction: {
|
||||
truncateAfterCompaction: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
});
|
||||
|
||||
expect(binding?.threadId).toBe("thread-existing");
|
||||
const savedBinding = await readCodexAppServerBinding(sessionFile);
|
||||
expect(savedBinding?.threadId).toBe("thread-existing");
|
||||
});
|
||||
|
||||
it("reuses the session record cache while sessions.json is unchanged", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const agentDir = path.join(tempDir, "agent");
|
||||
await writeExistingBinding(sessionFile, workspaceDir, { dynamicToolsFingerprint: "[]" });
|
||||
await writeSessionRecord(sessionFile, { totalTokens: 12_000 });
|
||||
const sessionsJson = path.join(path.dirname(sessionFile), "sessions.json");
|
||||
const readFileSpy = vi.spyOn(fs, "readFile");
|
||||
|
||||
for (let i = 0; i < 2; i += 1) {
|
||||
const binding = await rotateOversizedCodexAppServerStartupBinding({
|
||||
binding: await readCodexAppServerBinding(sessionFile),
|
||||
sessionFile,
|
||||
agentDir,
|
||||
config: undefined,
|
||||
});
|
||||
expect(binding?.threadId).toBe("thread-existing");
|
||||
}
|
||||
|
||||
const sessionStoreReads = readFileSpy.mock.calls.filter(
|
||||
([file]) => typeof file === "string" && file === sessionsJson,
|
||||
);
|
||||
expect(sessionStoreReads).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("checks native rollout token pressure under default compaction config", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const agentDir = path.join(tempDir, "agent");
|
||||
await writeExistingBinding(sessionFile, workspaceDir, { dynamicToolsFingerprint: "[]" });
|
||||
await writeSessionRecord(sessionFile, { totalTokens: 12_000 });
|
||||
const rolloutDir = path.join(agentDir, "codex-home", "sessions");
|
||||
await fs.mkdir(rolloutDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(rolloutDir, "rollout-thread-existing.jsonl"),
|
||||
`${JSON.stringify({
|
||||
payload: {
|
||||
type: "token_count",
|
||||
info: {
|
||||
last_token_usage: {
|
||||
total_tokens: 241_198,
|
||||
},
|
||||
model_context_window: 258_400,
|
||||
},
|
||||
},
|
||||
})}\n`,
|
||||
);
|
||||
|
||||
const binding = await rotateOversizedCodexAppServerStartupBinding({
|
||||
binding: await readCodexAppServerBinding(sessionFile),
|
||||
sessionFile,
|
||||
agentDir,
|
||||
config: undefined,
|
||||
});
|
||||
|
||||
expect(binding).toBeUndefined();
|
||||
const savedBinding = await readCodexAppServerBinding(sessionFile);
|
||||
expect(savedBinding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("caps the default native reserve so small context windows keep prompt budget", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const agentDir = path.join(tempDir, "agent");
|
||||
await writeExistingBinding(sessionFile, workspaceDir, { dynamicToolsFingerprint: "[]" });
|
||||
await writeSessionRecord(sessionFile, { totalTokens: 100 });
|
||||
const rolloutDir = path.join(agentDir, "codex-home", "sessions");
|
||||
await fs.mkdir(rolloutDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(rolloutDir, "rollout-thread-existing.jsonl"),
|
||||
`${JSON.stringify({
|
||||
payload: {
|
||||
type: "token_count",
|
||||
info: {
|
||||
last_token_usage: {
|
||||
total_tokens: 100,
|
||||
},
|
||||
model_context_window: 16_000,
|
||||
},
|
||||
},
|
||||
})}\n`,
|
||||
);
|
||||
|
||||
const binding = await rotateOversizedCodexAppServerStartupBinding({
|
||||
binding: await readCodexAppServerBinding(sessionFile),
|
||||
sessionFile,
|
||||
agentDir,
|
||||
config: undefined,
|
||||
});
|
||||
|
||||
expect(binding?.threadId).toBe("thread-existing");
|
||||
const savedBinding = await readCodexAppServerBinding(sessionFile);
|
||||
expect(savedBinding?.threadId).toBe("thread-existing");
|
||||
});
|
||||
|
||||
it("honors shorthand byte units for native rollout limits", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const agentDir = path.join(tempDir, "agent");
|
||||
await writeExistingBinding(sessionFile, workspaceDir, { dynamicToolsFingerprint: "[]" });
|
||||
await writeSessionRecord(sessionFile, { totalTokens: 12_000 });
|
||||
const rolloutDir = path.join(agentDir, "codex-home", "sessions");
|
||||
await fs.mkdir(rolloutDir, { recursive: true });
|
||||
await fs.writeFile(path.join(rolloutDir, "rollout-thread-existing.jsonl"), "x".repeat(2_000));
|
||||
|
||||
const binding = await rotateOversizedCodexAppServerStartupBinding({
|
||||
binding: await readCodexAppServerBinding(sessionFile),
|
||||
sessionFile,
|
||||
agentDir,
|
||||
config: {
|
||||
agents: {
|
||||
defaults: {
|
||||
compaction: {
|
||||
truncateAfterCompaction: true,
|
||||
maxActiveTranscriptBytes: "1k",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
});
|
||||
|
||||
expect(binding).toBeUndefined();
|
||||
const savedBinding = await readCodexAppServerBinding(sessionFile);
|
||||
expect(savedBinding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("honors custom Codex home rollout files for native rollout limits", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const agentDir = path.join(tempDir, "agent");
|
||||
const codexHome = path.join(tempDir, "custom-codex-home");
|
||||
await writeExistingBinding(sessionFile, workspaceDir, { dynamicToolsFingerprint: "[]" });
|
||||
await writeSessionRecord(sessionFile, { totalTokens: 12_000 });
|
||||
const rolloutDir = path.join(codexHome, "sessions");
|
||||
await fs.mkdir(rolloutDir, { recursive: true });
|
||||
await fs.writeFile(path.join(rolloutDir, "rollout-thread-existing.jsonl"), "x".repeat(2_000));
|
||||
|
||||
const binding = await rotateOversizedCodexAppServerStartupBinding({
|
||||
binding: await readCodexAppServerBinding(sessionFile),
|
||||
sessionFile,
|
||||
agentDir,
|
||||
codexHome,
|
||||
config: {
|
||||
agents: {
|
||||
defaults: {
|
||||
compaction: {
|
||||
truncateAfterCompaction: true,
|
||||
maxActiveTranscriptBytes: 1_000,
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
});
|
||||
|
||||
expect(binding).toBeUndefined();
|
||||
const savedBinding = await readCodexAppServerBinding(sessionFile);
|
||||
expect(savedBinding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uses current rollout token usage before cumulative usage", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const agentDir = path.join(tempDir, "agent");
|
||||
await writeExistingBinding(sessionFile, workspaceDir, { dynamicToolsFingerprint: "[]" });
|
||||
await writeSessionRecord(sessionFile, { totalTokens: 12_000 });
|
||||
const rolloutDir = path.join(agentDir, "codex-home", "sessions");
|
||||
await fs.mkdir(rolloutDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(rolloutDir, "rollout-thread-existing.jsonl"),
|
||||
`${JSON.stringify({
|
||||
payload: {
|
||||
type: "token_count",
|
||||
info: {
|
||||
total_token_usage: {
|
||||
total_tokens: 300_000,
|
||||
},
|
||||
last_token_usage: {
|
||||
total_tokens: 12_000,
|
||||
},
|
||||
},
|
||||
},
|
||||
})}\n`,
|
||||
);
|
||||
|
||||
const binding = await rotateOversizedCodexAppServerStartupBinding({
|
||||
binding: await readCodexAppServerBinding(sessionFile),
|
||||
sessionFile,
|
||||
agentDir,
|
||||
config: {
|
||||
agents: {
|
||||
defaults: {
|
||||
compaction: {
|
||||
truncateAfterCompaction: true,
|
||||
maxActiveTranscriptBytes: "1mb",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
});
|
||||
|
||||
expect(binding?.threadId).toBe("thread-existing");
|
||||
const savedBinding = await readCodexAppServerBinding(sessionFile);
|
||||
expect(savedBinding?.threadId).toBe("thread-existing");
|
||||
});
|
||||
|
||||
it("ignores stale session token totals for native rollout rotation", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const agentDir = path.join(tempDir, "agent");
|
||||
await writeExistingBinding(sessionFile, workspaceDir, { dynamicToolsFingerprint: "[]" });
|
||||
await writeSessionRecord(sessionFile, {
|
||||
totalTokens: 300_000,
|
||||
totalTokensFresh: false,
|
||||
});
|
||||
const rolloutDir = path.join(agentDir, "codex-home", "sessions");
|
||||
await fs.mkdir(rolloutDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(rolloutDir, "rollout-thread-existing.jsonl"),
|
||||
`${JSON.stringify({
|
||||
payload: {
|
||||
type: "token_count",
|
||||
info: {
|
||||
last_token_usage: {
|
||||
total_tokens: 12_000,
|
||||
},
|
||||
},
|
||||
},
|
||||
})}\n`,
|
||||
);
|
||||
|
||||
const binding = await rotateOversizedCodexAppServerStartupBinding({
|
||||
binding: await readCodexAppServerBinding(sessionFile),
|
||||
sessionFile,
|
||||
agentDir,
|
||||
config: {
|
||||
agents: {
|
||||
defaults: {
|
||||
compaction: {
|
||||
truncateAfterCompaction: true,
|
||||
maxActiveTranscriptBytes: "1mb",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
});
|
||||
|
||||
expect(binding?.threadId).toBe("thread-existing");
|
||||
const savedBinding = await readCodexAppServerBinding(sessionFile);
|
||||
expect(savedBinding?.threadId).toBe("thread-existing");
|
||||
});
|
||||
|
||||
it("clears native rollouts at Codex's reported model context window", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const agentDir = path.join(tempDir, "agent");
|
||||
await writeExistingBinding(sessionFile, workspaceDir, { dynamicToolsFingerprint: "[]" });
|
||||
await writeSessionRecord(sessionFile, { totalTokens: 12_000 });
|
||||
const rolloutDir = path.join(agentDir, "codex-home", "sessions");
|
||||
await fs.mkdir(rolloutDir, { recursive: true });
|
||||
const rolloutFile = path.join(rolloutDir, "rollout-thread-existing.jsonl");
|
||||
await fs.writeFile(
|
||||
rolloutFile,
|
||||
[
|
||||
JSON.stringify({
|
||||
payload: {
|
||||
type: "token_count",
|
||||
info: {
|
||||
last_token_usage: {
|
||||
total_tokens: 128_000,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
JSON.stringify({
|
||||
payload: {
|
||||
type: "token_count",
|
||||
info: {
|
||||
model_context_window: 128_000,
|
||||
},
|
||||
},
|
||||
}),
|
||||
].join("\n") + "\n",
|
||||
);
|
||||
const binding = await rotateOversizedCodexAppServerStartupBinding({
|
||||
binding: await readCodexAppServerBinding(sessionFile),
|
||||
sessionFile,
|
||||
agentDir,
|
||||
config: {
|
||||
agents: {
|
||||
defaults: {
|
||||
compaction: {
|
||||
truncateAfterCompaction: true,
|
||||
maxActiveTranscriptBytes: "1mb",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
});
|
||||
|
||||
expect(binding).toBeUndefined();
|
||||
const savedBinding = await readCodexAppServerBinding(sessionFile);
|
||||
expect(savedBinding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps native rollouts above the old guard when Codex still has context window headroom", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const agentDir = path.join(tempDir, "agent");
|
||||
await writeExistingBinding(sessionFile, workspaceDir, { dynamicToolsFingerprint: "[]" });
|
||||
await writeSessionRecord(sessionFile, { totalTokens: 12_000 });
|
||||
const rolloutDir = path.join(agentDir, "codex-home", "sessions");
|
||||
await fs.mkdir(rolloutDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(rolloutDir, "rollout-thread-existing.jsonl"),
|
||||
`${JSON.stringify({
|
||||
payload: {
|
||||
type: "token_count",
|
||||
info: {
|
||||
last_token_usage: {
|
||||
total_tokens: 86_000,
|
||||
},
|
||||
model_context_window: 272_000,
|
||||
},
|
||||
},
|
||||
})}\n`,
|
||||
);
|
||||
|
||||
const binding = await rotateOversizedCodexAppServerStartupBinding({
|
||||
binding: await readCodexAppServerBinding(sessionFile),
|
||||
sessionFile,
|
||||
agentDir,
|
||||
config: {
|
||||
agents: {
|
||||
defaults: {
|
||||
compaction: {
|
||||
truncateAfterCompaction: true,
|
||||
maxActiveTranscriptBytes: "1mb",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
});
|
||||
|
||||
expect(binding?.threadId).toBe("thread-existing");
|
||||
const savedBinding = await readCodexAppServerBinding(sessionFile);
|
||||
expect(savedBinding?.threadId).toBe("thread-existing");
|
||||
});
|
||||
|
||||
it("includes projected turn tokens in the native rollout pressure check", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const agentDir = path.join(tempDir, "agent");
|
||||
await writeExistingBinding(sessionFile, workspaceDir, { dynamicToolsFingerprint: "[]" });
|
||||
await writeSessionRecord(sessionFile, { totalTokens: 12_000 });
|
||||
const rolloutDir = path.join(agentDir, "codex-home", "sessions");
|
||||
await fs.mkdir(rolloutDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(rolloutDir, "rollout-thread-existing.jsonl"),
|
||||
`${JSON.stringify({
|
||||
payload: {
|
||||
type: "token_count",
|
||||
info: {
|
||||
last_token_usage: {
|
||||
total_tokens: 220_000,
|
||||
},
|
||||
model_context_window: 258_400,
|
||||
},
|
||||
},
|
||||
})}\n`,
|
||||
);
|
||||
|
||||
const binding = await rotateOversizedCodexAppServerStartupBinding({
|
||||
binding: await readCodexAppServerBinding(sessionFile),
|
||||
sessionFile,
|
||||
agentDir,
|
||||
config: undefined,
|
||||
projectedTurnTokens: 30_000,
|
||||
});
|
||||
|
||||
expect(binding).toBeUndefined();
|
||||
const savedBinding = await readCodexAppServerBinding(sessionFile);
|
||||
expect(savedBinding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uses the session context window when the native rollout omits its model window", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const agentDir = path.join(tempDir, "agent");
|
||||
await writeExistingBinding(sessionFile, workspaceDir, { dynamicToolsFingerprint: "[]" });
|
||||
await writeSessionRecord(sessionFile, { totalTokens: 12_000, contextTokens: 258_400 });
|
||||
const rolloutDir = path.join(agentDir, "codex-home", "sessions");
|
||||
await fs.mkdir(rolloutDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(rolloutDir, "rollout-thread-existing.jsonl"),
|
||||
`${JSON.stringify({
|
||||
payload: {
|
||||
type: "token_count",
|
||||
info: {
|
||||
last_token_usage: {
|
||||
total_tokens: 241_198,
|
||||
},
|
||||
},
|
||||
},
|
||||
})}\n`,
|
||||
);
|
||||
|
||||
const binding = await rotateOversizedCodexAppServerStartupBinding({
|
||||
binding: await readCodexAppServerBinding(sessionFile),
|
||||
sessionFile,
|
||||
agentDir,
|
||||
config: undefined,
|
||||
});
|
||||
|
||||
expect(binding).toBeUndefined();
|
||||
const savedBinding = await readCodexAppServerBinding(sessionFile);
|
||||
expect(savedBinding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("clears byte-oversized rollouts before reading their contents", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const agentDir = path.join(tempDir, "agent");
|
||||
await writeExistingBinding(sessionFile, workspaceDir, { dynamicToolsFingerprint: "[]" });
|
||||
await writeSessionRecord(sessionFile, { totalTokens: 12_000 });
|
||||
const rolloutDir = path.join(agentDir, "codex-home", "sessions");
|
||||
await fs.mkdir(rolloutDir, { recursive: true });
|
||||
const rolloutFile = path.join(rolloutDir, "rollout-thread-existing.jsonl");
|
||||
await fs.writeFile(rolloutFile, "x".repeat(2_000));
|
||||
const openSpy = vi.spyOn(fs, "open");
|
||||
|
||||
const binding = await rotateOversizedCodexAppServerStartupBinding({
|
||||
binding: await readCodexAppServerBinding(sessionFile),
|
||||
sessionFile,
|
||||
agentDir,
|
||||
config: {
|
||||
agents: {
|
||||
defaults: {
|
||||
compaction: {
|
||||
truncateAfterCompaction: true,
|
||||
maxActiveTranscriptBytes: 1_000,
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
});
|
||||
|
||||
expect(binding).toBeUndefined();
|
||||
expect(openSpy.mock.calls.some(([file]) => String(file) === rolloutFile)).toBe(false);
|
||||
const savedBinding = await readCodexAppServerBinding(sessionFile);
|
||||
expect(savedBinding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("clears native rollouts at the configured byte limit", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const agentDir = path.join(tempDir, "agent");
|
||||
await writeExistingBinding(sessionFile, workspaceDir, { dynamicToolsFingerprint: "[]" });
|
||||
await writeSessionRecord(sessionFile, { totalTokens: 12_000 });
|
||||
const rolloutDir = path.join(agentDir, "codex-home", "sessions");
|
||||
await fs.mkdir(rolloutDir, { recursive: true });
|
||||
await fs.writeFile(path.join(rolloutDir, "rollout-thread-existing.jsonl"), "x".repeat(1_000));
|
||||
|
||||
const binding = await rotateOversizedCodexAppServerStartupBinding({
|
||||
binding: await readCodexAppServerBinding(sessionFile),
|
||||
sessionFile,
|
||||
agentDir,
|
||||
config: {
|
||||
agents: {
|
||||
defaults: {
|
||||
compaction: {
|
||||
truncateAfterCompaction: true,
|
||||
maxActiveTranscriptBytes: 1_000,
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
});
|
||||
|
||||
expect(binding).toBeUndefined();
|
||||
const savedBinding = await readCodexAppServerBinding(sessionFile);
|
||||
expect(savedBinding).toBeUndefined();
|
||||
).toBe(88_000);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,258 +1,35 @@
|
||||
/**
|
||||
* Guards Codex app-server thread reuse during startup by rotating bindings when
|
||||
* native transcripts exceed byte or token budgets.
|
||||
*/
|
||||
import type { Dirent } from "node:fs";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
/** Decides whether a terminal Codex usage snapshot leaves room for the next turn. */
|
||||
import {
|
||||
embeddedAgentLog,
|
||||
type EmbeddedRunAttemptParams,
|
||||
} from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import { resolveCodexAppServerHomeDir } from "./auth-bridge.js";
|
||||
import { isJsonObject, type JsonValue } from "./protocol.js";
|
||||
import { clearCodexAppServerBinding, type CodexAppServerThreadBinding } from "./session-binding.js";
|
||||
import { isJsonObject } from "./protocol.js";
|
||||
import type { CodexAppServerThreadBinding } from "./session-binding.js";
|
||||
|
||||
// Codex owns proactive auto-compaction, but OpenClaw must not resume a native
|
||||
// thread that is already too close to the server-side window for the next turn.
|
||||
const CODEX_APP_SERVER_NATIVE_THREAD_FALLBACK_MAX_TOKENS = 300_000;
|
||||
const CODEX_APP_SERVER_NATIVE_THREAD_DEFAULT_RESERVE_TOKENS = 20_000;
|
||||
const CODEX_APP_SERVER_NATIVE_THREAD_MIN_PROMPT_BUDGET_TOKENS = 8_000;
|
||||
const CODEX_APP_SERVER_NATIVE_THREAD_MIN_PROMPT_BUDGET_RATIO = 0.5;
|
||||
const CODEX_APP_SERVER_BYTE_UNITS: Record<string, number> = {
|
||||
b: 1,
|
||||
k: 1024,
|
||||
kb: 1024,
|
||||
kib: 1024,
|
||||
m: 1024 * 1024,
|
||||
mb: 1024 * 1024,
|
||||
mib: 1024 * 1024,
|
||||
g: 1024 * 1024 * 1024,
|
||||
gb: 1024 * 1024 * 1024,
|
||||
gib: 1024 * 1024 * 1024,
|
||||
t: 1024 * 1024 * 1024 * 1024,
|
||||
tb: 1024 * 1024 * 1024 * 1024,
|
||||
tib: 1024 * 1024 * 1024 * 1024,
|
||||
};
|
||||
type CodexSessionRecordCacheEntry = {
|
||||
sessionsFile: string;
|
||||
mtimeMs: number;
|
||||
size: number;
|
||||
record: (Record<string, unknown> & { sessionKey: string }) | undefined;
|
||||
const DEFAULT_NATIVE_THREAD_MAX_TOKENS = 300_000;
|
||||
const DEFAULT_NATIVE_THREAD_RESERVE_TOKENS = 20_000;
|
||||
const MIN_PROMPT_BUDGET_TOKENS = 8_000;
|
||||
const MIN_PROMPT_BUDGET_RATIO = 0.5;
|
||||
const PROJECTED_CHARS_PER_TOKEN = 4;
|
||||
|
||||
export type CodexAppServerStartupTokenGuard = {
|
||||
contextWindowTokens?: number;
|
||||
projectedTurnTokens?: number;
|
||||
};
|
||||
|
||||
const codexSessionRecordCache = new Map<string, CodexSessionRecordCacheEntry>();
|
||||
|
||||
function parseCodexAppServerByteLimit(value: unknown): number | undefined {
|
||||
if (typeof value === "number" && Number.isFinite(value) && value > 0) {
|
||||
return Math.floor(value);
|
||||
}
|
||||
if (typeof value !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
const match = value.trim().match(/^(\d+(?:\.\d+)?)\s*([a-z]+)?$/i);
|
||||
if (!match) {
|
||||
return undefined;
|
||||
}
|
||||
const amount = Number(match[1]);
|
||||
if (!Number.isFinite(amount) || amount <= 0) {
|
||||
return undefined;
|
||||
}
|
||||
const unit = (match[2] ?? "b").toLowerCase();
|
||||
const multiplier = CODEX_APP_SERVER_BYTE_UNITS[unit];
|
||||
if (multiplier === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return Math.max(1, Math.round(amount * multiplier));
|
||||
}
|
||||
|
||||
async function listCodexAppServerRolloutFilesForThread(
|
||||
agentDir: string,
|
||||
threadId: string,
|
||||
codexHome?: string,
|
||||
): Promise<Array<{ path: string; bytes: number }>> {
|
||||
const resolvedAgentDir = path.resolve(agentDir);
|
||||
const resolvedCodexHome = codexHome?.trim()
|
||||
? path.resolve(codexHome)
|
||||
: resolveCodexAppServerHomeDir(resolvedAgentDir);
|
||||
const roots = [
|
||||
path.join(resolvedCodexHome, "sessions"),
|
||||
path.join(resolveCodexAppServerHomeDir(resolvedAgentDir), "sessions"),
|
||||
path.join(resolvedAgentDir, "agent", "codex-home", "sessions"),
|
||||
path.join(path.dirname(resolvedAgentDir), "codex-home", "sessions"),
|
||||
];
|
||||
const files: Array<{ path: string; bytes: number }> = [];
|
||||
const visited = new Set<string>();
|
||||
for (const root of roots) {
|
||||
if (visited.has(root)) {
|
||||
continue;
|
||||
}
|
||||
visited.add(root);
|
||||
const stack = [root];
|
||||
while (stack.length > 0) {
|
||||
const dir = stack.pop();
|
||||
if (!dir) {
|
||||
continue;
|
||||
}
|
||||
let entries: Dirent[];
|
||||
try {
|
||||
entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const file = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
stack.push(file);
|
||||
continue;
|
||||
}
|
||||
if (!entry.isFile() || !entry.name.endsWith(".jsonl") || !entry.name.includes(threadId)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
files.push({ path: file, bytes: (await fs.stat(file)).size });
|
||||
} catch {
|
||||
// Ignore rollout files that disappeared while the guard was scanning.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
async function readCodexSessionRecordForSessionFile(
|
||||
sessionFile: string,
|
||||
): Promise<(Record<string, unknown> & { sessionKey: string }) | undefined> {
|
||||
const sessionsFile = path.join(path.dirname(sessionFile), "sessions.json");
|
||||
const resolvedSessionFile = path.resolve(sessionFile);
|
||||
let stat: Awaited<ReturnType<typeof fs.stat>>;
|
||||
try {
|
||||
stat = await fs.stat(sessionsFile);
|
||||
} catch {
|
||||
codexSessionRecordCache.delete(resolvedSessionFile);
|
||||
return undefined;
|
||||
}
|
||||
const cached = codexSessionRecordCache.get(resolvedSessionFile);
|
||||
if (
|
||||
cached?.sessionsFile === sessionsFile &&
|
||||
cached.mtimeMs === stat.mtimeMs &&
|
||||
cached.size === stat.size
|
||||
) {
|
||||
return cached.record;
|
||||
}
|
||||
let store: JsonValue | undefined;
|
||||
try {
|
||||
store = JSON.parse(await fs.readFile(sessionsFile, "utf8")) as JsonValue;
|
||||
} catch {
|
||||
codexSessionRecordCache.delete(resolvedSessionFile);
|
||||
return undefined;
|
||||
}
|
||||
if (!isJsonObject(store)) {
|
||||
codexSessionRecordCache.delete(resolvedSessionFile);
|
||||
return undefined;
|
||||
}
|
||||
let found: (Record<string, unknown> & { sessionKey: string }) | undefined;
|
||||
for (const [sessionKey, record] of Object.entries(store)) {
|
||||
if (!isJsonObject(record) || typeof record.sessionFile !== "string") {
|
||||
continue;
|
||||
}
|
||||
if (path.resolve(record.sessionFile) !== resolvedSessionFile) {
|
||||
continue;
|
||||
}
|
||||
found = { sessionKey, ...record };
|
||||
break;
|
||||
}
|
||||
codexSessionRecordCache.set(resolvedSessionFile, {
|
||||
sessionsFile,
|
||||
mtimeMs: stat.mtimeMs,
|
||||
size: stat.size,
|
||||
record: found,
|
||||
});
|
||||
return found;
|
||||
}
|
||||
|
||||
type CodexAppServerRolloutTokenSnapshot = {
|
||||
totalTokens?: number;
|
||||
modelContextWindow?: number;
|
||||
};
|
||||
|
||||
async function readCodexAppServerRolloutTokenSnapshot(
|
||||
file: string,
|
||||
): Promise<CodexAppServerRolloutTokenSnapshot | undefined> {
|
||||
let handle: Awaited<ReturnType<typeof fs.open>>;
|
||||
try {
|
||||
handle = await fs.open(file, "r");
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
let snapshot: CodexAppServerRolloutTokenSnapshot | undefined;
|
||||
try {
|
||||
for await (const line of handle.readLines()) {
|
||||
const lineSnapshot = readCodexAppServerRolloutTokenSnapshotLine(line);
|
||||
if (lineSnapshot !== undefined) {
|
||||
snapshot ??= {};
|
||||
if (lineSnapshot.totalTokens !== undefined) {
|
||||
snapshot.totalTokens = lineSnapshot.totalTokens;
|
||||
}
|
||||
if (lineSnapshot.modelContextWindow !== undefined) {
|
||||
snapshot.modelContextWindow = lineSnapshot.modelContextWindow;
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
function readCodexAppServerRolloutTokenSnapshotLine(
|
||||
line: string,
|
||||
): CodexAppServerRolloutTokenSnapshot | undefined {
|
||||
if (!line.trim()) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(line) as JsonValue;
|
||||
const payload = isJsonObject(parsed) ? parsed.payload : undefined;
|
||||
const info =
|
||||
isJsonObject(payload) && payload.type === "token_count" && isJsonObject(payload.info)
|
||||
? payload.info
|
||||
: undefined;
|
||||
if (!info) {
|
||||
return undefined;
|
||||
}
|
||||
const usage = isJsonObject(info.last_token_usage)
|
||||
? info.last_token_usage
|
||||
: isJsonObject(info.total_token_usage)
|
||||
? info.total_token_usage
|
||||
: undefined;
|
||||
const value = usage?.total_tokens ?? usage?.totalTokens;
|
||||
const totalTokens = typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
||||
const windowValue = info.model_context_window ?? info.modelContextWindow;
|
||||
const modelContextWindow =
|
||||
typeof windowValue === "number" && Number.isFinite(windowValue) && windowValue > 0
|
||||
? Math.floor(windowValue)
|
||||
: undefined;
|
||||
const snapshot: CodexAppServerRolloutTokenSnapshot = {};
|
||||
if (totalTokens !== undefined) {
|
||||
snapshot.totalTokens = totalTokens;
|
||||
}
|
||||
if (modelContextWindow !== undefined) {
|
||||
snapshot.modelContextWindow = modelContextWindow;
|
||||
}
|
||||
return snapshot.totalTokens !== undefined || snapshot.modelContextWindow !== undefined
|
||||
? snapshot
|
||||
: undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
/** Conservative prompt-size estimate used by both harness and bound turns. */
|
||||
export function estimateCodexAppServerProjectedTurnTokens(params: {
|
||||
prompt: string;
|
||||
developerInstructions?: string;
|
||||
}): number {
|
||||
const inputChars = params.prompt.length + (params.developerInstructions?.length ?? 0);
|
||||
return Math.max(1, Math.ceil(inputChars / PROJECTED_CHARS_PER_TOKEN));
|
||||
}
|
||||
|
||||
function toNonNegativeInt(value: unknown): number | undefined {
|
||||
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
|
||||
return undefined;
|
||||
}
|
||||
return Math.floor(value);
|
||||
return typeof value === "number" && Number.isFinite(value) && value >= 0
|
||||
? Math.floor(value)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function readCompactionConfig(config: EmbeddedRunAttemptParams["config"] | undefined) {
|
||||
@@ -261,37 +38,28 @@ function readCompactionConfig(config: EmbeddedRunAttemptParams["config"] | undef
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function resolveCodexAppServerNativeThreadReserveTokens(
|
||||
function resolveNativeThreadReserveTokens(
|
||||
config: EmbeddedRunAttemptParams["config"] | undefined,
|
||||
): number {
|
||||
const compaction = readCompactionConfig(config);
|
||||
const reserveTokens = toNonNegativeInt(compaction?.reserveTokens);
|
||||
const reserveTokensFloor = toNonNegativeInt(compaction?.reserveTokensFloor);
|
||||
if (reserveTokens !== undefined) {
|
||||
return Math.max(
|
||||
reserveTokens,
|
||||
reserveTokensFloor ?? CODEX_APP_SERVER_NATIVE_THREAD_DEFAULT_RESERVE_TOKENS,
|
||||
);
|
||||
return Math.max(reserveTokens, reserveTokensFloor ?? DEFAULT_NATIVE_THREAD_RESERVE_TOKENS);
|
||||
}
|
||||
return reserveTokensFloor ?? CODEX_APP_SERVER_NATIVE_THREAD_DEFAULT_RESERVE_TOKENS;
|
||||
return reserveTokensFloor ?? DEFAULT_NATIVE_THREAD_RESERVE_TOKENS;
|
||||
}
|
||||
|
||||
function resolveCodexAppServerNativeThreadTokenFuse(params: {
|
||||
modelContextWindow: number | undefined;
|
||||
function resolveNativeThreadTokenFuse(params: {
|
||||
modelContextWindow?: number;
|
||||
reserveTokens: number;
|
||||
projectedTurnTokens?: number;
|
||||
}): number {
|
||||
const projectedTurnTokens =
|
||||
typeof params.projectedTurnTokens === "number" &&
|
||||
Number.isFinite(params.projectedTurnTokens) &&
|
||||
params.projectedTurnTokens > 0
|
||||
? Math.floor(params.projectedTurnTokens)
|
||||
: 0;
|
||||
const contextWindow =
|
||||
params.modelContextWindow ?? CODEX_APP_SERVER_NATIVE_THREAD_FALLBACK_MAX_TOKENS;
|
||||
const projectedTurnTokens = toNonNegativeInt(params.projectedTurnTokens) ?? 0;
|
||||
const contextWindow = params.modelContextWindow ?? DEFAULT_NATIVE_THREAD_MAX_TOKENS;
|
||||
const minPromptBudget = Math.min(
|
||||
CODEX_APP_SERVER_NATIVE_THREAD_MIN_PROMPT_BUDGET_TOKENS,
|
||||
Math.max(1, Math.floor(contextWindow * CODEX_APP_SERVER_NATIVE_THREAD_MIN_PROMPT_BUDGET_RATIO)),
|
||||
MIN_PROMPT_BUDGET_TOKENS,
|
||||
Math.max(1, Math.floor(contextWindow * MIN_PROMPT_BUDGET_RATIO)),
|
||||
);
|
||||
const effectiveReserveTokens = Math.min(
|
||||
params.reserveTokens,
|
||||
@@ -300,141 +68,50 @@ function resolveCodexAppServerNativeThreadTokenFuse(params: {
|
||||
return Math.max(1, contextWindow - effectiveReserveTokens - projectedTurnTokens);
|
||||
}
|
||||
|
||||
function maxFiniteNumber(values: Array<number | undefined>): number | undefined {
|
||||
const nums = values.filter(
|
||||
(value): value is number => typeof value === "number" && Number.isFinite(value),
|
||||
function minPositive(values: Array<number | undefined>): number | undefined {
|
||||
const present = values.filter(
|
||||
(value): value is number => typeof value === "number" && Number.isFinite(value) && value > 0,
|
||||
);
|
||||
if (nums.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return Math.max(...nums);
|
||||
return present.length > 0 ? Math.min(...present) : undefined;
|
||||
}
|
||||
|
||||
function minFiniteNumber(values: Array<number | undefined>): number | undefined {
|
||||
const nums = values.filter(
|
||||
(value): value is number => typeof value === "number" && Number.isFinite(value),
|
||||
);
|
||||
if (nums.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return Math.min(...nums);
|
||||
}
|
||||
|
||||
function hasContextEngineThreadBootstrapProjection(binding: CodexAppServerThreadBinding): boolean {
|
||||
return binding.contextEngine?.projection?.mode === "thread_bootstrap";
|
||||
}
|
||||
|
||||
/** Clears and drops a binding when the native Codex thread is too large to resume safely. */
|
||||
export async function rotateOversizedCodexAppServerStartupBinding(params: {
|
||||
/** Returns true when the last terminal usage snapshot leaves too little turn headroom. */
|
||||
export function shouldRotateCodexAppServerStartupBinding(params: {
|
||||
binding: CodexAppServerThreadBinding | undefined;
|
||||
sessionFile: string;
|
||||
agentDir: string;
|
||||
codexHome?: string;
|
||||
config: EmbeddedRunAttemptParams["config"] | undefined;
|
||||
contextEngineActive?: boolean;
|
||||
contextWindowTokens?: number;
|
||||
projectedTurnTokens?: number;
|
||||
}): Promise<CodexAppServerThreadBinding | undefined> {
|
||||
}): boolean {
|
||||
const binding = params.binding;
|
||||
if (!binding?.threadId) {
|
||||
return binding;
|
||||
const currentTokens = binding?.nativeContextUsage?.currentTokens;
|
||||
if (!binding?.threadId || currentTokens === undefined) {
|
||||
return false;
|
||||
}
|
||||
const sessionRecord = await readCodexSessionRecordForSessionFile(params.sessionFile);
|
||||
const rolloutFiles = await listCodexAppServerRolloutFilesForThread(
|
||||
params.agentDir,
|
||||
binding.threadId,
|
||||
params.codexHome,
|
||||
);
|
||||
const compaction = readCompactionConfig(params.config);
|
||||
const shouldDeferByteGuard =
|
||||
compaction?.truncateAfterCompaction === true &&
|
||||
params.contextEngineActive === true &&
|
||||
hasContextEngineThreadBootstrapProjection(binding);
|
||||
if (compaction?.truncateAfterCompaction === true && !shouldDeferByteGuard) {
|
||||
const maxBytes = parseCodexAppServerByteLimit(compaction.maxActiveTranscriptBytes);
|
||||
if (maxBytes !== undefined) {
|
||||
const oversizedFiles = rolloutFiles.filter((file) => file.bytes >= maxBytes);
|
||||
if (oversizedFiles.length > 0) {
|
||||
embeddedAgentLog.warn(
|
||||
"codex app-server native transcript exceeded active byte limit; starting a fresh thread",
|
||||
{
|
||||
threadId: binding.threadId,
|
||||
maxBytes,
|
||||
files: oversizedFiles.map((file) => ({ path: file.path, bytes: file.bytes })),
|
||||
},
|
||||
);
|
||||
await clearCodexAppServerBinding(params.sessionFile);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
const nativeTokenSnapshots = await Promise.all(
|
||||
rolloutFiles.map(async (file) => readCodexAppServerRolloutTokenSnapshot(file.path)),
|
||||
);
|
||||
const nativeTokens = maxFiniteNumber(
|
||||
nativeTokenSnapshots.map((snapshot) => snapshot?.totalTokens),
|
||||
);
|
||||
const nativeModelContextWindow = maxFiniteNumber(
|
||||
nativeTokenSnapshots.map((snapshot) => snapshot?.modelContextWindow),
|
||||
);
|
||||
const sessionModelContextWindow =
|
||||
typeof sessionRecord?.contextTokens === "number" &&
|
||||
Number.isFinite(sessionRecord.contextTokens) &&
|
||||
sessionRecord.contextTokens > 0
|
||||
? Math.floor(sessionRecord.contextTokens)
|
||||
: undefined;
|
||||
const reserveTokens = resolveCodexAppServerNativeThreadReserveTokens(params.config);
|
||||
const maxTokens = resolveCodexAppServerNativeThreadTokenFuse({
|
||||
modelContextWindow: minFiniteNumber([nativeModelContextWindow, sessionModelContextWindow]),
|
||||
const modelContextWindow = minPositive([binding.modelContextWindow, params.contextWindowTokens]);
|
||||
const reserveTokens = resolveNativeThreadReserveTokens(params.config);
|
||||
const maxTokens = resolveNativeThreadTokenFuse({
|
||||
modelContextWindow,
|
||||
reserveTokens,
|
||||
projectedTurnTokens: params.projectedTurnTokens,
|
||||
});
|
||||
const sessionTokens =
|
||||
sessionRecord?.totalTokensFresh !== false &&
|
||||
typeof sessionRecord?.totalTokens === "number" &&
|
||||
Number.isFinite(sessionRecord.totalTokens)
|
||||
? sessionRecord.totalTokens
|
||||
: undefined;
|
||||
const tokenCount = maxFiniteNumber([sessionTokens, nativeTokens]);
|
||||
if (tokenCount !== undefined && tokenCount >= maxTokens) {
|
||||
embeddedAgentLog.warn(
|
||||
"codex app-server native transcript exceeded active token limit; starting a fresh thread",
|
||||
{
|
||||
threadId: binding.threadId,
|
||||
maxTokens,
|
||||
sessionKey: sessionRecord?.sessionKey,
|
||||
sessionTokens,
|
||||
nativeTokens,
|
||||
nativeModelContextWindow,
|
||||
sessionModelContextWindow,
|
||||
reserveTokens,
|
||||
projectedTurnTokens: params.projectedTurnTokens,
|
||||
},
|
||||
);
|
||||
await clearCodexAppServerBinding(params.sessionFile);
|
||||
return undefined;
|
||||
if (currentTokens < maxTokens) {
|
||||
return false;
|
||||
}
|
||||
if (compaction?.truncateAfterCompaction !== true) {
|
||||
return binding;
|
||||
}
|
||||
if (shouldDeferByteGuard) {
|
||||
embeddedAgentLog.debug(
|
||||
"codex app-server deferring native transcript byte guard for context-engine thread bootstrap",
|
||||
{
|
||||
threadId: binding.threadId,
|
||||
engineId: binding.contextEngine?.engineId,
|
||||
epoch: binding.contextEngine?.projection?.epoch,
|
||||
fingerprint: binding.contextEngine?.projection?.fingerprint,
|
||||
},
|
||||
);
|
||||
return binding;
|
||||
}
|
||||
return binding;
|
||||
embeddedAgentLog.warn(
|
||||
"codex app-server thread usage left too little prompt headroom; starting a fresh thread",
|
||||
{
|
||||
threadId: binding.threadId,
|
||||
currentTokens,
|
||||
maxTokens,
|
||||
modelContextWindow,
|
||||
reserveTokens,
|
||||
projectedTurnTokens: params.projectedTurnTokens,
|
||||
},
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Internal sizing helpers exposed for startup-binding regression tests. */
|
||||
export const testing = {
|
||||
parseCodexAppServerByteLimit,
|
||||
readCodexAppServerRolloutTokenSnapshotLine,
|
||||
resolveCodexAppServerNativeThreadTokenFuse,
|
||||
resolveCodexAppServerNativeThreadReserveTokens,
|
||||
resolveNativeThreadReserveTokens,
|
||||
resolveNativeThreadTokenFuse,
|
||||
};
|
||||
|
||||
@@ -7,6 +7,47 @@ import { PassThrough, Writable } from "node:stream";
|
||||
import type { Model } from "openclaw/plugin-sdk/llm";
|
||||
import { vi } from "vitest";
|
||||
import { CodexAppServerClient } from "./client.js";
|
||||
import type {
|
||||
CodexAppServerClientLeaseFactory,
|
||||
CodexAppServerClientOptions,
|
||||
} from "./shared-client.js";
|
||||
|
||||
/** Naked-client injection contract confined to tests. */
|
||||
export type CodexTestAppServerClientFactory = (
|
||||
startOptions?: CodexAppServerClientOptions["startOptions"],
|
||||
authProfileId?: string,
|
||||
agentDir?: string,
|
||||
config?: CodexAppServerClientOptions["config"],
|
||||
options?: CodexAppServerClientOptions,
|
||||
) => Promise<CodexAppServerClient>;
|
||||
|
||||
/** Wraps a test client in the ownership contract required by production code. */
|
||||
export function adaptCodexTestClientFactory(
|
||||
factory: CodexTestAppServerClientFactory,
|
||||
): CodexAppServerClientLeaseFactory {
|
||||
return async (options) => ({
|
||||
client: await factory(
|
||||
options?.startOptions,
|
||||
options?.authProfileId ?? undefined,
|
||||
options?.agentDir,
|
||||
options?.config,
|
||||
options,
|
||||
),
|
||||
release: () => undefined,
|
||||
abandon: async () => undefined,
|
||||
});
|
||||
}
|
||||
|
||||
/** Completes lightweight request-only test doubles with the notification contract. */
|
||||
export function ensureCodexTestClientNotificationSurface(
|
||||
client: CodexAppServerClient,
|
||||
): CodexAppServerClient {
|
||||
const surface = client as unknown as {
|
||||
addNotificationHandler?: CodexAppServerClient["addNotificationHandler"];
|
||||
};
|
||||
surface.addNotificationHandler ??= () => () => undefined;
|
||||
return client;
|
||||
}
|
||||
|
||||
/** Builds a representative Codex-capable model fixture for app-server tests. */
|
||||
export function createCodexTestModel(provider = "openai", input = ["text"]): Model {
|
||||
|
||||
@@ -1,14 +1,49 @@
|
||||
// Codex tests cover thread lifecycle.binding plugin behavior.
|
||||
import path from "node:path";
|
||||
import { saveSessionStore } from "openclaw/plugin-sdk/session-store-runtime";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { CodexAppServerUnsafeSubscriptionError } from "./attempt-client-cleanup.js";
|
||||
import { CodexAppServerRpcError } from "./client.js";
|
||||
import {
|
||||
createParams,
|
||||
setupRunAttemptTestHooks,
|
||||
tempDir,
|
||||
threadStartResult,
|
||||
} from "./run-attempt-test-harness.js";
|
||||
import { readCodexAppServerBinding, writeCodexAppServerBinding } from "./session-binding.js";
|
||||
import { startOrResumeThread } from "./thread-lifecycle.js";
|
||||
import {
|
||||
readCodexAppServerBinding,
|
||||
registerCodexTestSessionIdentity,
|
||||
testCodexAppServerBindingStore,
|
||||
writeCodexAppServerBinding as writeCodexAppServerBindingImpl,
|
||||
} from "./session-binding.test-helpers.js";
|
||||
import { ensureCodexTestClientNotificationSurface } from "./test-support.js";
|
||||
import { startOrResumeThread as startOrResumeThreadImpl } from "./thread-lifecycle.js";
|
||||
|
||||
function startOrResumeThread(
|
||||
params: Omit<Parameters<typeof startOrResumeThreadImpl>[0], "bindingStore" | "abandonClient"> & {
|
||||
abandonClient?: () => Promise<void>;
|
||||
},
|
||||
) {
|
||||
registerCodexTestSessionIdentity(
|
||||
params.params.sessionFile,
|
||||
params.params.sessionId,
|
||||
params.params.sessionKey,
|
||||
);
|
||||
return startOrResumeThreadImpl({
|
||||
...params,
|
||||
client: ensureCodexTestClientNotificationSurface(params.client),
|
||||
abandonClient: params.abandonClient ?? (async () => undefined),
|
||||
bindingStore: testCodexAppServerBindingStore,
|
||||
});
|
||||
}
|
||||
|
||||
async function writeCodexAppServerBinding(
|
||||
sessionFile: string,
|
||||
binding: Parameters<typeof writeCodexAppServerBindingImpl>[1],
|
||||
): Promise<void> {
|
||||
registerCodexTestSessionIdentity(sessionFile, "session-1", "agent:main:session-1");
|
||||
await writeCodexAppServerBindingImpl(sessionFile, binding);
|
||||
}
|
||||
|
||||
function createThreadLifecycleAppServerOptions(): Parameters<
|
||||
typeof startOrResumeThread
|
||||
@@ -180,6 +215,47 @@ function createTwoCalendarAppPolicyContext() {
|
||||
setupRunAttemptTestHooks();
|
||||
|
||||
describe("Codex app-server thread lifecycle bindings", () => {
|
||||
it("reclaims an unloaded plugin's stale generation for the current session", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const storePath = path.join(tempDir, "sessions.json");
|
||||
const sessionKey = "agent:main:telegram:chat-1";
|
||||
registerCodexTestSessionIdentity(sessionFile, "session-old", sessionKey);
|
||||
await writeCodexAppServerBindingImpl(sessionFile, {
|
||||
threadId: "thread-old",
|
||||
cwd: workspaceDir,
|
||||
});
|
||||
await saveSessionStore(storePath, {
|
||||
[sessionKey]: { sessionId: "session-new", updatedAt: Date.now() },
|
||||
});
|
||||
const params = {
|
||||
...createParams(sessionFile, workspaceDir),
|
||||
sessionId: "session-new",
|
||||
sessionKey,
|
||||
config: { session: { store: storePath } },
|
||||
};
|
||||
const request = vi.fn(async (method: string) => {
|
||||
if (method === "thread/start") {
|
||||
return threadStartResult("thread-new");
|
||||
}
|
||||
throw new Error(`unexpected method: ${method}`);
|
||||
});
|
||||
|
||||
const binding = await startOrResumeThread({
|
||||
client: { request } as never,
|
||||
params,
|
||||
cwd: workspaceDir,
|
||||
dynamicTools: [],
|
||||
appServer: createThreadLifecycleAppServerOptions(),
|
||||
});
|
||||
|
||||
expect(binding).toMatchObject({ threadId: "thread-new", lifecycle: { action: "started" } });
|
||||
expect(request).not.toHaveBeenCalledWith("thread/resume", expect.anything(), expect.anything());
|
||||
await expect(readCodexAppServerBinding(sessionFile)).resolves.toMatchObject({
|
||||
threadId: "thread-new",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not write a binding when thread start resolves after abort", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
@@ -216,6 +292,179 @@ describe("Codex app-server thread lifecycle bindings", () => {
|
||||
await expect(readCodexAppServerBinding(sessionFile)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not release a resume subscription when the signal is already aborted", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
await writeCodexAppServerBinding(sessionFile, {
|
||||
threadId: "thread-existing",
|
||||
cwd: workspaceDir,
|
||||
});
|
||||
const abortController = new AbortController();
|
||||
abortController.abort("test_abort");
|
||||
const request = vi.fn();
|
||||
const reserveResumeThread = vi.fn();
|
||||
|
||||
await expect(
|
||||
startOrResumeThread({
|
||||
client: { request } as never,
|
||||
params: createParams(sessionFile, workspaceDir),
|
||||
cwd: workspaceDir,
|
||||
dynamicTools: [],
|
||||
appServer: createThreadLifecycleAppServerOptions(),
|
||||
signal: abortController.signal,
|
||||
reserveResumeThread,
|
||||
}),
|
||||
).rejects.toThrow("test_abort");
|
||||
|
||||
expect(reserveResumeThread).not.toHaveBeenCalled();
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("unsubscribes an orphaned fresh thread when another binding wins the commit", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const params = createParams(sessionFile, workspaceDir);
|
||||
const request = vi.fn(async (method: string, _requestParams?: unknown) => {
|
||||
if (method === "thread/start") {
|
||||
await writeCodexAppServerBinding(sessionFile, {
|
||||
threadId: "thread-winner",
|
||||
cwd: workspaceDir,
|
||||
});
|
||||
return threadStartResult("thread-orphan");
|
||||
}
|
||||
if (method === "thread/unsubscribe") {
|
||||
return {};
|
||||
}
|
||||
throw new Error(`unexpected method: ${method}`);
|
||||
});
|
||||
|
||||
await expect(
|
||||
startOrResumeThread({
|
||||
client: { request } as never,
|
||||
params,
|
||||
cwd: workspaceDir,
|
||||
dynamicTools: [],
|
||||
appServer: createThreadLifecycleAppServerOptions(),
|
||||
}),
|
||||
).rejects.toThrow("binding changed while committing a fresh thread");
|
||||
expect(request).toHaveBeenCalledWith(
|
||||
"thread/unsubscribe",
|
||||
{ threadId: "thread-orphan" },
|
||||
{ timeoutMs: 5_000 },
|
||||
);
|
||||
await expect(readCodexAppServerBinding(sessionFile)).resolves.toMatchObject({
|
||||
threadId: "thread-winner",
|
||||
});
|
||||
});
|
||||
|
||||
it("marks a fresh client unsafe when orphan cleanup cannot be confirmed", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const params = createParams(sessionFile, workspaceDir);
|
||||
const request = vi.fn(async (method: string) => {
|
||||
if (method === "thread/start") {
|
||||
await writeCodexAppServerBinding(sessionFile, {
|
||||
threadId: "thread-winner",
|
||||
cwd: workspaceDir,
|
||||
});
|
||||
return threadStartResult("thread-orphan");
|
||||
}
|
||||
if (method === "thread/unsubscribe") {
|
||||
throw new Error("unsubscribe failed");
|
||||
}
|
||||
throw new Error(`unexpected method: ${method}`);
|
||||
});
|
||||
|
||||
await expect(
|
||||
startOrResumeThread({
|
||||
client: { request } as never,
|
||||
params,
|
||||
cwd: workspaceDir,
|
||||
dynamicTools: [],
|
||||
appServer: createThreadLifecycleAppServerOptions(),
|
||||
}),
|
||||
).rejects.toBeInstanceOf(CodexAppServerUnsafeSubscriptionError);
|
||||
});
|
||||
|
||||
it("retires a fresh client when its subscription cannot be identified", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const abandonClient = vi.fn(async () => undefined);
|
||||
const request = vi.fn(async (method: string) => {
|
||||
if (method === "thread/start") {
|
||||
return {};
|
||||
}
|
||||
throw new Error(`unexpected method: ${method}`);
|
||||
});
|
||||
|
||||
await expect(
|
||||
startOrResumeThread({
|
||||
client: { request } as never,
|
||||
abandonClient,
|
||||
params: createParams(sessionFile, workspaceDir),
|
||||
cwd: workspaceDir,
|
||||
dynamicTools: [],
|
||||
appServer: createThreadLifecycleAppServerOptions(),
|
||||
}),
|
||||
).rejects.toBeInstanceOf(CodexAppServerUnsafeSubscriptionError);
|
||||
|
||||
expect(abandonClient).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("marks a resumed client unsafe when subscription cleanup cannot be confirmed", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
await writeCodexAppServerBinding(sessionFile, {
|
||||
threadId: "thread-existing",
|
||||
cwd: workspaceDir,
|
||||
});
|
||||
const request = vi.fn(async (method: string) => {
|
||||
if (method === "thread/resume") {
|
||||
await writeCodexAppServerBinding(sessionFile, {
|
||||
threadId: "thread-winner",
|
||||
cwd: workspaceDir,
|
||||
});
|
||||
return threadStartResult("thread-existing");
|
||||
}
|
||||
if (method === "thread/unsubscribe") {
|
||||
throw new Error("unsubscribe failed");
|
||||
}
|
||||
throw new Error(`unexpected method: ${method}`);
|
||||
});
|
||||
|
||||
await expect(
|
||||
startOrResumeThread({
|
||||
client: { request } as never,
|
||||
params: createParams(sessionFile, workspaceDir),
|
||||
cwd: workspaceDir,
|
||||
dynamicTools: [],
|
||||
appServer: createThreadLifecycleAppServerOptions(),
|
||||
}),
|
||||
).rejects.toBeInstanceOf(CodexAppServerUnsafeSubscriptionError);
|
||||
});
|
||||
|
||||
it("does not resume a binding during a fresh-only replacement", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
await writeCodexAppServerBinding(sessionFile, {
|
||||
threadId: "thread-concurrent",
|
||||
cwd: workspaceDir,
|
||||
});
|
||||
const request = vi.fn();
|
||||
|
||||
await expect(
|
||||
startOrResumeThread({
|
||||
client: { request } as never,
|
||||
params: createParams(sessionFile, workspaceDir),
|
||||
cwd: workspaceDir,
|
||||
dynamicTools: [],
|
||||
appServer: createThreadLifecycleAppServerOptions(),
|
||||
freshStartOnly: true,
|
||||
}),
|
||||
).rejects.toThrow("binding changed while starting a replacement thread");
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("resumes a bound Codex thread when only dynamic tool descriptions change", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
@@ -254,13 +503,268 @@ describe("Codex app-server thread lifecycle bindings", () => {
|
||||
expect(request.mock.calls.map(([method]) => method)).toEqual(["thread/start", "thread/resume"]);
|
||||
});
|
||||
|
||||
it("merges resume-owned fields without dropping preferences or a concurrent patch", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const params = createParams(sessionFile, workspaceDir);
|
||||
await writeCodexAppServerBinding(sessionFile, {
|
||||
threadId: "thread-existing",
|
||||
cwd: workspaceDir,
|
||||
approvalPolicy: "never",
|
||||
sandbox: "workspace-write",
|
||||
serviceTier: "flex",
|
||||
model: "gpt-5.4-codex",
|
||||
modelProvider: "openai",
|
||||
nativeContextUsage: { currentTokens: 90_000 },
|
||||
modelContextWindow: 258_400,
|
||||
});
|
||||
const request = vi.fn(async (method: string) => {
|
||||
if (method !== "thread/resume") {
|
||||
throw new Error(`unexpected method: ${method}`);
|
||||
}
|
||||
await testCodexAppServerBindingStore.mutate(
|
||||
{
|
||||
kind: "session",
|
||||
agentId: "main",
|
||||
sessionId: params.sessionId,
|
||||
sessionKey: params.sessionKey,
|
||||
},
|
||||
{
|
||||
kind: "patch",
|
||||
threadId: "thread-existing",
|
||||
patch: { serviceTier: "priority" },
|
||||
},
|
||||
);
|
||||
return threadStartResult("thread-existing");
|
||||
});
|
||||
|
||||
const binding = await startOrResumeThread({
|
||||
client: { request } as never,
|
||||
params,
|
||||
cwd: workspaceDir,
|
||||
dynamicTools: [],
|
||||
appServer: createThreadLifecycleAppServerOptions(),
|
||||
});
|
||||
|
||||
expect(binding).toMatchObject({
|
||||
threadId: "thread-existing",
|
||||
approvalPolicy: "never",
|
||||
sandbox: "workspace-write",
|
||||
serviceTier: "priority",
|
||||
lifecycle: { action: "resumed" },
|
||||
});
|
||||
const stored = await readCodexAppServerBinding(sessionFile);
|
||||
expect(stored).toMatchObject({
|
||||
approvalPolicy: "never",
|
||||
sandbox: "workspace-write",
|
||||
serviceTier: "priority",
|
||||
nativeContextUsage: { currentTokens: 90_000 },
|
||||
modelContextWindow: 258_400,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a resume when startup state was derived from a replaced binding", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
await writeCodexAppServerBinding(sessionFile, {
|
||||
threadId: "thread-replaced",
|
||||
cwd: workspaceDir,
|
||||
model: "gpt-5.4-codex",
|
||||
modelProvider: "openai",
|
||||
dynamicToolsFingerprint: "[]",
|
||||
});
|
||||
const request = vi.fn();
|
||||
|
||||
await expect(
|
||||
startOrResumeThread({
|
||||
client: { request } as never,
|
||||
params: createParams(sessionFile, workspaceDir),
|
||||
cwd: workspaceDir,
|
||||
dynamicTools: [],
|
||||
appServer: createThreadLifecycleAppServerOptions(),
|
||||
expectedResumeThreadId: "thread-startup",
|
||||
}),
|
||||
).rejects.toThrow("Codex thread binding changed during startup");
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rotates a model-changed binding before trusting its usage snapshot", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
await writeCodexAppServerBinding(sessionFile, {
|
||||
threadId: "thread-existing",
|
||||
cwd: workspaceDir,
|
||||
model: "gpt-old",
|
||||
modelProvider: "openai",
|
||||
nativeContextUsage: { currentTokens: 149_000 },
|
||||
modelContextWindow: 150_000,
|
||||
});
|
||||
const request = vi.fn(async (method: string) => {
|
||||
if (method !== "thread/start") {
|
||||
throw new Error(`unexpected method: ${method}`);
|
||||
}
|
||||
return threadStartResult("thread-fresh");
|
||||
});
|
||||
|
||||
const binding = await startOrResumeThread({
|
||||
client: { request } as never,
|
||||
params: createParams(sessionFile, workspaceDir),
|
||||
cwd: workspaceDir,
|
||||
dynamicTools: [],
|
||||
appServer: createThreadLifecycleAppServerOptions(),
|
||||
startupTokenGuard: { contextWindowTokens: 150_000, projectedTurnTokens: 1_000 },
|
||||
});
|
||||
|
||||
expect(request.mock.calls.map(([method]) => method)).toEqual(["thread/start"]);
|
||||
expect(binding).toMatchObject({
|
||||
threadId: "thread-fresh",
|
||||
model: "gpt-5.4-codex",
|
||||
lifecycle: { action: "started" },
|
||||
});
|
||||
await expect(readCodexAppServerBinding(sessionFile)).resolves.toMatchObject({
|
||||
threadId: "thread-fresh",
|
||||
model: "gpt-5.4-codex",
|
||||
});
|
||||
});
|
||||
|
||||
it("rotates a terminal usage snapshot before resuming", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
await writeCodexAppServerBinding(sessionFile, {
|
||||
threadId: "thread-existing",
|
||||
cwd: workspaceDir,
|
||||
model: "gpt-5.4-codex",
|
||||
modelProvider: "openai",
|
||||
dynamicToolsFingerprint: "[]",
|
||||
nativeContextUsage: { currentTokens: 95_000 },
|
||||
modelContextWindow: 100_000,
|
||||
});
|
||||
const request = vi.fn(async (method: string) => {
|
||||
if (method === "thread/resume") {
|
||||
return threadStartResult("thread-existing");
|
||||
}
|
||||
if (method === "thread/unsubscribe") {
|
||||
return {};
|
||||
}
|
||||
if (method === "thread/start") {
|
||||
return threadStartResult("thread-new");
|
||||
}
|
||||
throw new Error(`unexpected method: ${method}`);
|
||||
});
|
||||
|
||||
const binding = await startOrResumeThread({
|
||||
client: { request } as never,
|
||||
params: createParams(sessionFile, workspaceDir),
|
||||
cwd: workspaceDir,
|
||||
dynamicTools: [],
|
||||
appServer: createThreadLifecycleAppServerOptions(),
|
||||
startupTokenGuard: { contextWindowTokens: 100_000, projectedTurnTokens: 1_000 },
|
||||
});
|
||||
|
||||
expect(request.mock.calls.map(([method]) => method)).toEqual([
|
||||
"thread/resume",
|
||||
"thread/unsubscribe",
|
||||
"thread/start",
|
||||
]);
|
||||
expect(binding.threadId).toBe("thread-new");
|
||||
await expect(readCodexAppServerBinding(sessionFile)).resolves.toMatchObject({
|
||||
threadId: "thread-new",
|
||||
});
|
||||
});
|
||||
|
||||
it("rotates an active resumed thread when its terminal snapshot is over the fuse", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
await writeCodexAppServerBinding(sessionFile, {
|
||||
threadId: "thread-existing",
|
||||
cwd: workspaceDir,
|
||||
model: "gpt-5.4-codex",
|
||||
modelProvider: "openai",
|
||||
dynamicToolsFingerprint: "[]",
|
||||
nativeContextUsage: { currentTokens: 95_000 },
|
||||
modelContextWindow: 100_000,
|
||||
});
|
||||
const request = vi.fn(async (method: string) => {
|
||||
if (method === "thread/resume") {
|
||||
const response = threadStartResult("thread-existing");
|
||||
return {
|
||||
...response,
|
||||
thread: {
|
||||
...response.thread,
|
||||
status: { type: "active", activeFlags: [] },
|
||||
},
|
||||
};
|
||||
}
|
||||
if (method === "thread/unsubscribe") {
|
||||
return {};
|
||||
}
|
||||
if (method === "thread/start") {
|
||||
return threadStartResult("thread-new");
|
||||
}
|
||||
throw new Error(`unexpected method: ${method}`);
|
||||
});
|
||||
|
||||
const binding = await startOrResumeThread({
|
||||
client: { request } as never,
|
||||
params: createParams(sessionFile, workspaceDir),
|
||||
cwd: workspaceDir,
|
||||
dynamicTools: [],
|
||||
appServer: createThreadLifecycleAppServerOptions(),
|
||||
startupTokenGuard: { contextWindowTokens: 100_000, projectedTurnTokens: 1_000 },
|
||||
});
|
||||
|
||||
expect(request.mock.calls.map(([method]) => method)).toEqual([
|
||||
"thread/resume",
|
||||
"thread/unsubscribe",
|
||||
"thread/start",
|
||||
]);
|
||||
expect(binding.threadId).toBe("thread-new");
|
||||
await expect(readCodexAppServerBinding(sessionFile)).resolves.toMatchObject({
|
||||
threadId: "thread-new",
|
||||
});
|
||||
});
|
||||
|
||||
it("resumes without polling when no terminal usage snapshot exists", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
await writeCodexAppServerBinding(sessionFile, {
|
||||
threadId: "thread-existing",
|
||||
cwd: workspaceDir,
|
||||
model: "gpt-5.4-codex",
|
||||
modelProvider: "openai",
|
||||
dynamicToolsFingerprint: "[]",
|
||||
});
|
||||
const requests: unknown[] = [];
|
||||
const request = vi.fn(async (method: string, requestParams?: unknown) => {
|
||||
if (method !== "thread/resume") {
|
||||
throw new Error(`unexpected method: ${method}`);
|
||||
}
|
||||
requests.push(requestParams);
|
||||
return threadStartResult("thread-existing");
|
||||
});
|
||||
const client = { request } as never;
|
||||
const binding = await startOrResumeThread({
|
||||
client,
|
||||
params: createParams(sessionFile, workspaceDir),
|
||||
cwd: workspaceDir,
|
||||
dynamicTools: [],
|
||||
appServer: createThreadLifecycleAppServerOptions(),
|
||||
startupTokenGuard: { contextWindowTokens: 100_000, projectedTurnTokens: 1_000 },
|
||||
});
|
||||
|
||||
expect(requests).toEqual([
|
||||
expect.objectContaining({ threadId: "thread-existing", excludeTurns: true }),
|
||||
]);
|
||||
expect(binding.threadId).toBe("thread-existing");
|
||||
});
|
||||
|
||||
it("keeps the bound local provider when recoverable resume failure starts a fresh thread", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
await writeCodexAppServerBinding(sessionFile, {
|
||||
threadId: "thread-existing",
|
||||
cwd: workspaceDir,
|
||||
model: "local-model",
|
||||
model: "local-model-2",
|
||||
modelProvider: "lmstudio",
|
||||
approvalPolicy: "on-request",
|
||||
sandbox: "workspace-write",
|
||||
@@ -271,7 +775,10 @@ describe("Codex app-server thread lifecycle bindings", () => {
|
||||
const appServer = createThreadLifecycleAppServerOptions();
|
||||
const request = vi.fn(async (method: string, _requestParams?: unknown) => {
|
||||
if (method === "thread/resume") {
|
||||
throw new Error("stale thread");
|
||||
throw new CodexAppServerRpcError(
|
||||
{ code: -32_000, message: "stale thread" },
|
||||
"thread/resume",
|
||||
);
|
||||
}
|
||||
if (method === "thread/start") {
|
||||
const response = threadStartResult("thread-new");
|
||||
@@ -280,6 +787,9 @@ describe("Codex app-server thread lifecycle bindings", () => {
|
||||
response.thread.modelProvider = "lmstudio";
|
||||
return response;
|
||||
}
|
||||
if (method === "thread/unsubscribe") {
|
||||
return {};
|
||||
}
|
||||
throw new Error(`unexpected method: ${method}`);
|
||||
});
|
||||
|
||||
@@ -294,13 +804,49 @@ describe("Codex app-server thread lifecycle bindings", () => {
|
||||
const startParams = request.mock.calls.find(([method]) => method === "thread/start")?.[1] as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
expect(request.mock.calls.map(([method]) => method)).toEqual(["thread/resume", "thread/start"]);
|
||||
expect(request.mock.calls.map(([method]) => method)).toEqual([
|
||||
"thread/resume",
|
||||
"thread/unsubscribe",
|
||||
"thread/start",
|
||||
]);
|
||||
expect(startParams?.model).toBe("local-model-2");
|
||||
expect(startParams?.modelProvider).toBe("lmstudio");
|
||||
expect(binding.threadId).toBe("thread-new");
|
||||
expect(binding.modelProvider).toBe("lmstudio");
|
||||
});
|
||||
|
||||
it("rejects a mismatched resume without reusing the corrupted connection", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
await writeCodexAppServerBinding(sessionFile, {
|
||||
threadId: "thread-existing",
|
||||
cwd: workspaceDir,
|
||||
});
|
||||
const request = vi.fn(async (method: string, _requestParams?: unknown) => {
|
||||
if (method === "thread/resume") {
|
||||
return threadStartResult("thread-other");
|
||||
}
|
||||
throw new Error(`unexpected method: ${method}`);
|
||||
});
|
||||
|
||||
await expect(
|
||||
startOrResumeThread({
|
||||
client: { request } as never,
|
||||
params: createParams(sessionFile, workspaceDir),
|
||||
cwd: workspaceDir,
|
||||
dynamicTools: [],
|
||||
appServer: createThreadLifecycleAppServerOptions(),
|
||||
}),
|
||||
).rejects.toThrow("Codex thread/resume returned thread-other for thread-existing");
|
||||
|
||||
expect(request.mock.calls.map(([method, requestParams]) => [method, requestParams])).toEqual([
|
||||
["thread/resume", expect.objectContaining({ threadId: "thread-existing" })],
|
||||
]);
|
||||
await expect(readCodexAppServerBinding(sessionFile)).resolves.toMatchObject({
|
||||
threadId: "thread-existing",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the bound local provider when stale fingerprints force a fresh thread", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
@@ -404,7 +950,6 @@ describe("Codex app-server thread lifecycle bindings", () => {
|
||||
}
|
||||
throw new Error(`unexpected method: ${method}`);
|
||||
});
|
||||
|
||||
await startOrResumeThread({
|
||||
client: { request } as never,
|
||||
params,
|
||||
@@ -494,6 +1039,7 @@ describe("Codex app-server thread lifecycle bindings", () => {
|
||||
expect(binding.threadId).toBe("thread-fresh");
|
||||
expect(binding.lifecycle).toEqual({
|
||||
action: "started",
|
||||
activeModelProvider: "openai",
|
||||
rotatedContextEngineBinding: true,
|
||||
});
|
||||
expect(request.mock.calls.map(([method]) => method)).toEqual(["thread/start"]);
|
||||
@@ -543,7 +1089,7 @@ describe("Codex app-server thread lifecycle bindings", () => {
|
||||
});
|
||||
|
||||
expect(binding.threadId).toBe("thread-existing");
|
||||
expect(binding.lifecycle).toEqual({ action: "resumed" });
|
||||
expect(binding.lifecycle).toEqual({ action: "resumed", activeModelProvider: "openai" });
|
||||
expect(request.mock.calls.map(([method]) => method)).toEqual(["thread/resume"]);
|
||||
});
|
||||
|
||||
@@ -583,6 +1129,7 @@ describe("Codex app-server thread lifecycle bindings", () => {
|
||||
expect(binding.threadId).toBe("thread-fresh");
|
||||
expect(binding.lifecycle).toEqual({
|
||||
action: "started",
|
||||
activeModelProvider: "openai",
|
||||
rotatedContextEngineBinding: true,
|
||||
});
|
||||
expect(request.mock.calls.map(([method]) => method)).toEqual(["thread/start"]);
|
||||
@@ -639,6 +1186,7 @@ describe("Codex app-server thread lifecycle bindings", () => {
|
||||
expect(binding.threadId).toBe("thread-fresh");
|
||||
expect(binding.lifecycle).toEqual({
|
||||
action: "started",
|
||||
activeModelProvider: "openai",
|
||||
rotatedContextEngineBinding: true,
|
||||
});
|
||||
expect(request.mock.calls.map(([method]) => method)).toEqual(["thread/start"]);
|
||||
@@ -820,6 +1368,7 @@ describe("Codex app-server thread lifecycle bindings", () => {
|
||||
}
|
||||
throw new Error(`unexpected method: ${method}`);
|
||||
});
|
||||
const abandonClient = vi.fn(async () => undefined);
|
||||
|
||||
await expect(
|
||||
startOrResumeThread({
|
||||
@@ -828,10 +1377,12 @@ describe("Codex app-server thread lifecycle bindings", () => {
|
||||
cwd: workspaceDir,
|
||||
dynamicTools: [],
|
||||
appServer,
|
||||
abandonClient,
|
||||
}),
|
||||
).rejects.toThrow("codex app-server client is closed");
|
||||
|
||||
expect(request.mock.calls.map(([method]) => method)).toEqual(["thread/resume"]);
|
||||
expect(abandonClient).toHaveBeenCalledOnce();
|
||||
const binding = await readCodexAppServerBinding(sessionFile);
|
||||
expect(binding?.threadId).toBe("thread-existing");
|
||||
});
|
||||
|
||||
@@ -5,7 +5,12 @@ import path from "node:path";
|
||||
import type { EmbeddedRunAttemptParams } from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { CODEX_GPT5_BEHAVIOR_CONTRACT } from "../../prompt-overlay.js";
|
||||
import { createCodexTestModel } from "./test-support.js";
|
||||
import {
|
||||
resetCodexTestBindingStore,
|
||||
registerCodexTestSessionIdentity,
|
||||
testCodexAppServerBindingStore,
|
||||
} from "./session-binding.test-helpers.js";
|
||||
import { createCodexTestModel, ensureCodexTestClientNotificationSurface } from "./test-support.js";
|
||||
import {
|
||||
buildDeveloperInstructions,
|
||||
buildTurnCollaborationMode,
|
||||
@@ -16,10 +21,28 @@ import {
|
||||
formatCodexThreadLifecycleTimingSummary,
|
||||
resolveReasoningEffort,
|
||||
shouldWarnCodexThreadLifecycleTimingSummary,
|
||||
startOrResumeThread,
|
||||
startOrResumeThread as startOrResumeThreadImpl,
|
||||
type CodexThreadLifecycleTimingLogger,
|
||||
} from "./thread-lifecycle.js";
|
||||
|
||||
function startOrResumeThread(
|
||||
params: Omit<Parameters<typeof startOrResumeThreadImpl>[0], "bindingStore" | "abandonClient"> & {
|
||||
abandonClient?: () => Promise<void>;
|
||||
},
|
||||
) {
|
||||
registerCodexTestSessionIdentity(
|
||||
params.params.sessionFile,
|
||||
params.params.sessionId,
|
||||
params.params.sessionKey,
|
||||
);
|
||||
return startOrResumeThreadImpl({
|
||||
...params,
|
||||
client: ensureCodexTestClientNotificationSurface(params.client),
|
||||
abandonClient: params.abandonClient ?? (async () => undefined),
|
||||
bindingStore: testCodexAppServerBindingStore,
|
||||
});
|
||||
}
|
||||
|
||||
let tempDir: string;
|
||||
|
||||
function createAttemptParams(params: {
|
||||
@@ -619,6 +642,7 @@ describe("Codex app-server turn params", () => {
|
||||
serviceTier: "flex",
|
||||
personality: "none",
|
||||
developerInstructions: resumeParams.developerInstructions,
|
||||
excludeTurns: true,
|
||||
persistExtendedHistory: true,
|
||||
});
|
||||
expect(resumeParams.developerInstructions).not.toContain(CODEX_GPT5_BEHAVIOR_CONTRACT);
|
||||
@@ -833,6 +857,7 @@ describe("Codex app-server model provider selection", () => {
|
||||
|
||||
describe("Codex app-server thread lifecycle timing", () => {
|
||||
beforeEach(async () => {
|
||||
resetCodexTestBindingStore();
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-thread-lifecycle-"));
|
||||
});
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,8 +5,41 @@ import path from "node:path";
|
||||
import type { EmbeddedRunAttemptParams } from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { CodexAppServerRuntimeOptions } from "./config.js";
|
||||
import { readCodexAppServerBinding, writeCodexAppServerBinding } from "./session-binding.js";
|
||||
import { startOrResumeThread } from "./thread-lifecycle.js";
|
||||
import {
|
||||
readCodexAppServerBinding,
|
||||
registerCodexTestSessionIdentity,
|
||||
resetCodexTestBindingStore,
|
||||
testCodexAppServerBindingStore,
|
||||
writeCodexAppServerBinding as writeCodexAppServerBindingImpl,
|
||||
} from "./session-binding.test-helpers.js";
|
||||
import { ensureCodexTestClientNotificationSurface } from "./test-support.js";
|
||||
import { startOrResumeThread as startOrResumeThreadImpl } from "./thread-lifecycle.js";
|
||||
|
||||
function startOrResumeThread(
|
||||
params: Omit<Parameters<typeof startOrResumeThreadImpl>[0], "bindingStore" | "abandonClient"> & {
|
||||
abandonClient?: () => Promise<void>;
|
||||
},
|
||||
) {
|
||||
registerCodexTestSessionIdentity(
|
||||
params.params.sessionFile,
|
||||
params.params.sessionId,
|
||||
params.params.sessionKey,
|
||||
);
|
||||
return startOrResumeThreadImpl({
|
||||
...params,
|
||||
client: ensureCodexTestClientNotificationSurface(params.client),
|
||||
abandonClient: params.abandonClient ?? (async () => undefined),
|
||||
bindingStore: testCodexAppServerBindingStore,
|
||||
});
|
||||
}
|
||||
|
||||
async function writeCodexAppServerBinding(
|
||||
sessionFile: string,
|
||||
binding: Parameters<typeof writeCodexAppServerBindingImpl>[1],
|
||||
): Promise<void> {
|
||||
registerCodexTestSessionIdentity(sessionFile, "session-1", "agent:main:session-1");
|
||||
await writeCodexAppServerBindingImpl(sessionFile, binding);
|
||||
}
|
||||
|
||||
function threadStartResult(threadId = "thread-1"): Record<string, unknown> {
|
||||
return {
|
||||
@@ -92,6 +125,7 @@ describe("startOrResumeThread — user mcp.servers projection (regression: #8081
|
||||
let tempDir = "";
|
||||
|
||||
beforeEach(async () => {
|
||||
resetCodexTestBindingStore();
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-80814-"));
|
||||
});
|
||||
|
||||
|
||||
119
extensions/codex/src/app-server/thread-resume.test.ts
Normal file
119
extensions/codex/src/app-server/thread-resume.test.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { CodexAppServerRpcError, type CodexAppServerClient } from "./client.js";
|
||||
import { readCodexNativeContextUsage, resumeCodexAppServerThread } from "./thread-resume.js";
|
||||
|
||||
function resumeResponse(threadId: string, restoredTurns = 0) {
|
||||
return {
|
||||
thread: {
|
||||
id: threadId,
|
||||
sessionId: "session-1",
|
||||
forkedFromId: null,
|
||||
preview: "",
|
||||
ephemeral: false,
|
||||
modelProvider: "openai",
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
status: { type: "idle" },
|
||||
path: null,
|
||||
cwd: "/repo",
|
||||
cliVersion: "0.139.0",
|
||||
source: "unknown",
|
||||
agentNickname: null,
|
||||
agentRole: null,
|
||||
gitInfo: null,
|
||||
name: null,
|
||||
turns: Array.from({ length: restoredTurns }, (_, index) => ({
|
||||
id: `turn-${index}`,
|
||||
items: [],
|
||||
status: "completed",
|
||||
error: null,
|
||||
})),
|
||||
},
|
||||
model: "gpt-5.5-codex",
|
||||
modelProvider: "openai",
|
||||
serviceTier: null,
|
||||
cwd: "/repo",
|
||||
instructionSources: [],
|
||||
approvalPolicy: "never",
|
||||
approvalsReviewer: "user",
|
||||
sandbox: { type: "dangerFullAccess" },
|
||||
permissionProfile: null,
|
||||
reasoningEffort: null,
|
||||
};
|
||||
}
|
||||
|
||||
function createClient(requestImpl: (params: unknown) => unknown) {
|
||||
const request = vi.fn(async (_method: string, params: unknown) => await requestImpl(params));
|
||||
const client = { request } as unknown as CodexAppServerClient;
|
||||
return {
|
||||
client,
|
||||
request,
|
||||
};
|
||||
}
|
||||
|
||||
describe("resumeCodexAppServerThread", () => {
|
||||
it("reads current-context usage instead of cumulative thread usage", () => {
|
||||
expect(
|
||||
readCodexNativeContextUsage({
|
||||
method: "thread/tokenUsage/updated",
|
||||
params: {
|
||||
tokenUsage: {
|
||||
total: { totalTokens: 900_000 },
|
||||
last: { totalTokens: 42_000 },
|
||||
modelContextWindow: 258_400,
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toEqual({ currentTokens: 42_000, modelContextWindow: 258_400 });
|
||||
});
|
||||
|
||||
it("leaves a proven RPC rejection on the reusable client", async () => {
|
||||
const rejection = new CodexAppServerRpcError(
|
||||
{ code: -32_000, message: "thread not found" },
|
||||
"thread/resume",
|
||||
);
|
||||
const { client } = createClient(async () => {
|
||||
throw rejection;
|
||||
});
|
||||
const abandonClient = vi.fn(async () => undefined);
|
||||
|
||||
await expect(
|
||||
resumeCodexAppServerThread({
|
||||
client,
|
||||
abandonClient,
|
||||
request: { threadId: "thread-1", excludeTurns: true },
|
||||
}),
|
||||
).rejects.toBe(rejection);
|
||||
expect(abandonClient).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("retires the exact client when resume acceptance is indeterminate", async () => {
|
||||
const { client } = createClient(async () => {
|
||||
throw new Error("thread/resume timed out");
|
||||
});
|
||||
const abandonClient = vi.fn(async () => undefined);
|
||||
|
||||
await expect(
|
||||
resumeCodexAppServerThread({
|
||||
client,
|
||||
abandonClient,
|
||||
request: { threadId: "thread-1", excludeTurns: true },
|
||||
}),
|
||||
).rejects.toThrow("thread/resume timed out");
|
||||
expect(abandonClient).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("retires the exact client when the response names another thread", async () => {
|
||||
const { client } = createClient(async () => resumeResponse("thread-2"));
|
||||
const abandonClient = vi.fn(async () => undefined);
|
||||
|
||||
await expect(
|
||||
resumeCodexAppServerThread({
|
||||
client,
|
||||
abandonClient,
|
||||
request: { threadId: "thread-1", excludeTurns: true },
|
||||
}),
|
||||
).rejects.toThrow("returned thread-2 for thread-1");
|
||||
expect(abandonClient).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
91
extensions/codex/src/app-server/thread-resume.ts
Normal file
91
extensions/codex/src/app-server/thread-resume.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import {
|
||||
assertCodexThreadResumeSubscription,
|
||||
CodexAppServerUnsafeSubscriptionError,
|
||||
} from "./attempt-client-cleanup.js";
|
||||
/** Owns Codex thread/resume subscription safety. */
|
||||
import { CodexAppServerRpcError, type CodexAppServerClient } from "./client.js";
|
||||
import { assertCodexThreadResumeResponse } from "./protocol-validators.js";
|
||||
import {
|
||||
isJsonObject,
|
||||
type CodexServerNotification,
|
||||
type CodexThreadResumeParams,
|
||||
type CodexThreadResumeResponse,
|
||||
type JsonValue,
|
||||
} from "./protocol.js";
|
||||
|
||||
export type CodexNativeContextUsageUpdate = {
|
||||
currentTokens: number;
|
||||
modelContextWindow?: number;
|
||||
};
|
||||
|
||||
/** Resumes one thread and retires the physical client when acceptance is indeterminate. */
|
||||
export async function resumeCodexAppServerThread(params: {
|
||||
client: CodexAppServerClient;
|
||||
abandonClient: () => Promise<void>;
|
||||
request: CodexThreadResumeParams;
|
||||
timeoutMs?: number;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<CodexThreadResumeResponse> {
|
||||
const threadId = params.request.threadId;
|
||||
let response: CodexThreadResumeResponse;
|
||||
try {
|
||||
response = assertCodexThreadResumeResponse(
|
||||
await params.client.request("thread/resume", params.request, {
|
||||
...(params.timeoutMs !== undefined ? { timeoutMs: params.timeoutMs } : {}),
|
||||
...(params.signal ? { signal: params.signal } : {}),
|
||||
}),
|
||||
);
|
||||
assertCodexThreadResumeSubscription(threadId, response.thread.id);
|
||||
} catch (error) {
|
||||
if (error instanceof CodexAppServerRpcError) {
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
await params.abandonClient();
|
||||
} catch (abandonError) {
|
||||
throw new CodexAppServerUnsafeSubscriptionError(
|
||||
`Codex thread/resume client could not be retired for ${threadId}`,
|
||||
{ cause: abandonError },
|
||||
);
|
||||
}
|
||||
if (error instanceof CodexAppServerUnsafeSubscriptionError) {
|
||||
throw error;
|
||||
}
|
||||
throw new CodexAppServerUnsafeSubscriptionError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: `Codex thread/resume outcome is indeterminate for ${threadId}`,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
/** Reads the authoritative per-context usage carried by Codex notifications. */
|
||||
export function readCodexNativeContextUsage(
|
||||
notification: CodexServerNotification,
|
||||
): CodexNativeContextUsageUpdate | undefined {
|
||||
const params = isJsonObject(notification.params) ? notification.params : undefined;
|
||||
const tokenUsage = params && isJsonObject(params.tokenUsage) ? params.tokenUsage : undefined;
|
||||
const current = tokenUsage && isJsonObject(tokenUsage.last) ? tokenUsage.last : undefined;
|
||||
const currentTokens = current ? readNonNegativeFiniteNumber(current.totalTokens) : undefined;
|
||||
if (currentTokens === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
const modelContextWindow = readPositiveFiniteNumber(tokenUsage?.modelContextWindow);
|
||||
return {
|
||||
currentTokens,
|
||||
...(modelContextWindow !== undefined ? { modelContextWindow } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function readNonNegativeFiniteNumber(value: JsonValue | undefined): number | undefined {
|
||||
return typeof value === "number" && Number.isFinite(value) && value >= 0
|
||||
? Math.floor(value)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function readPositiveFiniteNumber(value: JsonValue | undefined): number | undefined {
|
||||
const number = readNonNegativeFiniteNumber(value);
|
||||
return number !== undefined && number > 0 ? number : undefined;
|
||||
}
|
||||
940
extensions/codex/src/app-server/turn-router.test.ts
Normal file
940
extensions/codex/src/app-server/turn-router.test.ts
Normal file
@@ -0,0 +1,940 @@
|
||||
import { embeddedAgentLog } from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { CodexAppServerClient } from "./client.js";
|
||||
import type { JsonValue } from "./protocol.js";
|
||||
import { createClientHarness } from "./test-support.js";
|
||||
import { getCodexAppServerTurnRouter, type CodexAppServerServerRequest } from "./turn-router.js";
|
||||
|
||||
type ClientHarness = ReturnType<typeof createClientHarness>;
|
||||
|
||||
type WireResponse = {
|
||||
id: number | string;
|
||||
result?: unknown;
|
||||
error?: unknown;
|
||||
};
|
||||
|
||||
describe("CodexAppServerTurnRouter", () => {
|
||||
const clients: CodexAppServerClient[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const client of clients) {
|
||||
client.close();
|
||||
}
|
||||
clients.length = 0;
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function createHarness(): ClientHarness {
|
||||
const harness = createClientHarness();
|
||||
clients.push(harness.client);
|
||||
return harness;
|
||||
}
|
||||
|
||||
it("installs one request and notification handler per client", () => {
|
||||
const harness = createHarness();
|
||||
const addNotificationHandler = vi.spyOn(harness.client, "addNotificationHandler");
|
||||
const addRequestHandler = vi.spyOn(harness.client, "addRequestHandler");
|
||||
const addCloseHandler = vi.spyOn(harness.client, "addCloseHandler");
|
||||
|
||||
const first = getCodexAppServerTurnRouter(harness.client);
|
||||
const second = getCodexAppServerTurnRouter(harness.client);
|
||||
|
||||
expect(second).toBe(first);
|
||||
expect(addNotificationHandler).toHaveBeenCalledTimes(1);
|
||||
expect(addRequestHandler).toHaveBeenCalledTimes(1);
|
||||
expect(addCloseHandler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("routes concurrent traffic to the exact thread and turn", async () => {
|
||||
const harness = createHarness();
|
||||
const router = getCodexAppServerTurnRouter(harness.client);
|
||||
const firstNotifications = vi.fn();
|
||||
const secondNotifications = vi.fn();
|
||||
const firstRequests = vi.fn(() => ({ owner: "first" }));
|
||||
const secondRequests = vi.fn(() => ({ owner: "second" }));
|
||||
const first = router.reserveThread({
|
||||
threadId: "thread-1",
|
||||
onNotification: firstNotifications,
|
||||
onRequest: firstRequests,
|
||||
});
|
||||
const second = router.reserveThread({
|
||||
threadId: "thread-2",
|
||||
onNotification: secondNotifications,
|
||||
onRequest: secondRequests,
|
||||
});
|
||||
first.armTurn();
|
||||
second.armTurn();
|
||||
await Promise.all([first.bindTurn("turn-1"), second.bindTurn("turn-2")]);
|
||||
|
||||
harness.send({
|
||||
method: "item/agentMessage/delta",
|
||||
params: { threadId: "thread-2", turnId: "turn-2", delta: "right" },
|
||||
});
|
||||
harness.send({
|
||||
method: "turn/completed",
|
||||
params: { threadId: "thread-2", turn: { id: "turn-2", items: [] } },
|
||||
});
|
||||
harness.send({
|
||||
method: "item/agentMessage/delta",
|
||||
params: { threadId: "thread-2", turnId: "turn-stale", delta: "wrong" },
|
||||
});
|
||||
harness.send({
|
||||
id: "request-2",
|
||||
method: "item/tool/call",
|
||||
params: { threadId: "thread-2", turnId: "turn-2", tool: "second" },
|
||||
});
|
||||
harness.send({
|
||||
id: "request-1",
|
||||
method: "item/tool/call",
|
||||
params: { threadId: "thread-1", turnId: "turn-1", tool: "first" },
|
||||
});
|
||||
|
||||
await vi.waitFor(() => expect(secondNotifications).toHaveBeenCalledTimes(2));
|
||||
const firstResponse = await waitForResponse(harness, "request-1");
|
||||
const secondResponse = await waitForResponse(harness, "request-2");
|
||||
|
||||
expect(firstNotifications).not.toHaveBeenCalled();
|
||||
expect(secondNotifications).toHaveBeenCalledWith(
|
||||
{
|
||||
method: "item/agentMessage/delta",
|
||||
params: { threadId: "thread-2", turnId: "turn-2", delta: "right" },
|
||||
},
|
||||
{ threadId: "thread-2", turnId: "turn-2" },
|
||||
);
|
||||
expect(secondNotifications).toHaveBeenCalledWith(
|
||||
{
|
||||
method: "turn/completed",
|
||||
params: { threadId: "thread-2", turn: { id: "turn-2", items: [] } },
|
||||
},
|
||||
{ threadId: "thread-2", turnId: "turn-2" },
|
||||
);
|
||||
expect(firstRequests).toHaveBeenCalledTimes(1);
|
||||
expect(secondRequests).toHaveBeenCalledTimes(1);
|
||||
expect(firstResponse).toEqual({ id: "request-1", result: { owner: "first" } });
|
||||
expect(secondResponse).toEqual({ id: "request-2", result: { owner: "second" } });
|
||||
});
|
||||
|
||||
it("buffers pre-bind notifications in order and filters the bound turn", async () => {
|
||||
const harness = createHarness();
|
||||
const methods: string[] = [];
|
||||
const receivedMethods: string[] = [];
|
||||
const route = getCodexAppServerTurnRouter(harness.client).reserveThread({
|
||||
threadId: "thread-1",
|
||||
onNotificationReceived: (notification) => {
|
||||
receivedMethods.push(notification.method);
|
||||
},
|
||||
onNotification: async (notification) => {
|
||||
await Promise.resolve();
|
||||
methods.push(notification.method);
|
||||
},
|
||||
});
|
||||
route.armTurn();
|
||||
|
||||
harness.send({
|
||||
method: "thread/started",
|
||||
params: { thread: { id: "thread-1" } },
|
||||
});
|
||||
harness.send({
|
||||
method: "item/started",
|
||||
params: { threadId: "thread-1", turnId: "turn-1" },
|
||||
});
|
||||
harness.send({
|
||||
method: "thread/status/changed",
|
||||
params: { threadId: "thread-1", status: { type: "active" } },
|
||||
});
|
||||
harness.send({
|
||||
method: "item/completed",
|
||||
params: { threadId: "thread-1", turnId: "turn-stale" },
|
||||
});
|
||||
harness.send({
|
||||
method: "turn/started",
|
||||
params: { threadId: "thread-1", turnId: "turn-1" },
|
||||
});
|
||||
await settleInput();
|
||||
|
||||
expect(methods).toEqual([]);
|
||||
expect(receivedMethods).toEqual([]);
|
||||
await route.bindTurn("turn-1");
|
||||
|
||||
expect(receivedMethods).toEqual([
|
||||
"thread/started",
|
||||
"item/started",
|
||||
"thread/status/changed",
|
||||
"turn/started",
|
||||
]);
|
||||
expect(methods).toEqual([
|
||||
"thread/started",
|
||||
"item/started",
|
||||
"thread/status/changed",
|
||||
"turn/started",
|
||||
]);
|
||||
});
|
||||
|
||||
it("flushes prior notifications before releasing a bound request", async () => {
|
||||
const harness = createHarness();
|
||||
const events: string[] = [];
|
||||
let finishFirst!: () => void;
|
||||
const firstPending = new Promise<void>((resolve) => {
|
||||
finishFirst = resolve;
|
||||
});
|
||||
const route = getCodexAppServerTurnRouter(harness.client).reserveThread({
|
||||
threadId: "thread-ordered",
|
||||
onNotification: async (notification) => {
|
||||
events.push(`${notification.method}:start`);
|
||||
if (notification.method === "item/started") {
|
||||
await firstPending;
|
||||
}
|
||||
events.push(`${notification.method}:end`);
|
||||
},
|
||||
onRequest: () => {
|
||||
events.push("request");
|
||||
return { success: true, contentItems: [] };
|
||||
},
|
||||
});
|
||||
route.armTurn();
|
||||
harness.send({
|
||||
method: "item/started",
|
||||
params: { threadId: "thread-ordered", turnId: "turn-ordered" },
|
||||
});
|
||||
harness.send({
|
||||
method: "item/agentMessage/delta",
|
||||
params: { threadId: "thread-ordered", turnId: "turn-ordered", delta: "done" },
|
||||
});
|
||||
harness.send({
|
||||
id: "request-ordered",
|
||||
method: "item/tool/call",
|
||||
params: { threadId: "thread-ordered", turnId: "turn-ordered", tool: "message" },
|
||||
});
|
||||
|
||||
const binding = route.bindTurn("turn-ordered");
|
||||
await vi.waitFor(() => expect(events).toEqual(["item/started:start"]));
|
||||
expect(harness.writes).toEqual([]);
|
||||
|
||||
finishFirst();
|
||||
await binding;
|
||||
expect(await waitForResponse(harness, "request-ordered")).toEqual({
|
||||
id: "request-ordered",
|
||||
result: { success: true, contentItems: [] },
|
||||
});
|
||||
expect(events).toEqual([
|
||||
"item/started:start",
|
||||
"item/started:end",
|
||||
"item/agentMessage/delta:start",
|
||||
"item/agentMessage/delta:end",
|
||||
"request",
|
||||
]);
|
||||
});
|
||||
|
||||
it("records receipt synchronously and drains accepted work after release", async () => {
|
||||
const harness = createHarness();
|
||||
const events: string[] = [];
|
||||
let finishFirst!: () => void;
|
||||
const firstPending = new Promise<void>((resolve) => {
|
||||
finishFirst = resolve;
|
||||
});
|
||||
const route = getCodexAppServerTurnRouter(harness.client).reserveThread({
|
||||
threadId: "thread-receive",
|
||||
onNotificationReceived: (notification) => {
|
||||
events.push(`${notification.method}:received`);
|
||||
},
|
||||
onNotification: async (notification) => {
|
||||
events.push(`${notification.method}:start`);
|
||||
if (notification.method === "item/started") {
|
||||
await firstPending;
|
||||
}
|
||||
events.push(`${notification.method}:end`);
|
||||
},
|
||||
});
|
||||
harness.send({
|
||||
method: "item/started",
|
||||
params: { threadId: "thread-receive", turnId: "turn-receive" },
|
||||
});
|
||||
harness.send({
|
||||
method: "item/completed",
|
||||
params: { threadId: "thread-receive", turnId: "turn-receive" },
|
||||
});
|
||||
|
||||
await vi.waitFor(() => expect(events).toContain("item/started:start"));
|
||||
expect(events.slice(0, 3)).toEqual([
|
||||
"item/started:received",
|
||||
"item/completed:received",
|
||||
"item/started:start",
|
||||
]);
|
||||
|
||||
route.release();
|
||||
finishFirst();
|
||||
await route.drain();
|
||||
expect(events).toEqual([
|
||||
"item/started:received",
|
||||
"item/completed:received",
|
||||
"item/started:start",
|
||||
"item/started:end",
|
||||
"item/completed:start",
|
||||
"item/completed:end",
|
||||
]);
|
||||
});
|
||||
|
||||
it("releases routing waiters without waiting for an async notification", async () => {
|
||||
const harness = createHarness();
|
||||
let notificationStarted!: () => void;
|
||||
const started = new Promise<void>((resolve) => {
|
||||
notificationStarted = resolve;
|
||||
});
|
||||
const neverFinishes = new Promise<void>(() => {});
|
||||
const route = getCodexAppServerTurnRouter(harness.client).reserveThread({
|
||||
threadId: "thread-release-tail",
|
||||
onNotification: async () => {
|
||||
notificationStarted();
|
||||
await neverFinishes;
|
||||
},
|
||||
onRequest: () => ({ decision: "accept" }),
|
||||
});
|
||||
route.armTurn();
|
||||
harness.send({
|
||||
method: "item/started",
|
||||
params: { threadId: "thread-release-tail", turnId: "turn-release-tail" },
|
||||
});
|
||||
harness.send({
|
||||
id: "request-release-tail",
|
||||
method: "item/commandExecution/requestApproval",
|
||||
params: {
|
||||
threadId: "thread-release-tail",
|
||||
turnId: "turn-release-tail",
|
||||
itemId: "item-1",
|
||||
},
|
||||
});
|
||||
const binding = route.bindTurn("turn-release-tail");
|
||||
await started;
|
||||
|
||||
route.release();
|
||||
|
||||
await expect(binding).rejects.toThrow("thread route is released");
|
||||
expect(await waitForResponse(harness, "request-release-tail")).toEqual({
|
||||
id: "request-release-tail",
|
||||
result: { decision: "decline" },
|
||||
});
|
||||
});
|
||||
|
||||
it("delivers open-route notifications while an armed route waits", async () => {
|
||||
const harness = createHarness();
|
||||
const router = getCodexAppServerTurnRouter(harness.client);
|
||||
const threadHandler = vi.fn();
|
||||
const turnHandler = vi.fn();
|
||||
router.reserveThread({
|
||||
threadId: "thread-live",
|
||||
onNotification: threadHandler,
|
||||
});
|
||||
const turnRoute = router.reserveThread({
|
||||
threadId: "thread-buffered",
|
||||
onNotification: turnHandler,
|
||||
});
|
||||
turnRoute.armTurn();
|
||||
|
||||
const liveNotification = {
|
||||
method: "thread/status/changed",
|
||||
params: { threadId: "thread-live", status: { type: "active" } },
|
||||
};
|
||||
const bufferedNotification = {
|
||||
method: "item/started",
|
||||
params: { threadId: "thread-buffered", turnId: "turn-buffered" },
|
||||
};
|
||||
harness.send(liveNotification);
|
||||
harness.send(bufferedNotification);
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(threadHandler).toHaveBeenCalledWith(liveNotification, {
|
||||
threadId: "thread-live",
|
||||
}),
|
||||
);
|
||||
expect(turnHandler).not.toHaveBeenCalled();
|
||||
|
||||
await turnRoute.bindTurn("turn-buffered");
|
||||
expect(turnHandler).toHaveBeenCalledWith(bufferedNotification, {
|
||||
threadId: "thread-buffered",
|
||||
turnId: "turn-buffered",
|
||||
});
|
||||
});
|
||||
|
||||
it("holds dormant traffic until one-shot activation", async () => {
|
||||
const harness = createHarness();
|
||||
const events: string[] = [];
|
||||
const route = getCodexAppServerTurnRouter(harness.client).reserveThread({
|
||||
threadId: "thread-dormant",
|
||||
});
|
||||
route.armTurn();
|
||||
|
||||
harness.send({
|
||||
method: "thread/status/changed",
|
||||
params: { threadId: "thread-dormant", status: { type: "active" } },
|
||||
});
|
||||
harness.send({
|
||||
method: "item/started",
|
||||
params: { threadId: "thread-dormant", turnId: "turn-dormant" },
|
||||
});
|
||||
harness.send({
|
||||
id: "request-dormant-thread",
|
||||
method: "mcpServer/elicitation/request",
|
||||
params: { threadId: "thread-dormant", turnId: null },
|
||||
});
|
||||
harness.send({
|
||||
id: "request-dormant-turn",
|
||||
method: "item/tool/call",
|
||||
params: { threadId: "thread-dormant", turnId: "turn-dormant" },
|
||||
});
|
||||
await settleInput();
|
||||
|
||||
expect(events).toEqual([]);
|
||||
expect(harness.writes).toEqual([]);
|
||||
expect(route.signal.aborted).toBe(false);
|
||||
await expect(route.bindTurn("turn-dormant")).rejects.toThrow(
|
||||
"thread route must be activated before binding a turn",
|
||||
);
|
||||
await expect(route.activate({})).rejects.toThrow(
|
||||
"thread route requires a notification or request handler",
|
||||
);
|
||||
|
||||
await route.activate({
|
||||
onNotification: async (notification) => {
|
||||
await Promise.resolve();
|
||||
events.push(`notification:${notification.method}`);
|
||||
},
|
||||
onRequest: (request): JsonValue => {
|
||||
events.push(`request:${request.method}`);
|
||||
return request.method === "item/tool/call"
|
||||
? { success: true, contentItems: [] }
|
||||
: { action: "accept" };
|
||||
},
|
||||
});
|
||||
|
||||
expect(events).toEqual([]);
|
||||
expect(harness.writes.map((line) => JSON.parse(line) as WireResponse)).not.toContainEqual(
|
||||
expect.objectContaining({ id: "request-dormant-turn" }),
|
||||
);
|
||||
|
||||
await route.bindTurn("turn-dormant");
|
||||
expect(events.slice(0, 2)).toEqual([
|
||||
"notification:thread/status/changed",
|
||||
"notification:item/started",
|
||||
]);
|
||||
expect(await waitForResponse(harness, "request-dormant-thread")).toEqual({
|
||||
id: "request-dormant-thread",
|
||||
result: { action: "accept" },
|
||||
});
|
||||
expect(await waitForResponse(harness, "request-dormant-turn")).toEqual({
|
||||
id: "request-dormant-turn",
|
||||
result: { success: true, contentItems: [] },
|
||||
});
|
||||
expect(events.at(-1)).toBe("request:item/tool/call");
|
||||
await expect(route.activate({ onRequest: vi.fn() })).rejects.toThrow(
|
||||
"thread route already activated",
|
||||
);
|
||||
});
|
||||
|
||||
it("waits for binding before validating turn-scoped requests", async () => {
|
||||
const harness = createHarness();
|
||||
const router = getCodexAppServerTurnRouter(harness.client);
|
||||
const matchingHandler = vi.fn(() => ({ success: true, contentItems: [] }));
|
||||
const matchingRoute = router.reserveThread({
|
||||
threadId: "thread-match",
|
||||
onRequest: matchingHandler,
|
||||
});
|
||||
matchingRoute.armTurn();
|
||||
|
||||
harness.send({
|
||||
id: "request-match",
|
||||
method: "item/tool/call",
|
||||
params: { threadId: "thread-match", turnId: "turn-match", tool: "message" },
|
||||
});
|
||||
await settleInput();
|
||||
|
||||
expect(matchingHandler).not.toHaveBeenCalled();
|
||||
expect(harness.writes).toEqual([]);
|
||||
|
||||
await matchingRoute.bindTurn("turn-match");
|
||||
await expect(waitForResponse(harness, "request-match")).resolves.toEqual({
|
||||
id: "request-match",
|
||||
result: { success: true, contentItems: [] },
|
||||
});
|
||||
expect(matchingHandler).toHaveBeenCalledTimes(1);
|
||||
|
||||
const staleHandler = vi.fn(() => ({ success: true, contentItems: [] }));
|
||||
const staleRoute = router.reserveThread({
|
||||
threadId: "thread-stale",
|
||||
onRequest: staleHandler,
|
||||
});
|
||||
staleRoute.armTurn();
|
||||
harness.send({
|
||||
id: "request-stale",
|
||||
method: "item/tool/call",
|
||||
params: { threadId: "thread-stale", turnId: "turn-stale", tool: "message" },
|
||||
});
|
||||
await settleInput();
|
||||
|
||||
expect(staleHandler).not.toHaveBeenCalled();
|
||||
await staleRoute.bindTurn("turn-current");
|
||||
|
||||
expect(await waitForResponse(harness, "request-stale")).toEqual({
|
||||
id: "request-stale",
|
||||
result: {
|
||||
contentItems: [
|
||||
{
|
||||
type: "inputText",
|
||||
text: "OpenClaw did not register a handler for this app-server tool call.",
|
||||
},
|
||||
],
|
||||
success: false,
|
||||
},
|
||||
});
|
||||
expect(staleHandler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("routes no-turn requests immediately after activation", async () => {
|
||||
const harness = createHarness();
|
||||
const handleRequest = (request: CodexAppServerServerRequest): JsonValue => {
|
||||
if (request.method === "execCommandApproval" || request.method === "applyPatchApproval") {
|
||||
return { decision: "approved" };
|
||||
}
|
||||
return { action: "accept", content: { answer: "yes" } };
|
||||
};
|
||||
const handler = vi.fn(handleRequest);
|
||||
const route = getCodexAppServerTurnRouter(harness.client).reserveThread({
|
||||
threadId: "thread-1",
|
||||
});
|
||||
|
||||
harness.send({
|
||||
id: "elicitation-1",
|
||||
method: "mcpServer/elicitation/request",
|
||||
params: { threadId: "thread-1", turnId: null, message: "Continue?" },
|
||||
});
|
||||
|
||||
await settleInput();
|
||||
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
expect(harness.writes).toEqual([]);
|
||||
|
||||
await route.activate({ onRequest: handler });
|
||||
|
||||
expect(await waitForResponse(harness, "elicitation-1")).toEqual({
|
||||
id: "elicitation-1",
|
||||
result: { action: "accept", content: { answer: "yes" } },
|
||||
});
|
||||
expect(handler).toHaveBeenCalledOnce();
|
||||
route.release();
|
||||
});
|
||||
|
||||
it("keeps resumed-turn requests open until a new turn is armed", async () => {
|
||||
const harness = createHarness();
|
||||
const handler = vi.fn(() => undefined);
|
||||
const notificationHandler = vi.fn();
|
||||
const route = getCodexAppServerTurnRouter(harness.client).reserveThread({
|
||||
threadId: "thread-resumed",
|
||||
onRequest: handler,
|
||||
onNotification: notificationHandler,
|
||||
});
|
||||
|
||||
harness.send({
|
||||
id: "old-turn-request",
|
||||
method: "item/commandExecution/requestApproval",
|
||||
params: { threadId: "thread-resumed", turnId: "turn-old", itemId: "item-old" },
|
||||
});
|
||||
await expect(waitForResponse(harness, "old-turn-request")).resolves.toEqual({
|
||||
id: "old-turn-request",
|
||||
result: { decision: "decline" },
|
||||
});
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
|
||||
route.armTurn();
|
||||
harness.send({
|
||||
id: "pending-turn-request",
|
||||
method: "item/commandExecution/requestApproval",
|
||||
params: { threadId: "thread-resumed", turnId: "turn-next", itemId: "item-next" },
|
||||
});
|
||||
const earlyError = {
|
||||
method: "error",
|
||||
params: { threadId: "thread-resumed", message: "turn start failed" },
|
||||
};
|
||||
harness.send(earlyError);
|
||||
await settleInput();
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
expect(notificationHandler).not.toHaveBeenCalled();
|
||||
|
||||
await route.cancelTurn();
|
||||
expect(notificationHandler).toHaveBeenCalledWith(earlyError, {
|
||||
threadId: "thread-resumed",
|
||||
});
|
||||
await expect(waitForResponse(harness, "pending-turn-request")).resolves.toEqual({
|
||||
id: "pending-turn-request",
|
||||
result: { decision: "decline" },
|
||||
});
|
||||
expect(handler).toHaveBeenCalledTimes(2);
|
||||
|
||||
route.armTurn();
|
||||
await route.bindTurn("turn-final");
|
||||
route.release();
|
||||
});
|
||||
|
||||
it("consumes one native completion and clears stale completion when arming", async () => {
|
||||
const harness = createHarness();
|
||||
const route = getCodexAppServerTurnRouter(harness.client).reserveThread({
|
||||
threadId: "thread-native",
|
||||
onNotification: vi.fn(),
|
||||
});
|
||||
harness.send({
|
||||
method: "turn/completed",
|
||||
params: { threadId: "thread-native", turn: { id: "turn-native", items: [] } },
|
||||
});
|
||||
await settleInput();
|
||||
|
||||
await expect(route.waitForTurnCompletion({ timeoutMs: 10 })).resolves.toBe(true);
|
||||
await expect(route.waitForTurnCompletion({ timeoutMs: 1 })).resolves.toBe(false);
|
||||
|
||||
harness.send({
|
||||
method: "turn/completed",
|
||||
params: { threadId: "thread-native", turn: { id: "turn-stale", items: [] } },
|
||||
});
|
||||
await settleInput();
|
||||
route.armTurn();
|
||||
await expect(route.waitForTurnCompletion({ timeoutMs: 1 })).resolves.toBe(false);
|
||||
await route.cancelTurn();
|
||||
});
|
||||
|
||||
it("settles an active native-completion waiter on completion, abort, and release", async () => {
|
||||
const harness = createHarness();
|
||||
const route = getCodexAppServerTurnRouter(harness.client).reserveThread({
|
||||
threadId: "thread-native-wait",
|
||||
onNotification: vi.fn(),
|
||||
});
|
||||
|
||||
const completed = route.waitForTurnCompletion({ timeoutMs: 100 });
|
||||
harness.send({
|
||||
method: "turn/completed",
|
||||
params: { threadId: "thread-native-wait", turn: { id: "turn-native", items: [] } },
|
||||
});
|
||||
await expect(completed).resolves.toBe(true);
|
||||
|
||||
const controller = new AbortController();
|
||||
const aborted = route.waitForTurnCompletion({ timeoutMs: 100, signal: controller.signal });
|
||||
controller.abort("test");
|
||||
await expect(aborted).resolves.toBe(false);
|
||||
|
||||
const released = route.waitForTurnCompletion({ timeoutMs: 100 });
|
||||
route.release();
|
||||
await expect(released).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("watches one exact native turn without reserving its thread", async () => {
|
||||
const harness = createHarness();
|
||||
const router = getCodexAppServerTurnRouter(harness.client);
|
||||
const watch = router.watchNativeTurnCompletion({
|
||||
threadId: "thread-native-watch",
|
||||
turnId: "turn-target",
|
||||
timeoutMs: 100,
|
||||
});
|
||||
const settled = vi.fn();
|
||||
void watch.completion.then(settled);
|
||||
|
||||
const route = router.reserveThread({
|
||||
threadId: "thread-native-watch",
|
||||
onNotification: vi.fn(),
|
||||
});
|
||||
route.release();
|
||||
harness.send({
|
||||
method: "turn/completed",
|
||||
params: {
|
||||
threadId: "thread-native-watch",
|
||||
turn: { id: "turn-other", status: "completed" },
|
||||
},
|
||||
});
|
||||
await settleInput();
|
||||
expect(settled).not.toHaveBeenCalled();
|
||||
|
||||
harness.send({
|
||||
method: "turn/completed",
|
||||
params: {
|
||||
threadId: "thread-native-watch",
|
||||
turn: { id: "turn-target", status: "completed" },
|
||||
},
|
||||
});
|
||||
await expect(watch.completion).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it("treats an exact non-retry error as native turn termination", async () => {
|
||||
const harness = createHarness();
|
||||
const watch = getCodexAppServerTurnRouter(harness.client).watchNativeTurnCompletion({
|
||||
threadId: "thread-native-error",
|
||||
turnId: "turn-native-error",
|
||||
timeoutMs: 100,
|
||||
});
|
||||
const settled = vi.fn();
|
||||
void watch.completion.then(settled);
|
||||
|
||||
harness.send({
|
||||
method: "error",
|
||||
params: {
|
||||
threadId: "thread-native-error",
|
||||
turnId: "turn-native-error",
|
||||
error: { message: "retrying" },
|
||||
willRetry: true,
|
||||
},
|
||||
});
|
||||
await settleInput();
|
||||
expect(settled).not.toHaveBeenCalled();
|
||||
|
||||
harness.send({
|
||||
method: "error",
|
||||
params: {
|
||||
threadId: "thread-native-error",
|
||||
turnId: "turn-native-error",
|
||||
error: { message: "review setup failed" },
|
||||
willRetry: false,
|
||||
},
|
||||
});
|
||||
await expect(watch.completion).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it("refreshes native turn idle timeout on exact progress", async () => {
|
||||
vi.useFakeTimers();
|
||||
const harness = createHarness();
|
||||
const watch = getCodexAppServerTurnRouter(harness.client).watchNativeTurnCompletion({
|
||||
threadId: "thread-native-progress",
|
||||
turnId: "turn-native-progress",
|
||||
timeoutMs: 1_000,
|
||||
});
|
||||
const settled = vi.fn();
|
||||
void watch.completion.then(settled);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(900);
|
||||
harness.send({
|
||||
method: "item/agentMessage/delta",
|
||||
params: {
|
||||
threadId: "thread-native-progress",
|
||||
turnId: "turn-native-progress",
|
||||
delta: "working",
|
||||
},
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(900);
|
||||
expect(settled).not.toHaveBeenCalled();
|
||||
|
||||
harness.send({
|
||||
method: "turn/completed",
|
||||
params: {
|
||||
threadId: "thread-native-progress",
|
||||
turn: { id: "turn-native-progress", status: "completed" },
|
||||
},
|
||||
});
|
||||
await expect(watch.completion).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it("cancels a detached native-turn completion watch", async () => {
|
||||
const harness = createHarness();
|
||||
const watch = getCodexAppServerTurnRouter(harness.client).watchNativeTurnCompletion({
|
||||
threadId: "thread-native-cancel",
|
||||
turnId: "turn-native-cancel",
|
||||
timeoutMs: 100,
|
||||
});
|
||||
|
||||
watch.cancel();
|
||||
|
||||
await expect(watch.completion).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("settles detached native-turn watches on timeout and client close", async () => {
|
||||
const timeoutHarness = createHarness();
|
||||
const timedOut = getCodexAppServerTurnRouter(timeoutHarness.client).watchNativeTurnCompletion({
|
||||
threadId: "thread-native-timeout",
|
||||
turnId: "turn-native-timeout",
|
||||
timeoutMs: 1,
|
||||
});
|
||||
await expect(timedOut.completion).resolves.toBe(false);
|
||||
|
||||
const closeHarness = createHarness();
|
||||
const closed = getCodexAppServerTurnRouter(closeHarness.client).watchNativeTurnCompletion({
|
||||
threadId: "thread-native-close",
|
||||
turnId: "turn-native-close",
|
||||
timeoutMs: 100,
|
||||
});
|
||||
closeHarness.client.close();
|
||||
await expect(closed.completion).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("releases pending requests and removes routes on cleanup", async () => {
|
||||
const harness = createHarness();
|
||||
const router = getCodexAppServerTurnRouter(harness.client);
|
||||
const notificationHandler = vi.fn();
|
||||
const requestHandler = vi.fn(() => ({ decision: "accept" }));
|
||||
const route = router.reserveThread({
|
||||
threadId: "thread-release",
|
||||
onNotification: notificationHandler,
|
||||
onRequest: requestHandler,
|
||||
});
|
||||
route.armTurn();
|
||||
harness.send({
|
||||
id: "request-release",
|
||||
method: "item/commandExecution/requestApproval",
|
||||
params: {
|
||||
threadId: "thread-release",
|
||||
turnId: "turn-release",
|
||||
itemId: "item-1",
|
||||
},
|
||||
});
|
||||
await settleInput();
|
||||
|
||||
route.release();
|
||||
harness.send({
|
||||
method: "item/started",
|
||||
params: { threadId: "thread-release", turnId: "turn-release" },
|
||||
});
|
||||
|
||||
expect(await waitForResponse(harness, "request-release")).toEqual({
|
||||
id: "request-release",
|
||||
result: { decision: "decline" },
|
||||
});
|
||||
expect(notificationHandler).not.toHaveBeenCalled();
|
||||
expect(requestHandler).not.toHaveBeenCalled();
|
||||
|
||||
let finishActiveRequest!: (result: { decision: string }) => void;
|
||||
const activeResult = new Promise<{ decision: string }>((resolve) => {
|
||||
finishActiveRequest = resolve;
|
||||
});
|
||||
let failActiveRequest!: (error: Error) => void;
|
||||
const rejectedActiveResult = new Promise<{ decision: string }>((_resolve, reject) => {
|
||||
failActiveRequest = reject;
|
||||
});
|
||||
const activeHandler = vi.fn((request: { id: number | string }) =>
|
||||
request.id === "request-active-reject" ? rejectedActiveResult : activeResult,
|
||||
);
|
||||
const activeRoute = router.reserveThread({
|
||||
threadId: "thread-active",
|
||||
onRequest: activeHandler,
|
||||
});
|
||||
activeRoute.armTurn();
|
||||
await activeRoute.bindTurn("turn-active");
|
||||
harness.send({
|
||||
id: "request-active",
|
||||
method: "item/commandExecution/requestApproval",
|
||||
params: {
|
||||
threadId: "thread-active",
|
||||
turnId: "turn-active",
|
||||
itemId: "item-2",
|
||||
},
|
||||
});
|
||||
harness.send({
|
||||
id: "request-active-reject",
|
||||
method: "item/commandExecution/requestApproval",
|
||||
params: {
|
||||
threadId: "thread-active",
|
||||
turnId: "turn-active",
|
||||
itemId: "item-3",
|
||||
},
|
||||
});
|
||||
await vi.waitFor(() => expect(activeHandler).toHaveBeenCalledTimes(2));
|
||||
|
||||
activeRoute.release();
|
||||
finishActiveRequest({ decision: "accept" });
|
||||
failActiveRequest(new Error("stale request failure"));
|
||||
|
||||
expect(await waitForResponse(harness, "request-active")).toEqual({
|
||||
id: "request-active",
|
||||
result: { decision: "decline" },
|
||||
});
|
||||
expect(await waitForResponse(harness, "request-active-reject")).toEqual({
|
||||
id: "request-active-reject",
|
||||
result: { decision: "decline" },
|
||||
});
|
||||
|
||||
const closingRoute = router.reserveThread({
|
||||
threadId: "thread-close",
|
||||
onRequest: requestHandler,
|
||||
});
|
||||
harness.client.close();
|
||||
|
||||
await expect(closingRoute.bindTurn("turn-close")).rejects.toThrow("turn router closed");
|
||||
expect(closingRoute.signal.aborted).toBe(true);
|
||||
expect(closingRoute.signal.reason).toEqual(new Error("codex app-server turn router closed"));
|
||||
expect(() =>
|
||||
router.reserveThread({ threadId: "thread-late", onRequest: requestHandler }),
|
||||
).toThrow("turn router is closed");
|
||||
});
|
||||
|
||||
it("releases dormant waiters and aborts the reservation", async () => {
|
||||
const harness = createHarness();
|
||||
const router = getCodexAppServerTurnRouter(harness.client);
|
||||
const route = router.reserveThread({ threadId: "thread-dormant-release" });
|
||||
harness.send({
|
||||
id: "request-dormant-release",
|
||||
method: "item/tool/call",
|
||||
params: { threadId: "thread-dormant-release", turnId: "turn-1" },
|
||||
});
|
||||
await settleInput();
|
||||
|
||||
route.release();
|
||||
|
||||
expect(route.signal.aborted).toBe(true);
|
||||
expect(route.signal.reason).toEqual(new Error("codex app-server thread route is released"));
|
||||
await expect(route.activate({ onRequest: vi.fn() })).rejects.toThrow(
|
||||
"thread route is released",
|
||||
);
|
||||
await expect(route.bindTurn("turn-1")).rejects.toThrow("thread route is released");
|
||||
expect(await waitForResponse(harness, "request-dormant-release")).toEqual({
|
||||
id: "request-dormant-release",
|
||||
result: {
|
||||
contentItems: [
|
||||
{
|
||||
type: "inputText",
|
||||
text: "OpenClaw did not register a handler for this app-server tool call.",
|
||||
},
|
||||
],
|
||||
success: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("fails and removes a route when its pre-bind buffer is full", async () => {
|
||||
vi.spyOn(embeddedAgentLog, "warn").mockImplementation(() => undefined);
|
||||
const harness = createHarness();
|
||||
const router = getCodexAppServerTurnRouter(harness.client);
|
||||
const route = router.reserveThread({
|
||||
threadId: "thread-overflow",
|
||||
onNotification: vi.fn(),
|
||||
});
|
||||
route.armTurn();
|
||||
for (let index = 0; index <= 256; index += 1) {
|
||||
harness.send({
|
||||
method: "item/started",
|
||||
params: { threadId: "thread-overflow", turnId: "turn-overflow" },
|
||||
});
|
||||
}
|
||||
await settleInput();
|
||||
|
||||
await expect(route.bindTurn("turn-overflow")).rejects.toThrow(
|
||||
"pre-bind notification buffer exceeded 256 entries",
|
||||
);
|
||||
expect(() =>
|
||||
router.reserveThread({
|
||||
threadId: "thread-overflow",
|
||||
onNotification: vi.fn(),
|
||||
}),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
async function waitForResponse(harness: ClientHarness, id: number | string): Promise<WireResponse> {
|
||||
let response: WireResponse | undefined;
|
||||
await vi.waitFor(() => {
|
||||
response = harness.writes
|
||||
.map((write) => JSON.parse(write) as WireResponse)
|
||||
.find((candidate) => candidate.id === id);
|
||||
expect(response).toBeDefined();
|
||||
});
|
||||
if (!response) {
|
||||
throw new Error(`missing app-server response for ${id}`);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
async function settleInput(): Promise<void> {
|
||||
await new Promise<void>((resolve) => {
|
||||
setImmediate(resolve);
|
||||
});
|
||||
}
|
||||
592
extensions/codex/src/app-server/turn-router.ts
Normal file
592
extensions/codex/src/app-server/turn-router.ts
Normal file
@@ -0,0 +1,592 @@
|
||||
/** Keyed routing for all turn traffic on one shared Codex app-server client. */
|
||||
import { embeddedAgentLog } from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import type { CodexAppServerClient } from "./client.js";
|
||||
import {
|
||||
readCodexNotificationThreadId,
|
||||
readCodexNotificationTurnId,
|
||||
} from "./notification-correlation.js";
|
||||
import {
|
||||
isJsonObject,
|
||||
type CodexServerNotification,
|
||||
type JsonValue,
|
||||
type RpcRequest,
|
||||
} from "./protocol.js";
|
||||
|
||||
const DEFAULT_PREBIND_NOTIFICATION_LIMIT = 256;
|
||||
export const CODEX_APP_SERVER_NATIVE_TURN_WAIT_TIMEOUT_MS = 30_000;
|
||||
|
||||
export type CodexAppServerServerRequest = Required<Pick<RpcRequest, "id" | "method">> & {
|
||||
params?: JsonValue;
|
||||
};
|
||||
export type CodexThreadRouteScope = {
|
||||
threadId: string;
|
||||
turnId?: string;
|
||||
};
|
||||
type CodexThreadRequestHandler = (
|
||||
request: CodexAppServerServerRequest,
|
||||
scope: CodexThreadRouteScope,
|
||||
) => Promise<JsonValue | undefined> | JsonValue | undefined;
|
||||
type CodexThreadNotificationHandler = (
|
||||
notification: CodexServerNotification,
|
||||
scope: CodexThreadRouteScope,
|
||||
) => Promise<void> | void;
|
||||
type CodexThreadNotificationReceivedHandler = (
|
||||
notification: CodexServerNotification,
|
||||
scope: CodexThreadRouteScope,
|
||||
receivedAtMs: number,
|
||||
) => void;
|
||||
type CodexThreadRouteHandlers = {
|
||||
onNotificationReceived?: CodexThreadNotificationReceivedHandler;
|
||||
onNotification?: CodexThreadNotificationHandler;
|
||||
onRequest?: CodexThreadRequestHandler;
|
||||
};
|
||||
|
||||
export type CodexThreadRouteReservation = {
|
||||
readonly threadId: string;
|
||||
readonly signal: AbortSignal;
|
||||
activate: (handlers: CodexThreadRouteHandlers) => Promise<void>;
|
||||
armTurn: () => void;
|
||||
bindTurn: (turnId: string) => Promise<void>;
|
||||
cancelTurn: () => Promise<void>;
|
||||
waitForTurnCompletion: (options: { timeoutMs: number; signal?: AbortSignal }) => Promise<boolean>;
|
||||
drain: () => Promise<void>;
|
||||
release: () => void;
|
||||
};
|
||||
|
||||
type RouteOptions = Partial<CodexThreadRouteHandlers> & {
|
||||
threadId: string;
|
||||
releaseOn?: AbortSignal;
|
||||
};
|
||||
|
||||
export type CodexAppServerTurnRouter = {
|
||||
reserveThread: (options: RouteOptions) => CodexThreadRouteReservation;
|
||||
watchNativeTurnCompletion: (options: {
|
||||
threadId: string;
|
||||
turnId: string;
|
||||
timeoutMs: number;
|
||||
}) => CodexNativeTurnCompletionWatch;
|
||||
};
|
||||
|
||||
export type CodexNativeTurnCompletionWatch = {
|
||||
completion: Promise<boolean>;
|
||||
cancel: () => void;
|
||||
};
|
||||
|
||||
type Deferred = { promise: Promise<void>; resolve: () => void };
|
||||
type PendingNotification = {
|
||||
notification: CodexServerNotification;
|
||||
receivedAtMs: number;
|
||||
scope: CodexThreadRouteScope;
|
||||
};
|
||||
type Route = {
|
||||
threadId: string;
|
||||
controller: AbortController;
|
||||
handlers?: CodexThreadRouteHandlers;
|
||||
released?: Error;
|
||||
ended: Deferred;
|
||||
activated: Deferred;
|
||||
gate: "open" | "armed" | "bound";
|
||||
binding?: Deferred;
|
||||
turnId?: string;
|
||||
pending: PendingNotification[];
|
||||
notificationTail: Promise<void>;
|
||||
nativeTurnCompleted: boolean;
|
||||
nativeTurnCompletion?: Deferred;
|
||||
detachReleaseOn?: () => void;
|
||||
};
|
||||
type NativeTurnCompletionWatcher = {
|
||||
turnId: string;
|
||||
finish: (completed: boolean) => void;
|
||||
touch: () => void;
|
||||
};
|
||||
|
||||
const routers = new WeakMap<CodexAppServerClient, ClientTurnRouter>();
|
||||
|
||||
/** Returns the sole router installed on a physical app-server client. */
|
||||
export function getCodexAppServerTurnRouter(
|
||||
client: CodexAppServerClient,
|
||||
): CodexAppServerTurnRouter {
|
||||
const existing = routers.get(client);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
const router = new ClientTurnRouter(client);
|
||||
routers.set(client, router);
|
||||
return router;
|
||||
}
|
||||
|
||||
class ClientTurnRouter implements CodexAppServerTurnRouter {
|
||||
private readonly routes = new Map<string, Route>();
|
||||
private readonly nativeTurnCompletionWatchers = new Map<
|
||||
string,
|
||||
Set<NativeTurnCompletionWatcher>
|
||||
>();
|
||||
private disposed = false;
|
||||
|
||||
constructor(client: CodexAppServerClient) {
|
||||
client.addNotificationHandler((notification) => this.routeNotification(notification));
|
||||
client.addRequestHandler((request) => this.routeRequest(request));
|
||||
client.addCloseHandler(() => this.dispose());
|
||||
}
|
||||
|
||||
reserveThread(options: RouteOptions): CodexThreadRouteReservation {
|
||||
this.assertActive();
|
||||
const threadId = requireId(options.threadId, "thread id");
|
||||
if (this.routes.has(threadId)) {
|
||||
throw new Error(`codex app-server thread route already reserved: ${threadId}`);
|
||||
}
|
||||
const route: Route = {
|
||||
threadId,
|
||||
controller: new AbortController(),
|
||||
ended: deferred(),
|
||||
activated: deferred(),
|
||||
gate: "open",
|
||||
pending: [],
|
||||
notificationTail: Promise.resolve(),
|
||||
nativeTurnCompleted: false,
|
||||
};
|
||||
this.routes.set(threadId, route);
|
||||
if (options.onNotification || options.onRequest) {
|
||||
this.activateNow(route, options);
|
||||
}
|
||||
const releaseOn = options.releaseOn;
|
||||
if (releaseOn) {
|
||||
const release = () => this.release(route, abortReason(releaseOn));
|
||||
releaseOn.addEventListener("abort", release, { once: true });
|
||||
route.detachReleaseOn = () => releaseOn.removeEventListener("abort", release);
|
||||
if (releaseOn.aborted) {
|
||||
release();
|
||||
}
|
||||
}
|
||||
return {
|
||||
threadId,
|
||||
signal: route.controller.signal,
|
||||
activate: (handlers) => this.activate(route, handlers),
|
||||
armTurn: () => this.armTurn(route),
|
||||
bindTurn: (turnId) => this.bindTurn(route, turnId),
|
||||
cancelTurn: () => this.cancelTurn(route),
|
||||
waitForTurnCompletion: (waitOptions) => this.waitForTurnCompletion(route, waitOptions),
|
||||
drain: () => this.drainNotifications(route),
|
||||
release: () => this.release(route),
|
||||
};
|
||||
}
|
||||
|
||||
watchNativeTurnCompletion(options: {
|
||||
threadId: string;
|
||||
turnId: string;
|
||||
timeoutMs: number;
|
||||
}): CodexNativeTurnCompletionWatch {
|
||||
this.assertActive();
|
||||
const threadId = requireId(options.threadId, "thread id");
|
||||
const turnId = requireId(options.turnId, "turn id");
|
||||
let settle!: (completed: boolean) => void;
|
||||
const completion = new Promise<boolean>((resolve) => {
|
||||
settle = resolve;
|
||||
});
|
||||
const watchers =
|
||||
this.nativeTurnCompletionWatchers.get(threadId) ?? new Set<NativeTurnCompletionWatcher>();
|
||||
this.nativeTurnCompletionWatchers.set(threadId, watchers);
|
||||
let settled = false;
|
||||
const finish = (completed: boolean) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
watchers.delete(watcher);
|
||||
if (watchers.size === 0) {
|
||||
this.nativeTurnCompletionWatchers.delete(threadId);
|
||||
}
|
||||
clearTimeout(timeout);
|
||||
settle(completed);
|
||||
};
|
||||
const touch = () => {
|
||||
timeout.refresh();
|
||||
};
|
||||
const watcher = { turnId, finish, touch };
|
||||
watchers.add(watcher);
|
||||
const timeout = setTimeout(() => finish(false), Math.max(1, options.timeoutMs));
|
||||
timeout.unref?.();
|
||||
return { completion, cancel: () => finish(false) };
|
||||
}
|
||||
|
||||
private dispose(): void {
|
||||
if (this.disposed) {
|
||||
return;
|
||||
}
|
||||
this.disposed = true;
|
||||
for (const route of this.routes.values()) {
|
||||
this.release(route, new Error("codex app-server turn router closed"));
|
||||
}
|
||||
for (const watchers of this.nativeTurnCompletionWatchers.values()) {
|
||||
for (const watcher of watchers) {
|
||||
watcher.finish(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async activate(route: Route, handlers: CodexThreadRouteHandlers): Promise<void> {
|
||||
this.assertRoute(route);
|
||||
this.activateNow(route, handlers);
|
||||
await this.waitForNotifications(route);
|
||||
this.assertRoute(route);
|
||||
}
|
||||
|
||||
private activateNow(route: Route, handlers: CodexThreadRouteHandlers): void {
|
||||
if (route.handlers) {
|
||||
throw new Error(`codex app-server thread route already activated: ${route.threadId}`);
|
||||
}
|
||||
this.assertRoute(route);
|
||||
if (!handlers.onNotification && !handlers.onRequest) {
|
||||
throw new Error("codex app-server thread route requires a notification or request handler");
|
||||
}
|
||||
route.handlers = handlers;
|
||||
if (!handlers.onNotification) {
|
||||
route.pending.length = 0;
|
||||
} else if (route.gate !== "armed") {
|
||||
this.flushNotifications(route);
|
||||
}
|
||||
route.activated.resolve();
|
||||
}
|
||||
|
||||
private armTurn(route: Route): void {
|
||||
this.assertRoute(route);
|
||||
if (route.gate !== "open") {
|
||||
throw new Error(`codex app-server thread route cannot arm from ${route.gate}`);
|
||||
}
|
||||
route.gate = "armed";
|
||||
route.nativeTurnCompleted = false;
|
||||
route.binding = deferred();
|
||||
}
|
||||
|
||||
private async cancelTurn(route: Route): Promise<void> {
|
||||
if (route.released || route.gate !== "armed") {
|
||||
return;
|
||||
}
|
||||
route.gate = "open";
|
||||
route.binding?.resolve();
|
||||
route.binding = undefined;
|
||||
this.flushNotifications(route);
|
||||
await this.waitForNotifications(route);
|
||||
this.assertRoute(route);
|
||||
}
|
||||
|
||||
private async bindTurn(route: Route, turnIdInput: string): Promise<void> {
|
||||
this.assertRoute(route);
|
||||
if (!route.handlers) {
|
||||
throw new Error("codex app-server thread route must be activated before binding a turn");
|
||||
}
|
||||
if (route.gate !== "armed") {
|
||||
throw new Error(`codex app-server thread route cannot bind from ${route.gate}`);
|
||||
}
|
||||
const turnId = requireId(turnIdInput, "turn id");
|
||||
route.gate = "bound";
|
||||
route.turnId = turnId;
|
||||
this.flushNotifications(route);
|
||||
route.binding?.resolve();
|
||||
await this.waitForNotifications(route);
|
||||
this.assertRoute(route);
|
||||
}
|
||||
|
||||
private routeNotification(notification: CodexServerNotification): void {
|
||||
if (this.disposed) {
|
||||
return;
|
||||
}
|
||||
const scope = readScope(notification.params);
|
||||
const watchers = scope.threadId
|
||||
? this.nativeTurnCompletionWatchers.get(scope.threadId)
|
||||
: undefined;
|
||||
const route = scope.threadId ? this.routes.get(scope.threadId) : undefined;
|
||||
if (!watchers && !route) {
|
||||
return;
|
||||
}
|
||||
const terminal = isCodexTerminalTurnNotification(notification);
|
||||
if (scope.turnId && watchers) {
|
||||
for (const watcher of watchers) {
|
||||
if (watcher.turnId === scope.turnId) {
|
||||
if (terminal) {
|
||||
watcher.finish(true);
|
||||
} else {
|
||||
watcher.touch();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!route) {
|
||||
return;
|
||||
}
|
||||
const routeScope: CodexThreadRouteScope = {
|
||||
threadId: route.threadId,
|
||||
...(scope.turnId ? { turnId: scope.turnId } : {}),
|
||||
};
|
||||
const receivedAtMs = Date.now();
|
||||
if (route.gate !== "bound" && terminal) {
|
||||
if (route.nativeTurnCompletion) {
|
||||
route.nativeTurnCompletion.resolve();
|
||||
} else {
|
||||
route.nativeTurnCompleted = true;
|
||||
}
|
||||
}
|
||||
if (!route.handlers) {
|
||||
this.bufferNotification(route, notification, routeScope, receivedAtMs);
|
||||
return;
|
||||
}
|
||||
const handler = route.handlers.onNotification;
|
||||
if (!handler) {
|
||||
return;
|
||||
}
|
||||
if (route.gate === "bound" && scope.turnId && scope.turnId !== route.turnId) {
|
||||
return;
|
||||
}
|
||||
if (route.gate === "armed") {
|
||||
this.bufferNotification(route, notification, routeScope, receivedAtMs);
|
||||
return;
|
||||
}
|
||||
route.handlers.onNotificationReceived?.(notification, routeScope, receivedAtMs);
|
||||
this.enqueueNotification(route, handler, notification, routeScope);
|
||||
}
|
||||
|
||||
private async routeRequest(request: CodexAppServerServerRequest): Promise<JsonValue | undefined> {
|
||||
if (this.disposed) {
|
||||
return undefined;
|
||||
}
|
||||
const scope = readScope(request.params);
|
||||
if (!scope.threadId) {
|
||||
return undefined;
|
||||
}
|
||||
const route = this.routes.get(scope.threadId);
|
||||
if (!route || route.released) {
|
||||
return undefined;
|
||||
}
|
||||
if (!route.handlers) {
|
||||
await route.activated.promise;
|
||||
}
|
||||
if (route.released || !route.handlers) {
|
||||
return undefined;
|
||||
}
|
||||
const handler = route.handlers.onRequest;
|
||||
if (!handler) {
|
||||
return undefined;
|
||||
}
|
||||
// Open routes service a resumed native turn. Arming starts the handoff to a
|
||||
// new OpenClaw turn, whose requests must wait for its accepted turn id.
|
||||
while (route.gate === "armed") {
|
||||
await route.binding?.promise;
|
||||
if (route.released) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
if (route.gate === "bound") {
|
||||
if (scope.turnId && scope.turnId !== route.turnId) {
|
||||
return undefined;
|
||||
}
|
||||
if (route.released) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
await this.waitForNotifications(route);
|
||||
if (route.released) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const result = await handler(request, {
|
||||
threadId: scope.threadId,
|
||||
...(scope.turnId ? { turnId: scope.turnId } : {}),
|
||||
});
|
||||
return route.released ? undefined : result;
|
||||
} catch (error) {
|
||||
if (route.released) {
|
||||
return undefined;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private flushNotifications(route: Route): void {
|
||||
const handler = route.handlers?.onNotification;
|
||||
if (!handler) {
|
||||
return;
|
||||
}
|
||||
for (const pending of route.pending.splice(0)) {
|
||||
if (
|
||||
!pending.scope.turnId ||
|
||||
route.gate !== "bound" ||
|
||||
pending.scope.turnId === route.turnId
|
||||
) {
|
||||
route.handlers?.onNotificationReceived?.(
|
||||
pending.notification,
|
||||
pending.scope,
|
||||
pending.receivedAtMs,
|
||||
);
|
||||
this.enqueueNotification(route, handler, pending.notification, pending.scope);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private bufferNotification(
|
||||
route: Route,
|
||||
notification: CodexServerNotification,
|
||||
scope: CodexThreadRouteScope,
|
||||
receivedAtMs: number,
|
||||
): void {
|
||||
if (route.pending.length < DEFAULT_PREBIND_NOTIFICATION_LIMIT) {
|
||||
route.pending.push({ notification, receivedAtMs, scope });
|
||||
return;
|
||||
}
|
||||
const error = new Error(
|
||||
`codex app-server pre-bind notification buffer exceeded ${DEFAULT_PREBIND_NOTIFICATION_LIMIT} entries for thread ${route.threadId}`,
|
||||
);
|
||||
embeddedAgentLog.warn(error.message);
|
||||
this.release(route, error);
|
||||
}
|
||||
|
||||
private enqueueNotification(
|
||||
route: Route,
|
||||
handler: CodexThreadNotificationHandler,
|
||||
notification: CodexServerNotification,
|
||||
scope: CodexThreadRouteScope,
|
||||
): void {
|
||||
if (route.released) {
|
||||
return;
|
||||
}
|
||||
route.notificationTail = route.notificationTail
|
||||
.then(() => handler(notification, scope))
|
||||
.catch((error: unknown) => {
|
||||
if (!route.released) {
|
||||
embeddedAgentLog.warn("codex app-server keyed notification handler failed", {
|
||||
method: notification.method,
|
||||
threadId: route.threadId,
|
||||
turnId: route.turnId,
|
||||
error,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async waitForNotifications(route: Route): Promise<void> {
|
||||
await Promise.race([route.notificationTail, route.ended.promise]);
|
||||
}
|
||||
|
||||
private async drainNotifications(route: Route): Promise<void> {
|
||||
await route.notificationTail;
|
||||
}
|
||||
|
||||
private async waitForTurnCompletion(
|
||||
route: Route,
|
||||
options: { timeoutMs: number; signal?: AbortSignal },
|
||||
): Promise<boolean> {
|
||||
this.assertRoute(route);
|
||||
if (route.nativeTurnCompleted) {
|
||||
route.nativeTurnCompleted = false;
|
||||
return true;
|
||||
}
|
||||
if (route.nativeTurnCompletion) {
|
||||
throw new Error(`codex app-server turn completion wait already active: ${route.threadId}`);
|
||||
}
|
||||
const completion = deferred();
|
||||
route.nativeTurnCompletion = completion;
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
let removeAbort: (() => void) | undefined;
|
||||
const timedOut = new Promise<boolean>((resolve) => {
|
||||
timeout = setTimeout(() => resolve(false), Math.max(1, options.timeoutMs));
|
||||
});
|
||||
const aborted = new Promise<boolean>((resolve) => {
|
||||
const signal = options.signal;
|
||||
if (!signal) {
|
||||
return;
|
||||
}
|
||||
const onAbort = () => resolve(false);
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
removeAbort = () => signal.removeEventListener("abort", onAbort);
|
||||
if (signal.aborted) {
|
||||
onAbort();
|
||||
}
|
||||
});
|
||||
try {
|
||||
return await Promise.race([
|
||||
completion.promise.then(() => true),
|
||||
route.ended.promise.then(() => false),
|
||||
timedOut,
|
||||
aborted,
|
||||
]);
|
||||
} finally {
|
||||
if (route.nativeTurnCompletion === completion) {
|
||||
route.nativeTurnCompletion = undefined;
|
||||
}
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
removeAbort?.();
|
||||
}
|
||||
}
|
||||
|
||||
private release(route: Route, error = new Error("codex app-server thread route is released")) {
|
||||
if (route.released) {
|
||||
return;
|
||||
}
|
||||
route.released = error;
|
||||
route.pending.length = 0;
|
||||
route.ended.resolve();
|
||||
route.activated.resolve();
|
||||
route.binding?.resolve();
|
||||
route.detachReleaseOn?.();
|
||||
route.controller.abort(error);
|
||||
if (this.routes.get(route.threadId) === route) {
|
||||
this.routes.delete(route.threadId);
|
||||
}
|
||||
}
|
||||
|
||||
private assertActive(): void {
|
||||
if (this.disposed) {
|
||||
throw new Error("codex app-server turn router is closed");
|
||||
}
|
||||
}
|
||||
|
||||
private assertRoute(route: Route): void {
|
||||
if (route.released) {
|
||||
throw route.released;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** True after Codex will not continue the exact turn. */
|
||||
export function isCodexTerminalTurnNotification(notification: CodexServerNotification): boolean {
|
||||
if (notification.method === "turn/completed") {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
notification.method === "error" &&
|
||||
isJsonObject(notification.params) &&
|
||||
notification.params.willRetry === false
|
||||
);
|
||||
}
|
||||
|
||||
function deferred(): Deferred {
|
||||
let resolve!: () => void;
|
||||
const promise = new Promise<void>((resolvePromise) => {
|
||||
resolve = resolvePromise;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
function abortReason(signal: AbortSignal): Error {
|
||||
return signal.reason instanceof Error
|
||||
? signal.reason
|
||||
: new Error(String(signal.reason ?? "codex app-server thread route aborted"));
|
||||
}
|
||||
|
||||
function readScope(value: JsonValue | undefined) {
|
||||
if (!isJsonObject(value)) {
|
||||
return {};
|
||||
}
|
||||
const threadId = readCodexNotificationThreadId(value);
|
||||
const turnId = readCodexNotificationTurnId(value);
|
||||
return { ...(threadId ? { threadId } : {}), ...(turnId ? { turnId } : {}) };
|
||||
}
|
||||
|
||||
function requireId(value: string, label: string): string {
|
||||
const normalized = value.trim();
|
||||
if (!normalized) {
|
||||
throw new Error(`codex app-server ${label} must not be empty`);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
@@ -16,7 +16,11 @@ import {
|
||||
type JsonObject,
|
||||
type JsonValue,
|
||||
} from "./protocol.js";
|
||||
import { readRecentCodexRateLimits, rememberCodexRateLimits } from "./rate-limit-cache.js";
|
||||
import {
|
||||
readCodexRateLimitsRevision,
|
||||
readRecentCodexRateLimits,
|
||||
rememberCodexRateLimitsRead,
|
||||
} from "./rate-limit-cache.js";
|
||||
import {
|
||||
formatCodexUsageLimitErrorMessage,
|
||||
resolveCodexUsageLimitResetAtMs,
|
||||
@@ -73,13 +77,19 @@ export async function markCodexAuthProfileBlockedFromRateLimits(params: {
|
||||
export async function formatCodexTurnStartUsageLimitError(params: {
|
||||
client: CodexAppServerClient;
|
||||
error: unknown;
|
||||
pendingNotifications: CodexServerNotification[];
|
||||
errorNotification?: CodexServerNotification;
|
||||
rateLimitsRevisionBeforeTurnStart?: number;
|
||||
timeoutMs?: number;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<CodexUsageLimitErrorResult | undefined> {
|
||||
return refreshCodexUsageLimitError({
|
||||
client: params.client,
|
||||
source: readCodexTurnStartUsageLimitErrorSource(params.error, params.pendingNotifications),
|
||||
source: readCodexTurnStartUsageLimitErrorSource(
|
||||
params.client,
|
||||
params.error,
|
||||
params.errorNotification,
|
||||
params.rateLimitsRevisionBeforeTurnStart,
|
||||
),
|
||||
timeoutMs: params.timeoutMs,
|
||||
signal: params.signal,
|
||||
});
|
||||
@@ -101,7 +111,7 @@ export async function refreshCodexUsageLimitPromptError(params: {
|
||||
source: {
|
||||
message: params.message,
|
||||
codexErrorInfo: "usageLimitExceeded",
|
||||
rateLimits: readRecentCodexRateLimits(),
|
||||
rateLimits: readRecentCodexRateLimits(params.client),
|
||||
},
|
||||
timeoutMs: params.timeoutMs,
|
||||
signal: params.signal,
|
||||
@@ -163,7 +173,7 @@ async function readCodexRateLimitsFromAppServerForUsageLimitError(params: {
|
||||
timeoutMs: resolveCodexUsageLimitRateLimitRefreshTimeoutMs(params.timeoutMs),
|
||||
signal: params.signal,
|
||||
});
|
||||
rememberCodexRateLimits(rateLimits);
|
||||
rememberCodexRateLimitsRead(params.client, rateLimits);
|
||||
return rateLimits;
|
||||
} catch (error) {
|
||||
embeddedAgentLog.debug("codex app-server rate-limit refresh failed after usage-limit error", {
|
||||
@@ -181,54 +191,39 @@ function resolveCodexUsageLimitRateLimitRefreshTimeoutMs(timeoutMs: number | und
|
||||
}
|
||||
|
||||
function readCodexTurnStartUsageLimitErrorSource(
|
||||
client: CodexAppServerClient,
|
||||
error: unknown,
|
||||
pendingNotifications: CodexServerNotification[],
|
||||
errorNotification: CodexServerNotification | undefined,
|
||||
rateLimitsRevisionBeforeTurnStart: number | undefined,
|
||||
): CodexUsageLimitErrorSource {
|
||||
const notificationError = readLatestCodexErrorNotification(pendingNotifications);
|
||||
const notificationRateLimits = readLatestRateLimitNotificationPayload(pendingNotifications);
|
||||
const notificationError = readCodexErrorNotification(errorNotification);
|
||||
const errorPayload = readCodexErrorPayload(error);
|
||||
const rateLimits =
|
||||
notificationRateLimits ?? errorPayload.rateLimits ?? readRecentCodexRateLimits();
|
||||
const rateLimits = errorPayload.rateLimits ?? readRecentCodexRateLimits(client);
|
||||
const cacheUpdatedDuringTurnStart =
|
||||
rateLimitsRevisionBeforeTurnStart !== undefined &&
|
||||
readCodexRateLimitsRevision(client) > rateLimitsRevisionBeforeTurnStart;
|
||||
return {
|
||||
message: notificationError?.message ?? errorPayload.message ?? formatErrorMessage(error),
|
||||
codexErrorInfo: notificationError?.codexErrorInfo ?? errorPayload.codexErrorInfo,
|
||||
rateLimits,
|
||||
rateLimitsTrustedForProfile:
|
||||
notificationRateLimits !== undefined || errorPayload.rateLimits !== undefined,
|
||||
errorPayload.rateLimits !== undefined || cacheUpdatedDuringTurnStart,
|
||||
};
|
||||
}
|
||||
|
||||
function readLatestRateLimitNotificationPayload(
|
||||
notifications: CodexServerNotification[],
|
||||
): JsonValue | undefined {
|
||||
for (let index = notifications.length - 1; index >= 0; index -= 1) {
|
||||
const notification = notifications[index];
|
||||
if (notification?.method === "account/rateLimits/updated") {
|
||||
rememberCodexRateLimits(notification.params);
|
||||
return notification.params;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function readLatestCodexErrorNotification(
|
||||
notifications: CodexServerNotification[],
|
||||
function readCodexErrorNotification(
|
||||
notification: CodexServerNotification | undefined,
|
||||
): { message?: string; codexErrorInfo?: JsonValue | null } | undefined {
|
||||
for (let index = notifications.length - 1; index >= 0; index -= 1) {
|
||||
const notification = notifications[index];
|
||||
if (notification?.method !== "error" || !isJsonObject(notification.params)) {
|
||||
continue;
|
||||
}
|
||||
const error = notification.params.error;
|
||||
if (!isJsonObject(error)) {
|
||||
continue;
|
||||
}
|
||||
return {
|
||||
message: readString(error, "message"),
|
||||
codexErrorInfo: error.codexErrorInfo,
|
||||
};
|
||||
if (notification?.method !== "error" || !isJsonObject(notification.params)) {
|
||||
return undefined;
|
||||
}
|
||||
return undefined;
|
||||
const error = notification.params.error;
|
||||
return isJsonObject(error)
|
||||
? {
|
||||
message: readString(error, "message"),
|
||||
codexErrorInfo: error.codexErrorInfo,
|
||||
}
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function readCodexErrorPayload(error: unknown): {
|
||||
@@ -246,9 +241,6 @@ function readCodexErrorPayload(error: unknown): {
|
||||
}
|
||||
const nestedError = isJsonObject(data.error) ? data.error : data;
|
||||
const rateLimits = nestedError.rateLimits ?? data.rateLimits;
|
||||
if (rateLimits !== undefined) {
|
||||
rememberCodexRateLimits(rateLimits);
|
||||
}
|
||||
return {
|
||||
message: readString(nestedError, "message") ?? message,
|
||||
codexErrorInfo: nestedError.codexErrorInfo,
|
||||
|
||||
@@ -14,7 +14,6 @@ import type { PluginCommandContext } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { normalizeUniqueStringEntries } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { CODEX_CONTROL_METHODS, type CodexControlMethod } from "./app-server/capabilities.js";
|
||||
import { isJsonObject, type JsonObject, type JsonValue } from "./app-server/protocol.js";
|
||||
import { rememberCodexRateLimits } from "./app-server/rate-limit-cache.js";
|
||||
import {
|
||||
summarizeCodexAccountUsage,
|
||||
type CodexAccountUsageSummary,
|
||||
@@ -57,9 +56,13 @@ export async function readCodexAccountAuthOverview(params: {
|
||||
safeCodexControlRequest: SafeCodexControlRequest;
|
||||
account: SafeValue<JsonValue | undefined>;
|
||||
limits: SafeValue<JsonValue | undefined>;
|
||||
scope: Pick<
|
||||
CodexControlRequestOptions,
|
||||
"agentDir" | "authProfileId" | "sessionId" | "sessionKey"
|
||||
>;
|
||||
}): Promise<CodexAccountAuthOverview | undefined> {
|
||||
const config = params.ctx.config;
|
||||
const agentDir = resolveDefaultAgentDir(config);
|
||||
const agentDir = params.scope.agentDir ?? resolveDefaultAgentDir(config);
|
||||
const store = ensureAuthProfileStore(agentDir, {
|
||||
allowKeychainPrompt: false,
|
||||
config,
|
||||
@@ -317,6 +320,10 @@ async function readSubscriptionUsage(params: {
|
||||
config: AuthProfileOrderConfig;
|
||||
subscriptionProfileId: string;
|
||||
now: number;
|
||||
scope: Pick<
|
||||
CodexControlRequestOptions,
|
||||
"agentDir" | "authProfileId" | "sessionId" | "sessionKey"
|
||||
>;
|
||||
}): Promise<CodexAccountUsageSummary | undefined> {
|
||||
const limits = await params.safeCodexControlRequest(
|
||||
params.pluginConfig,
|
||||
@@ -324,6 +331,7 @@ async function readSubscriptionUsage(params: {
|
||||
undefined,
|
||||
{
|
||||
config: params.config,
|
||||
...params.scope,
|
||||
authProfileId: params.subscriptionProfileId,
|
||||
isolated: true,
|
||||
},
|
||||
@@ -331,7 +339,6 @@ async function readSubscriptionUsage(params: {
|
||||
if (!limits.ok) {
|
||||
return undefined;
|
||||
}
|
||||
rememberCodexRateLimits(limits.value);
|
||||
return summarizeCodexAccountUsage(limits.value, params.now);
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -24,6 +24,7 @@ type AuthProfileOrderConfig = Parameters<
|
||||
export type CodexControlRequestOptions = {
|
||||
config?: AuthProfileOrderConfig;
|
||||
authProfileId?: string;
|
||||
agentId?: string;
|
||||
agentDir?: string;
|
||||
sessionKey?: string;
|
||||
sessionId?: string;
|
||||
@@ -34,6 +35,10 @@ export function requestOptions(
|
||||
pluginConfig: unknown,
|
||||
limit: number,
|
||||
config?: AuthProfileOrderConfig,
|
||||
scope: Pick<
|
||||
CodexControlRequestOptions,
|
||||
"agentDir" | "agentId" | "authProfileId" | "sessionId" | "sessionKey"
|
||||
> = {},
|
||||
) {
|
||||
const runtime = resolveCodexAppServerRuntimeOptions({ pluginConfig });
|
||||
return {
|
||||
@@ -41,6 +46,7 @@ export function requestOptions(
|
||||
timeoutMs: runtime.requestTimeoutMs,
|
||||
startOptions: runtime.start,
|
||||
config,
|
||||
...scope,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -63,7 +69,7 @@ export async function codexControlRequest(
|
||||
method: CodexControlMethod,
|
||||
requestParams?: unknown,
|
||||
options: CodexControlRequestOptions = {},
|
||||
) {
|
||||
): Promise<unknown> {
|
||||
const runtime = resolveCodexAppServerRuntimeOptions({ pluginConfig });
|
||||
return await requestCodexAppServerJson({
|
||||
method,
|
||||
@@ -71,6 +77,7 @@ export async function codexControlRequest(
|
||||
timeoutMs: runtime.requestTimeoutMs,
|
||||
startOptions: runtime.start,
|
||||
config: options.config,
|
||||
agentId: options.agentId,
|
||||
sessionKey: options.sessionKey,
|
||||
sessionId: options.sessionId,
|
||||
authProfileId: options.authProfileId,
|
||||
@@ -96,7 +103,7 @@ export async function safeCodexControlRequest(
|
||||
method: CodexControlMethod,
|
||||
requestParams?: unknown,
|
||||
options: CodexControlRequestOptions = {},
|
||||
) {
|
||||
): Promise<SafeValue<unknown>> {
|
||||
return await safeValue(
|
||||
async () =>
|
||||
await codexControlRequest(pluginConfig, method, requestParams as JsonValue, options),
|
||||
@@ -107,32 +114,51 @@ async function safeCodexModelList(
|
||||
pluginConfig: unknown,
|
||||
limit: number,
|
||||
config?: AuthProfileOrderConfig,
|
||||
scope?: Pick<
|
||||
CodexControlRequestOptions,
|
||||
"agentDir" | "agentId" | "authProfileId" | "sessionId" | "sessionKey"
|
||||
>,
|
||||
) {
|
||||
return await safeValue(
|
||||
async () => await listCodexAppServerModels(requestOptions(pluginConfig, limit, config)),
|
||||
async () => await listCodexAppServerModels(requestOptions(pluginConfig, limit, config, scope)),
|
||||
);
|
||||
}
|
||||
|
||||
export async function readCodexStatusProbes(
|
||||
pluginConfig: unknown,
|
||||
config?: AuthProfileOrderConfig,
|
||||
scope: Pick<
|
||||
CodexControlRequestOptions,
|
||||
"agentDir" | "agentId" | "authProfileId" | "sessionId" | "sessionKey"
|
||||
> = {},
|
||||
) {
|
||||
const [models, account, limits, mcps, skills] = await Promise.all([
|
||||
safeCodexModelList(pluginConfig, 20, config),
|
||||
safeCodexModelList(pluginConfig, 20, config, scope),
|
||||
safeCodexControlRequest(
|
||||
pluginConfig,
|
||||
CODEX_CONTROL_METHODS.account,
|
||||
{ refreshToken: false },
|
||||
{ config },
|
||||
{ config, ...scope },
|
||||
),
|
||||
safeCodexControlRequest(pluginConfig, CODEX_CONTROL_METHODS.rateLimits, undefined, { config }),
|
||||
safeCodexControlRequest(pluginConfig, CODEX_CONTROL_METHODS.rateLimits, undefined, {
|
||||
config,
|
||||
...scope,
|
||||
}),
|
||||
safeCodexControlRequest(
|
||||
pluginConfig,
|
||||
CODEX_CONTROL_METHODS.listMcpServers,
|
||||
{ limit: 100 },
|
||||
{ config },
|
||||
{ config, ...scope },
|
||||
),
|
||||
safeCodexControlRequest(
|
||||
pluginConfig,
|
||||
CODEX_CONTROL_METHODS.listSkills,
|
||||
{},
|
||||
{
|
||||
config,
|
||||
...scope,
|
||||
},
|
||||
),
|
||||
safeCodexControlRequest(pluginConfig, CODEX_CONTROL_METHODS.listSkills, {}, { config }),
|
||||
]);
|
||||
|
||||
return { models, account, limits, mcps, skills };
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user